[java-idp-plugin-webauthn] 06/11: JWEBAUTHN-27 - Add basic authenticator policy

Phil Smart philip.smart at jisc.ac.uk
Fri Oct 18 17:13:31 UTC 2024


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

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

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

commit 9f105b36d189a3a941a89fad61e0045e01ceb3e4
Author: Phil Smart <philip.smart at jisc.ac.uk>
AuthorDate: Tue Oct 8 14:34:31 2024 +0100

    JWEBAUTHN-27 - Add basic authenticator policy
    
     - Add credential policy checking to authentication flow
     - Add default chain policy rules
     - Add default 2faOnly rule
    
    https://shibboleth.atlassian.net/browse/JWEBAUTHN-27
---
 .../authn/WebAuthnAuthenticationEventIds.java      |   4 +
 .../context/WebAuthnAuthenticationContext.java     |  24 +++
 .../WebAuthnErrorMessageLookupFunction.java        | 127 ++++++++++++++++
 .../authn/webauthn/policy/CredentialPolicy.java    |  63 ++++++++
 .../webauthn/storage/CredentialRegistration.java   |  10 ++
 .../storage/WebAuthnCredentialRepository.java      |  12 ++
 .../authn/webauthn/impl/CheckCredentialPolicy.java | 167 +++++++++++++++++++++
 .../policy/impl/AbstractCredentialPolicyRule.java  | 159 ++++++++++++++++++++
 .../impl/SecondFactorOnlyCredentialPolicyRule.java |  81 ++++++++++
 .../IdPStorageServiceCredentialRespository.java    |  19 +++
 .../idp/flows/authn/WebAuthn/webauthn-beans.xml    |  23 +++
 .../idp/flows/authn/WebAuthn/webauthn-flow.xml     |   6 +
 .../authn/webauthn/conf/authn/webauthn.properties  |   7 +
 .../idp/plugin/authn/webauthn/messages.properties  |   5 +-
 .../webauthn/flow/AbstractWebAuthnFlowTest.java    |  20 +++
 .../flow/TestUsernameslessFlowWithPolicy.java      | 109 ++++++++++++++
 ...hnEnvironmentApplicationContextInitializer.java |   2 +-
 ...ssWithPolicyApplicationContextInitializer.java} |  19 +--
 .../authn/webauthn/impl/AbstractWebAuthnTest.java  |  17 ++-
 .../SecondFactorOnlyCredentialPolicyRuleTest.java  |  67 +++++++++
 .../storage/impl/InMemoryRegistrationStorage.java  |  12 ++
 .../test/resources/logback-webauthn-flow-test.xml  |  25 +++
 22 files changed, 963 insertions(+), 15 deletions(-)

diff --git a/webauthn-api/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/authn/WebAuthnAuthenticationEventIds.java b/webauthn-api/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/authn/WebAuthnAuthenticationEventIds.java
index f08b062..e026d4d 100644
--- a/webauthn-api/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/authn/WebAuthnAuthenticationEventIds.java
+++ b/webauthn-api/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/authn/WebAuthnAuthenticationEventIds.java
@@ -32,6 +32,10 @@ public final class WebAuthnAuthenticationEventIds {
     /** The user has no registered WebAuthn credentials for the user handle supplied. */
     @Nonnull @NotEmpty 
     public static final String USER_HANDLE_NOT_REGISTERED = "UserHandleNotRegistered";
+
+    /** A credential policy rejected the credential/authenticator.*/
+    @Nonnull @NotEmpty 
+    public static final String CREDENTIAL_POLICY_REJECTION = "CredentialPolicyRejection";
     
     /** Private constructor.*/
     private WebAuthnAuthenticationEventIds() {
diff --git a/webauthn-api/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/context/WebAuthnAuthenticationContext.java b/webauthn-api/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/context/WebAuthnAuthenticationContext.java
index fd715b6..67a6a07 100644
--- a/webauthn-api/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/context/WebAuthnAuthenticationContext.java
+++ b/webauthn-api/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/context/WebAuthnAuthenticationContext.java
@@ -23,6 +23,7 @@ import com.yubico.webauthn.data.ClientAssertionExtensionOutputs;
 import com.yubico.webauthn.data.PublicKeyCredential;
 import com.yubico.webauthn.data.PublicKeyCredentialRequestOptions;
 
+import net.shibboleth.idp.plugin.authn.webauthn.storage.CredentialRegistration;
 import net.shibboleth.shared.logic.Constraint;
 
 
@@ -49,6 +50,9 @@ public final class WebAuthnAuthenticationContext extends BaseWebAuthnContext {
    
     /** The public key credential request options for generating an authentication assertion.*/ 
     @Nullable private PublicKeyCredentialRequestOptions publicKeyCredentialRequestOptions;
+    
+    /** The authentication credential signalled by the authenticator.*/
+    @Nullable private CredentialRegistration authenticationCredential;
 
     /**
      * Get the credential Id.
@@ -181,5 +185,25 @@ public final class WebAuthnAuthenticationContext extends BaseWebAuthnContext {
     public boolean isSecondFactor() {
         return secondFactor;
     }
+    
+    /**
+     * Set the credential used by the authenticator for this authentication.
+     * 
+     * @param credential The authentication credential to set.
+     */
+    @Nonnull public WebAuthnAuthenticationContext setAuthenticationCredential(
+            @Nullable final CredentialRegistration credential) {
+        authenticationCredential = credential;
+        return this;
+    }
+    
+    /**
+     * Get the credential used by the authenticator for this authentication.
+     * 
+     * @return the authentication credential.
+     */
+    @Nullable public CredentialRegistration getAuthenticationCredential() {
+        return authenticationCredential;
+    }
 
 }
diff --git a/webauthn-api/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/context/navigate/WebAuthnErrorMessageLookupFunction.java b/webauthn-api/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/context/navigate/WebAuthnErrorMessageLookupFunction.java
new file mode 100644
index 0000000..26e2b97
--- /dev/null
+++ b/webauthn-api/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/context/navigate/WebAuthnErrorMessageLookupFunction.java
@@ -0,0 +1,127 @@
+/*
+ * 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.context.navigate;
+
+import java.util.function.Function;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.opensaml.messaging.context.navigate.ChildContextLookup;
+import org.opensaml.messaging.context.navigate.ContextDataLookupFunction;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.springframework.context.support.ApplicationObjectSupport;
+import org.springframework.context.support.MessageSourceAccessor;
+
+import net.shibboleth.idp.authn.context.AuthenticationContext;
+import net.shibboleth.idp.authn.context.AuthenticationErrorContext;
+import net.shibboleth.idp.plugin.authn.webauthn.context.WebAuthnRegistrationContext;
+import net.shibboleth.idp.plugin.authn.webauthn.context.WebAuthnRegistrationErrorContext;
+import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.primitive.StringSupport;
+
+/**
+ * A function that examines the state of a request and produces an appropriate message for WebAuthn flow views.
+ * 
+ * <p>NOTE: The result of this function is <strong>NOT</strong> HTML-encoded in any way and must
+ * be encoded for safety if used.</p>
+ */
+public class WebAuthnErrorMessageLookupFunction extends ApplicationObjectSupport
+                            implements ContextDataLookupFunction<ProfileRequestContext,String>{
+    
+    /** Message ID to use for generic messages. */
+    private String genericMessageID;
+    
+    /** Lookup strategy to locate the WebAuthn registration information context. */
+    @Nonnull 
+    private Function<ProfileRequestContext,WebAuthnRegistrationErrorContext> webauthnErrorContextLookupStrategy;
+    
+    /** Constructor.*/
+    public WebAuthnErrorMessageLookupFunction() {
+        webauthnErrorContextLookupStrategy = new ChildContextLookup<>(WebAuthnRegistrationErrorContext.class)
+                .compose(new ChildContextLookup<>(WebAuthnRegistrationContext.class));
+    }
+    
+    /**
+     * Set the strategy used to lookup the {@link WebAuthnRegistrationErrorContext}.
+     * 
+     * @param strategy The strategy to set.
+     */
+    public void setWebauthnErrorContextLookupStrategy(
+            final Function<ProfileRequestContext, WebAuthnRegistrationErrorContext> strategy) {
+        webauthnErrorContextLookupStrategy = Constraint.isNotNull(strategy,
+                "WebAuthnRegistrationErrorContextLookupStrategy can not be null");
+    }
+    
+    /**
+     * Sets whether non-message-based error messages should be exposed or turned into a more
+     * generic value.
+     * 
+     * @param id message ID
+     */
+    public void setGenericMessageID(@Nullable final String id) {
+        genericMessageID = StringSupport.trimOrNull(id);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public String apply(@Nullable final ProfileRequestContext input) {
+        
+        final MessageSourceAccessor messageSource = getMessageSourceAccessor();
+        if (messageSource == null) {
+            return null;
+        }
+        
+        final AuthenticationContext authCtx = input != null ? input.getSubcontext(AuthenticationContext.class) : null;
+        final AuthenticationErrorContext errorCtx =
+                authCtx != null ? authCtx.getSubcontext(AuthenticationErrorContext.class) : null;
+
+        if (errorCtx == null) {
+            return null;
+        }
+        
+        final String classifiedError = errorCtx.getLastClassifiedError(); 
+        if (classifiedError != null && !classifiedError.isEmpty()) {
+            return getClassifiedMessage(messageSource, classifiedError);
+        }         
+        return null;
+    }
+    
+    /**
+     * Get classified message.
+     * 
+     * @param messageSource Spring message source
+     * @param classifiedMessage classified message
+     * 
+     * @return mapped message, or null if an empty string was produced.
+     */
+    @Nullable private String getClassifiedMessage(@Nonnull final MessageSourceAccessor messageSource,
+            @Nonnull final String classifiedMessage) {        
+        
+        String message = messageSource.getMessage(classifiedMessage, "");
+        if (message.isEmpty()) {
+            message = messageSource.getMessage( genericMessageID != null ? genericMessageID : 
+                "idp.webauthn.message", "Registration result: " 
+                    + classifiedMessage);
+        }        
+        if (message.isEmpty()) {
+            return null;
+        }
+        return message;
+
+    }
+    
+
+}
diff --git a/webauthn-api/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/policy/CredentialPolicy.java b/webauthn-api/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/policy/CredentialPolicy.java
new file mode 100644
index 0000000..478af5c
--- /dev/null
+++ b/webauthn-api/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/policy/CredentialPolicy.java
@@ -0,0 +1,63 @@
+/*
+ * 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;
+
+import javax.annotation.Nonnull;
+
+import org.opensaml.profile.context.ProfileRequestContext;
+
+import net.shibboleth.idp.plugin.authn.webauthn.storage.CredentialRegistration;
+import net.shibboleth.shared.component.IdentifiedComponent;
+
+/**
+ * An API for applying policy checks to an authentication credential. 
+ */
+public interface CredentialPolicy extends IdentifiedComponent {
+    
+    /**
+     * Representation of the three outcomes of an CredentialPolicy.
+     */
+    public enum CredentialPolicyOutcome {
+        /** Allow the credential to be used. */
+        ACCEPT,
+        /** Reject the credential. */
+        REJECT,
+        /** The policy was not active and should be ignored. */
+        IGNORE;
+        
+        /**
+         * Helper method to create an {@link CredentialPolicyOutcome} from a boolean flag.
+         * 
+         * @param outcome the boolean outcome
+         * @return the outcome appropriate for the given boolean
+         */
+        @Nonnull public static CredentialPolicyOutcome of(final boolean outcome) {
+            return outcome ? CredentialPolicyOutcome.ACCEPT : CredentialPolicyOutcome.REJECT;
+        }
+    }
+    
+    /**
+     * Execute the policy.
+     * 
+     * @param credential the credential to accept or reject.
+     * @param prc the profile request context
+     * 
+     * @return {@link CredentialPolicyOutcome#ACCEPT} if allowed, {@link CredentialPolicyOutcome#REJECT} if rejected, 
+     * and {@link CredentialPolicyOutcome#IGNORE} otherwise.
+     */
+    CredentialPolicyOutcome evaluate(@Nonnull final CredentialRegistration credential, 
+            @Nonnull final ProfileRequestContext prc);
+
+}
diff --git a/webauthn-api/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/storage/CredentialRegistration.java b/webauthn-api/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/storage/CredentialRegistration.java
index 912c45c..05fba1f 100644
--- a/webauthn-api/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/storage/CredentialRegistration.java
+++ b/webauthn-api/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/storage/CredentialRegistration.java
@@ -224,6 +224,16 @@ public final class CredentialRegistration {
         return credential.getCredentialId().getBase64Url();
     }
     
+    /**
+     * Get the credential ID as a Hex encoded string.
+     * 
+     * @return the credential ID Hex encoded
+     */
+    @JsonIgnore
+    public String getCredentialIdHex() {
+        return credential.getCredentialId().getHex();
+    }
+    
     /**
      * Get the capabilities of the authenticator that created this credential.
      * 
diff --git a/webauthn-api/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/storage/WebAuthnCredentialRepository.java b/webauthn-api/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/storage/WebAuthnCredentialRepository.java
index 9d36b49..e85faf9 100644
--- a/webauthn-api/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/storage/WebAuthnCredentialRepository.java
+++ b/webauthn-api/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/storage/WebAuthnCredentialRepository.java
@@ -79,6 +79,18 @@ public interface WebAuthnCredentialRepository extends CredentialRepository {
      */
     @Nonnull Optional<CredentialRegistration> getRegistrationByUsernameAndCredentialId(@Nonnull final String username,
             @Nonnull final ByteArray credentialId);
+    
+    
+    /**
+     * Get the credential belonging to the user by userHandle and credentialID.
+     * 
+     * @param credentialId the identifier of the credential to find
+     * @param userHandle the user handle (user.id)
+     * 
+     * @return the credential registration if found.
+     */
+    Optional<CredentialRegistration> getRegistrationByUserHandleAndCredentialId(@Nonnull final ByteArray credentialId, 
+            @Nonnull final ByteArray userHandle);
 
     /**
      * Remove the registration for the given user.
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
new file mode 100644
index 0000000..8303b7e
--- /dev/null
+++ b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/impl/CheckCredentialPolicy.java
@@ -0,0 +1,167 @@
+/*
+ * 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 javax.annotation.Nullable;
+
+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.context.AuthenticationContext;
+import net.shibboleth.idp.authn.context.AuthenticationErrorContext;
+import net.shibboleth.idp.plugin.authn.webauthn.authn.WebAuthnAuthenticationEventIds;
+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.CredentialRegistration;
+import net.shibboleth.idp.plugin.authn.webauthn.storage.WebAuthnCredentialRepository;
+import net.shibboleth.shared.annotation.constraint.NonnullAfterInit;
+import net.shibboleth.shared.annotation.constraint.NonnullBeforeExec;
+import net.shibboleth.shared.component.ComponentInitializationException;
+import net.shibboleth.shared.primitive.LoggerFactory;
+
+/**
+ * A policy engine action that checks with the configured policy if the credential can be used to authenticate. 
+ * 
+ * @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
+ */
+public class CheckCredentialPolicy extends AbstractWebAuthnAction<WebAuthnAuthenticationContext> {
+    
+    /** Class logger. */
+    @Nonnull private final Logger log = LoggerFactory.getLogger(CheckCredentialPolicy.class);
+
+    /** The stashed assertion response.*/
+    @NonnullBeforeExec 
+    private PublicKeyCredential<AuthenticatorAssertionResponse, ClientAssertionExtensionOutputs> assertion;
+    
+    /** The credential policy to check.*/
+    @Nullable private CredentialPolicy credentialPolicy;
+    
+    /** The credential repository to use.*/
+    @NonnullAfterInit private WebAuthnCredentialRepository repository;
+
+    /** The stashed authentication context.*/
+    @NonnullBeforeExec private AuthenticationContext authnContext;
+
+    /**
+     * Constructor.
+     */
+    protected CheckCredentialPolicy() {
+        super(new ChildContextLookup<>(WebAuthnAuthenticationContext.class).
+                compose(new ChildContextLookup<>(AuthenticationContext.class)));
+    }
+    
+    /**
+     * 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;
+    }
+    
+    /** {@inheritDoc} */
+    @Override
+    protected void doInitialize() throws ComponentInitializationException {
+        super.doInitialize();
+        repository = getCredentialRepository();
+        if (repository == null) {
+            throw new ComponentInitializationException("Credential repository can not be null");
+        }
+    }
+    
+    /** {@inheritDoc} */
+    @Override
+    protected boolean doPreExecute(@Nonnull final ProfileRequestContext profileRequestContext,
+            @Nonnull final WebAuthnAuthenticationContext context) {
+        
+        if (!super.doPreExecute(profileRequestContext, context)) {
+            return false;
+        }
+       
+        assertion = context.getPublicKeyCredentialAssertionResponse();
+        if (assertion == null) {
+            log.error("{} Assertion not available in registration context", getLogPrefix());
+            ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_PROFILE_CTX);
+            return false;
+        }
+        
+        authnContext = profileRequestContext.getSubcontext(AuthenticationContext.class);
+        if (authnContext == null) {
+            log.error("{} Authentication context not available", 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 CredentialPolicy localPolicy = credentialPolicy;
+        if (localPolicy == null) {
+            // If no policy, nothing can be applied
+            log.trace("{} No authenticator policy to apply", getLogPrefix());
+            return;
+        }        
+       
+        final Optional<ByteArray> userHandle = assertion.getResponse().getUserHandle();
+        if (userHandle.isEmpty()) {
+            log.trace("{} UserHandle could not be found in the response",getLogPrefix());
+            return;
+        }
+        
+        final Optional<CredentialRegistration> credential = 
+                repository.getRegistrationByUserHandleAndCredentialId(assertion.getId(), userHandle.get());
+        if (credential.isEmpty()) {
+            log.trace("{} UserHandle '{}' has no registered credential",getLogPrefix(), userHandle.get().getHex());
+            return;
+        }
+        
+        final CredentialPolicyOutcome outcome = localPolicy.evaluate(credential.get(), profileRequestContext);
+        if (outcome == CredentialPolicyOutcome.REJECT) {
+            log.warn("{} CredentialPolicy '{}' has rejected credential '{}'", getLogPrefix(), localPolicy.getId(),
+                    credential.get().getCredential().getCredentialId().getBase64Url());
+            authnContext.ensureSubcontext(AuthenticationErrorContext.class).getClassifiedErrors().add(
+                    WebAuthnAuthenticationEventIds.CREDENTIAL_POLICY_REJECTION);
+            ActionSupport.buildEvent(profileRequestContext, WebAuthnAuthenticationEventIds.CREDENTIAL_POLICY_REJECTION);
+            return;
+        } else if (outcome == CredentialPolicyOutcome.IGNORE) {
+            log.trace("{} CredentialPolicy '{}' was not active for credential '{}', accepting", getLogPrefix(), localPolicy.getId(),
+                    credential.get().getCredential().getCredentialId().getBase64Url());
+            return;
+        }
+        log.debug("{} CredentialPolicy '{}' accepted credential '{}'", getLogPrefix(), localPolicy.getId(),
+                    credential.get().getCredential().getCredentialId().getHex());
+             
+    } 
+    
+}
diff --git a/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/policy/impl/AbstractCredentialPolicyRule.java b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/policy/impl/AbstractCredentialPolicyRule.java
new file mode 100644
index 0000000..f6cd2d2
--- /dev/null
+++ b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/policy/impl/AbstractCredentialPolicyRule.java
@@ -0,0 +1,159 @@
+/*
+ * 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.function.BiPredicate;
+import java.util.function.Function;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.opensaml.messaging.context.navigate.ChildContextLookup;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.slf4j.Logger;
+
+import com.yubico.fido.metadata.FidoMetadataService;
+
+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.storage.CredentialRegistration;
+import net.shibboleth.shared.annotation.constraint.ThreadSafeAfterInit;
+import net.shibboleth.shared.component.AbstractIdentifiableInitializableComponent;
+import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.primitive.LoggerFactory;
+
+/**
+ * A base class for {@link CredentialPolicy credential policies}. Ensures the credential is not null before it is
+ * passed to the policy rule implementation. Can be enabled and disabled using the activiation condition. 
+ * 
+ * <p>Returns {@link CredentialPolicyOutcome#ACCEPT} if the credential is accepted, returns
+ * {@link CredentialPolicyOutcome#REJECT} if the credential is rejected, returns 
+ * {@link CredentialPolicyOutcome#IGNORE} if the rule is to be ignored.</p>
+ */
+ at ThreadSafeAfterInit
+public abstract class AbstractCredentialPolicyRule extends AbstractIdentifiableInitializableComponent 
+        implements CredentialPolicy {
+    
+    /** Class logger. */
+    @Nonnull private final Logger log = LoggerFactory.getLogger(AbstractCredentialPolicyRule.class);
+    
+    /** FIDO metadata service resolver.*/ 
+    @Nullable private FidoMetadataService fidoMetadataService;  
+    
+    /** Does this policy rule apply? Default is true. */
+    @Nonnull private BiPredicate<CredentialRegistration, ProfileRequestContext> activationCondition;
+    
+    /** Lookup strategy to locate the WebAuthn context. */
+    @Nonnull private Function<ProfileRequestContext,WebAuthnAuthenticationContext> webauthnContextLookupStrategy;
+    
+    /** Constructor.*/
+    protected AbstractCredentialPolicyRule() {
+        //default is always true
+        activationCondition = (cred,prc) -> true;
+        webauthnContextLookupStrategy = new ChildContextLookup<>(WebAuthnAuthenticationContext.class).
+                    compose(new ChildContextLookup<>(AuthenticationContext.class));
+    }
+    
+    /**
+     * Set the WebAuthn context lookup strategy to use.
+     * 
+     * @param strategy lookup strategy
+     */
+    public void setWebAuthnContextLookupStrategy(
+            @Nonnull final Function<ProfileRequestContext,WebAuthnAuthenticationContext> strategy) {
+        checkSetterPreconditions();
+
+        webauthnContextLookupStrategy = 
+                Constraint.isNotNull(strategy, "WebAuthnContextLookuplookup strategy cannot be null");
+    }
+    
+    
+    /**
+     * Set an activation condition for this policy rule.
+     * 
+     * @param condition condition to set
+     */
+    public void setActivationConditionStrategy(
+            @Nonnull final BiPredicate<CredentialRegistration, ProfileRequestContext> condition) {
+        checkSetterPreconditions();        
+        activationCondition = Constraint.isNotNull(condition, "Activation condition cannot be null");
+    }
+    
+    /**
+     * Set an activation condition for this policy rule.
+     * 
+     * @param flag the flag to set
+     */
+    public void setActivationCondition(final boolean flag) {
+        checkSetterPreconditions();        
+        activationCondition = flag ? (cred,prc) -> true : (cred,prc) -> false;
+    }
+    
+    /**
+     * Set the FIDO metadata source.
+     * 
+     * @param service the attestation trust source.
+     */
+    public void setFidoMetadataService(@Nullable final FidoMetadataService trustSource) {
+        checkSetterPreconditions();
+        fidoMetadataService = trustSource;
+    }
+    
+    /**
+     * Get the metadata service to use. 
+     * 
+     * @return the metadata service.
+     */
+    @Nullable protected FidoMetadataService getFidoMetadataService() {
+        return fidoMetadataService;
+    }
+    
+    /** {@inheritDoc} 
+     * 
+     * <p>
+     * Tests the policy is active, extracts and presents the {@link WebAuthnAuthenticationContext} context from the 
+     * {@link ProfileRequestContext} for convenience. 
+     * </p>
+     */
+    @Override
+    public CredentialPolicyOutcome evaluate(
+            @Nonnull final CredentialRegistration credential, @Nonnull final ProfileRequestContext prc) {
+        if (!activationCondition.test(credential, prc)) {
+            //not active for this request
+            log.trace("CredentialPolicy rule '{}' not active for this request", getId());
+            return CredentialPolicyOutcome.IGNORE;
+        }        
+        final WebAuthnAuthenticationContext webAuthnContext = webauthnContextLookupStrategy.apply(prc);
+        if (credential == null || prc == null || webAuthnContext == null) {
+            return CredentialPolicyOutcome.REJECT;
+        }
+        return doEvaluate(credential, prc, webAuthnContext);
+    }
+
+    /**
+     * Execute the policy. Implementations should override this method.
+     * 
+     * @param credential the credential the check the policy rules for
+     * @param prc the profile request context
+     * @param webAuthnContext the WebAuthn authentication context
+     * 
+     * @return the credential policy outcome
+     */
+    protected abstract CredentialPolicyOutcome doEvaluate(
+            @Nonnull final CredentialRegistration credential, @Nonnull final ProfileRequestContext prc,
+            @Nonnull final WebAuthnAuthenticationContext webAuthnContext);
+
+}
diff --git a/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/policy/impl/SecondFactorOnlyCredentialPolicyRule.java b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/policy/impl/SecondFactorOnlyCredentialPolicyRule.java
new file mode 100644
index 0000000..99b917f
--- /dev/null
+++ b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/policy/impl/SecondFactorOnlyCredentialPolicyRule.java
@@ -0,0 +1,81 @@
+/*
+ * 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.Map;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.slf4j.Logger;
+
+import net.shibboleth.idp.plugin.authn.webauthn.admin.policy.impl.SecondFactorOnlyAuthenticatorInspector;
+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.CredentialRegistration;
+import net.shibboleth.shared.codec.Base64Support;
+import net.shibboleth.shared.codec.EncodingException;
+import net.shibboleth.shared.primitive.LoggerFactory;
+
+/**
+ * A {@link CredentialPolicy} that rejects credentials used in sole-factor mode if created by authenticators which 
+ * should only be used for second factor authentication.
+ */
+public class SecondFactorOnlyCredentialPolicyRule extends AbstractCredentialPolicyRule {
+
+    /** Class logger. */
+    @Nonnull private final Logger log = LoggerFactory.getLogger(SecondFactorOnlyCredentialPolicyRule.class);
+    
+    /** {@inheritDoc} */
+    @Override
+    public CredentialPolicyOutcome doEvaluate(
+            @Nonnull final CredentialRegistration credential, @Nonnull final ProfileRequestContext prc,
+            @Nonnull final WebAuthnAuthenticationContext webAuthnContext) {
+        
+        final Map<String,String> capabilities = credential.getAuthenticatorCapabilities();
+        if (capabilities.containsKey(SecondFactorOnlyAuthenticatorInspector.CAPABILITY_NAME)) {
+            final String value = capabilities.get(SecondFactorOnlyAuthenticatorInspector.CAPABILITY_NAME);
+            final boolean isTrue = Boolean.parseBoolean(value);
+            
+            if (!webAuthnContext.isSecondFactor() && isTrue) {
+                if (log.isTraceEnabled()) {
+                    log.trace("Rejected credential '{}', authentication is sole-factor and authenticator '{}' that "
+                            + "created the credential should only be used as a second factor", 
+                            credential.getCredentialIdBase64Url(), toBase64OrUnknown(credential.getAaguid()));
+                }
+                return CredentialPolicyOutcome.REJECT;
+            }
+        }
+        return CredentialPolicyOutcome.ACCEPT;
+    }
+    
+    /**
+     * Convert the value to Base64 URL encoding, or return "unknown" if there is an error.
+     * 
+     * @param value the value to Base64 URL encode
+     * @return the Bas64 URL encoded string.
+     */
+    private String toBase64OrUnknown(@Nullable final byte[] value) {
+        if (value == null) {
+            return "unknown";
+        }
+        try {
+            return Base64Support.encodeURLSafe(value);
+        } catch (final EncodingException e) {
+            return "unknown";
+        }
+    }
+
+}
diff --git a/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/IdPStorageServiceCredentialRespository.java b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/IdPStorageServiceCredentialRespository.java
index c90bc91..7250576 100644
--- a/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/IdPStorageServiceCredentialRespository.java
+++ b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/IdPStorageServiceCredentialRespository.java
@@ -309,6 +309,25 @@ public class IdPStorageServiceCredentialRespository extends AbstractIdentifiable
             readLock.unlock();
         }
     }
+    
+    /** {@inheritDoc} */
+    @Override
+    @Nonnull public Optional<CredentialRegistration> getRegistrationByUserHandleAndCredentialId(
+            final ByteArray credentialId, final ByteArray userHandle) {
+        checkComponentActive();
+        final Lock readLock = lock.readLock();
+        try {
+            readLock.lock();
+            final Collection<CredentialRegistration> existingRegistrations = getRegistrationsByUserHandle(userHandle);
+            final Optional<CredentialRegistration> registration = existingRegistrations.stream()
+                    .filter(credReg -> credentialId.equals(credReg.getCredential().getCredentialId()))
+                    .findFirst();
+            assert registration != null;
+            return registration;
+        } finally {
+            readLock.unlock();
+        }
+    }
 
     /** {@inheritDoc} */
     @Override
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 d6b9f22..b674f5d 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
@@ -18,6 +18,9 @@
     
     <bean id="AbstractWebAuthnAuthenticationAction" scope="prototype" abstract="true"
         p:webAuthnClient="#{getObject('shibboleth.authn.webauthn.WebAuthnAuthenticationClientFactory') ?: getObject('shibboleth.authn.webauthn.DefaultWebAuthnAuthenticationClientFactory')}"/>
+        
+    <bean id="AbstractCredentialPolicyRule" scope="prototype" abstract="true"
+        p:fidoMetadataService="#{'false'.equals('%{idp.authn.webauthn.metadata.enabled:false}') ? null : getObject('shibboleth.authn.webauthn.DefaultWebAuthnFidoMetadataServiceFactory')}"/>
 
     <!-- Flow beans -->
     
@@ -137,6 +140,20 @@
         p:triggerEventOnNoCredentials="%{idp.authn.webauthn.signalEventOnNoCredentialsRegisteredForUserHandle:false}"
         p:noCredentialsEventId="%{idp.authn.webauthn.userHandleNoRegisteredCredentialsEventId:NoCredentialsRegisteredForUserHandle}"/>        
 
+    <bean id="CheckCredentialPolicy" parent="AbstractWebAuthnAuthenticationAction" scope="prototype"
+        class="net.shibboleth.idp.plugin.authn.webauthn.impl.CheckCredentialPolicy"
+        p:credentialRepository="#{getObject('shibboleth.authn.webauthn.CredentialRepositoryy') ?: getObject('shibboleth.authn.webauthn.DefaultCredentialRepository')}"
+        p:credentialPolicy="#{getObject('%{idp.authn.webauthn.credential.policy:shibboleth.authn.webauthn.ChainedCredentialPolicies}')}"
+        p:activationCondition="%{idp.authn.webauthn.credential.policy.enabled:false}"/>
+        
+    <util:list id="shibboleth.authn.webauthn.ChainedCredentialPolicies">  
+      
+       <bean id="SecondFactorOnlyCredentialPolicyRule" parent="AbstractCredentialPolicyRule" scope="prototype"
+            class="net.shibboleth.idp.plugin.authn.webauthn.policy.impl.SecondFactorOnlyCredentialPolicyRule"
+            p:activationCondition="%{idp.authn.webauthn.registration.authenticator.policy.secondFactorOnly.enabled:true}"/>
+         
+    </util:list>       
+    
     <bean id="ValidateWebAuthnAssertion" scope="prototype"
         class="net.shibboleth.idp.plugin.authn.webauthn.impl.ValidateWebAuthnAssertion"
         p:webAuthnClient="#{getObject('shibboleth.authn.webauthn.WebAuthnAuthenticationClientFactory') ?: getObject('shibboleth.authn.webauthn.DefaultWebAuthnAuthenticationClientFactory')}" 
@@ -145,6 +162,12 @@
         p:populateAuditContextAction="#{%{idp.authn.webauthn.audit.enabled:%{idp.authn.audit.enabled:false}} ? getObject('shibboleth.authn.webauthn.PopulateAuditContext') : null}"
         p:writeAuditLogAction="#{%{idp.authn.webauthn.audit.enabled:%{idp.authn.audit.enabled:false}} ? getObject('WriteAuthnAuditLog') : null}"/>
 
+    <!-- Error beans -->
+    <alias alias="WebAuthnErrorFunction" name="%{idp.authn.webauthn.errorMessageFunction:DefaultWebAuthnErrorFunction}" />
+        
+    <bean id="DefaultWebAuthnErrorFunction" class="net.shibboleth.idp.plugin.authn.webauthn.context.navigate.WebAuthnErrorMessageLookupFunction" lazy-init="true"
+        p:genericMessageID="%{idp.authn.webauthn.genericMessageID:idp.webauthn.message}" />
+    
     <!-- Audit logging beans. -->
 
     <!-- Default audit format and extractors --> 
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 bf832ed..330dd56 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
@@ -122,6 +122,7 @@
             <evaluate expression="authenticationContext.getSubcontext(T(net.shibboleth.idp.ui.context.RelyingPartyUIContext))" result="viewScope.rpUIContext" />
             <evaluate expression="authenticationContext.getSubcontext(T(net.shibboleth.idp.authn.context.AuthenticationErrorContext))" result="viewScope.authenticationErrorContext" />
             <evaluate expression="authenticationContext.getSubcontext(T(net.shibboleth.idp.authn.context.AuthenticationWarningContext))" result="viewScope.authenticationWarningContext" />
+            <evaluate expression="flowRequestContext.getActiveFlow().getApplicationContext().getBean('WebAuthnErrorFunction')" result="viewScope.errorMessageFunction" />
             <evaluate expression="flowRequestContext.getActiveFlow().getApplicationContext().getBean('WebAuthnCSPDigester')" result="requestScope.cspDigester" />
             <evaluate expression="flowRequestContext.getActiveFlow().getApplicationContext().getBean('WebAuthnCSPNonce')" result="requestScope.cspNonce" />   
             <evaluate expression="T(net.shibboleth.shared.codec.HTMLEncoder)" result="viewScope.encoder" />
@@ -138,9 +139,14 @@
         <!-- lookup credentials here so we can exit the process before validation if no registered credentials exist and
         the authentication plugin has been configured to trigger a custom event. Useful for the usernameless flow. -->
         <evaluate expression="LookupRegisteredCredentialsFromUserHandle"/>
+        <evaluate expression="CheckCredentialPolicy"/>
         <evaluate expression="ValidateWebAuthnAssertion"/>
         <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. -->
+        <transition on="CredentialPolicyRejection" to="GenerateAuthenticationCeremonyOptions"/>        
     </action-state>
     
     <bean-import resource="webauthn-beans.xml" />
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 d08d8ed..bcc8984 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
@@ -108,6 +108,13 @@ idp.authn.webauthn.supportedPrincipals = \
 # When using the default chained set of inspectors, give a comma seperated list of authenticators (by attestation GUIDs (AAGUID)) to tag as only allowed for second factor authentication
 #idp.authn.webauthn.registration.authenticator.inspector.secondFactorOnlyAuthenticators =
 
+# Enable the credential/authenticator policy during authentication
+# idp.authn.webauthn.credential.policy.enabled = false
+# Set the credential repository to use, defaults to a chained set of policies
+#idp.authn.webauthn.credential.policy = shibboleth.authn.webauthn.ChainedCredentialPolicies
+# When using the default chained policy, should be enable the 'second-factor only' credential rule
+#idp.authn.webauthn.registration.authenticator.policy.secondFactorOnly.enabled = true
+
 
 # Allow inline self-enrolment 
 #idp.authnwebauthn.registration.allowInline = true
diff --git a/webauthn-impl/src/main/resources/net/shibboleth/idp/plugin/authn/webauthn/messages.properties b/webauthn-impl/src/main/resources/net/shibboleth/idp/plugin/authn/webauthn/messages.properties
index ae45412..e84d3e1 100644
--- a/webauthn-impl/src/main/resources/net/shibboleth/idp/plugin/authn/webauthn/messages.properties
+++ b/webauthn-impl/src/main/resources/net/shibboleth/idp/plugin/authn/webauthn/messages.properties
@@ -63,4 +63,7 @@ idp.webauthn.debug.register.request = Registration options
 # Messages to report back to the user during registration
 InvalidRegistration = Key registration unsuccessful
 ValidRegistration = Key was registered successfully
-KeyRemoved = Key was removed successfully
\ No newline at end of file
+KeyRemoved = Key was removed successfully
+
+# Messages to report back to the user during authentication
+CredentialPolicyRejection = Credential was rejected by policy
\ No newline at end of file
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 547a6f8..c672cfa 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
@@ -92,6 +92,7 @@ import net.shibboleth.idp.ui.context.RelyingPartyUIContext;
 import net.shibboleth.shared.annotation.constraint.NotEmpty;
 import net.shibboleth.shared.codec.Base64Support;
 import net.shibboleth.shared.codec.EncodingException;
+import net.shibboleth.shared.collection.CollectionSupport;
 import net.shibboleth.shared.collection.Pair;
 import net.shibboleth.shared.servlet.impl.HttpServletRequestResponseContext;
 
@@ -208,6 +209,7 @@ public abstract class AbstractWebAuthnFlowTest extends AbstractFlowTest {
      * 
      * @param username the username
      * @param display name the display name
+     * @param userHandleB64 the user handle base64 encoded
      * @return the credential registration 
      * 
      * @throws Exception on error
@@ -215,6 +217,23 @@ public abstract class AbstractWebAuthnFlowTest extends AbstractFlowTest {
     protected CredentialRegistration createCredentialRegistration(final String username, final String displayName,
             final String userHandleB64) throws Exception {
 
+        return createCredentialRegistration(username, displayName, userHandleB64, null);
+    }
+    
+    /**
+     * Create a credential registration with a new attestation response from the mock authenticator.
+     * 
+     * @param username the username
+     * @param display name the display name
+     * @param userHandleB64 the user handle base64 encoded
+     * @param capabilities the capabilities of the authenticator that created this credential
+     * @return the credential registration 
+     * 
+     * @throws Exception on error
+     */
+    protected CredentialRegistration createCredentialRegistration(final String username, final String displayName,
+            final String userHandleB64, final Map<String,String> capabilities) throws Exception {
+
         final var user = UserIdentity.builder()
                 .name(username)
                 .displayName(displayName)
@@ -245,6 +264,7 @@ public abstract class AbstractWebAuthnFlowTest extends AbstractFlowTest {
                  .withCredential(credential)
                  .withCredentialNickname("nickname")
                  .withDiscoverable(Optional.of(Boolean.TRUE))
+                 .withAuthenticatorCapabilities(capabilities != null ? capabilities : CollectionSupport.emptyMap())
                  .withUserVerified(true)
                  .build();
          
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
new file mode 100644
index 0000000..30f5da0
--- /dev/null
+++ b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/TestUsernameslessFlowWithPolicy.java
@@ -0,0 +1,109 @@
+/*
+ * 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 java.util.Map;
+
+import javax.annotation.Nonnull;
+
+import org.springframework.test.context.ContextConfiguration;
+import org.springframework.webflow.context.ExternalContextHolder;
+import org.springframework.webflow.engine.impl.FlowExecutionImpl;
+import org.springframework.webflow.executor.FlowExecutionResult;
+import org.testng.annotations.Test;
+
+import com.yubico.webauthn.data.AuthenticatorAssertionResponse;
+import com.yubico.webauthn.data.ByteArray;
+import com.yubico.webauthn.data.ClientAssertionExtensionOutputs;
+import com.yubico.webauthn.data.PublicKeyCredential;
+
+import net.shibboleth.idp.plugin.authn.webauthn.admin.policy.impl.SecondFactorOnlyAuthenticatorInspector;
+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.CredentialRegistration;
+import net.shibboleth.shared.collection.Pair;
+
+
+/**
+ * Flow tests for the usernameless flow with active policies.
+ */
+ at ContextConfiguration(
+        locations = {
+                "classpath*:/META-INF/net.shibboleth.idp/postconfig.xml",
+                "classpath*:/net/shibboleth/idp/plugin/authn/webauthn/test-beans.xml", },
+        initializers = {
+                TestWebAuthnEnvironmentApplicationContextInitializer.class,
+                TestWebAuthnUsernamelessWithPolicyApplicationContextInitializer.class
+                }
+        )
+public class TestUsernameslessFlowWithPolicy extends AbstractWebAuthnFlowTest{
+    
+    /** Flow ID. */
+    @Nonnull public static final String FLOW_ID = "authn/WebAuthn";
+
+    /**
+     * Constructor.
+     */
+    protected TestUsernameslessFlowWithPolicy() {
+        super(FLOW_ID, "proceed");
+    }
+    
+    @SuppressWarnings("null")
+    @Test
+    public void testUsernamelessFlow_CredentialRejected_2FAOnly() throws Exception {
+        
+        //Register a credential for use that is only suitable for 2FA
+        final CredentialRegistration registration = 
+                createCredentialRegistration(USERNAME, DISPLAY_NAME, USER_HANDLE_B64, 
+                        Map.of(SecondFactorOnlyAuthenticatorInspector.CAPABILITY_NAME, "true"));
+        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>
+        assertionResponse = createAssertionReponseFrom(
+                registration.getCredential().getCredentialId().getBytes(), challenge.getBytes());
+
+        final String assertionResponseJson = jsonMapper.writeValueAsString(assertionResponse);
+        
+        // Re-set external context to holder
+        ExternalContextHolder.setExternalContext(externalContext);
+        setHttpFormRequest("POST", Map.of(ExtractPublicKeyCredentialAssertionFromFormRequest.DEFAULT_PARAMETER_NAME, 
+                assertionResponseJson));
+        externalContext.setEventId("proceed");
+        result.getSecond().setCurrentState("DisplayWebAuthnView");
+        result.getSecond().resume(externalContext);
+        
+        // Policy failure will return the user to the webauthn login page
+        assertCurrentStateEquals("DisplayWebAuthnView", result.getSecond());
+
+    }
+    
+
+}
diff --git a/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/TestWebAuthnEnvironmentApplicationContextInitializer.java b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/TestWebAuthnEnvironmentApplicationContextInitializer.java
index c499db2..f183a05 100644
--- a/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/TestWebAuthnEnvironmentApplicationContextInitializer.java
+++ b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/TestWebAuthnEnvironmentApplicationContextInitializer.java
@@ -46,7 +46,7 @@ public class TestWebAuthnEnvironmentApplicationContextInitializer
         mock.setProperty("idp.authn.webauthn.relyingPartyId", "idp.example.com");
         mock.setProperty("idp.csrf.enabled", "false");
         mock.setProperty("idp.authn.webauthn.relyingPartyName", "Shibboleth");
-        mock.setProperty("idp.service.logging.resource", "/logback-webauthn-test.xml");
+        mock.setProperty("idp.service.logging.resource", "/logback-webauthn-flow-test.xml");
         mock.setProperty("idp.additionalProperties",
                 "/conf/ldap.properties, /conf/saml-nameid.properties, /conf/services.properties, /conf/admin/admin.properties, /conf/authn/authn.properties, /conf/c14n/subject-c14n.properties, /credentials/secrets.properties, /conf/sp/sp.properties");
         applicationContext.getEnvironment().getPropertySources().addFirst(mock);
diff --git a/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/TestWebAuthnEnvironmentApplicationContextInitializer.java b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/TestWebAuthnUsernamelessWithPolicyApplicationContextInitializer.java
similarity index 62%
copy from webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/TestWebAuthnEnvironmentApplicationContextInitializer.java
copy to webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/TestWebAuthnUsernamelessWithPolicyApplicationContextInitializer.java
index c499db2..f7b9220 100644
--- a/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/TestWebAuthnEnvironmentApplicationContextInitializer.java
+++ b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/TestWebAuthnUsernamelessWithPolicyApplicationContextInitializer.java
@@ -32,25 +32,20 @@ import net.shibboleth.shared.primitive.LoggerFactory;
  * set to {@link Ordered#LOWEST_PRECEDENCE} or things blow up.</p>
  */
 @Order(Ordered.LOWEST_PRECEDENCE)
-public class TestWebAuthnEnvironmentApplicationContextInitializer
+public class TestWebAuthnUsernamelessWithPolicyApplicationContextInitializer
         implements ApplicationContextInitializer<ConfigurableApplicationContext> {
 
     /** Class logger. */
-    @Nonnull private final Logger log = LoggerFactory.getLogger(TestWebAuthnEnvironmentApplicationContextInitializer.class);
+    @Nonnull private final Logger log = LoggerFactory.getLogger(TestWebAuthnUsernamelessWithPolicyApplicationContextInitializer.class);
 
     /** {@inheritDoc} */
     @Override public void initialize(@Nonnull final ConfigurableApplicationContext applicationContext) {
-        final MockPropertySource mock = new MockPropertySource();
-        mock.setProperty("idp.home", "classpath:/net/shibboleth/idp/module");
-        mock.setProperty("idp.webflows", "classpath*:/flows");
-        mock.setProperty("idp.authn.webauthn.relyingPartyId", "idp.example.com");
-        mock.setProperty("idp.csrf.enabled", "false");
-        mock.setProperty("idp.authn.webauthn.relyingPartyName", "Shibboleth");
-        mock.setProperty("idp.service.logging.resource", "/logback-webauthn-test.xml");
-        mock.setProperty("idp.additionalProperties",
-                "/conf/ldap.properties, /conf/saml-nameid.properties, /conf/services.properties, /conf/admin/admin.properties, /conf/authn/authn.properties, /conf/c14n/subject-c14n.properties, /credentials/secrets.properties, /conf/sp/sp.properties");
+        final MockPropertySource mock = new MockPropertySource("usernameless-mock-properties");
+        mock.setProperty("idp.authn.webauthn.usernameless.enabled", "true");
+        mock.setProperty("idp.authn.webauthn.credential.policy.enabled", "true");
+        mock.setProperty("idp.authn.webauthn.registration.authenticator.policy.secondFactorOnly.enabled", "true");
         applicationContext.getEnvironment().getPropertySources().addFirst(mock);
-        log.info("Prepending properties '{}'", mock.getSource());
+        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 5ac1fe1..fd34308 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,19 @@ public abstract class AbstractWebAuthnTest {
      * 
      * @throws Exception on error
      */
-    protected CredentialRegistration createCredentialRegistration() throws Exception {
+    protected CredentialRegistration createCredentialRegistration() throws Exception {         
+         return createCredentialRegistration(null);
+    }
+    
+    /**
+     * Create a credential registration with a new attestation response from the mock authenticator.
+     * 
+     * @param capabilities the set of capabilities to add
+     * @return the credential registration 
+     * @throws Exception on error
+     */
+    protected CredentialRegistration createCredentialRegistration(final Map<String,String> capabilities) 
+                throws Exception {
 
         final var user = UserIdentity.builder()
                 .name("jdoe")
@@ -365,6 +377,9 @@ public abstract class AbstractWebAuthnTest {
                  .withCredential(credential)
                  .withCredentialNickname("nickname")
                  .withDiscoverable(Optional.of(Boolean.TRUE))
+                 .withAuthenticatorCapabilities(capabilities)
+                 .withAaguid(attestation.getResponse().getParsedAuthenticatorData()
+                         .getAttestedCredentialData().get().getAaguid().getBytes())
                  .withUserVerified(true)
                  .build();
          
diff --git a/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/policy/impl/SecondFactorOnlyCredentialPolicyRuleTest.java b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/policy/impl/SecondFactorOnlyCredentialPolicyRuleTest.java
new file mode 100644
index 0000000..220e09d
--- /dev/null
+++ b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/policy/impl/SecondFactorOnlyCredentialPolicyRuleTest.java
@@ -0,0 +1,67 @@
+/*
+ * 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 static org.testng.Assert.assertTrue;
+
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+import net.shibboleth.idp.plugin.authn.webauthn.admin.policy.impl.SecondFactorOnlyAuthenticatorInspector;
+import net.shibboleth.idp.plugin.authn.webauthn.impl.AbstractWebAuthnTest;
+import net.shibboleth.idp.plugin.authn.webauthn.policy.CredentialPolicy.CredentialPolicyOutcome;
+import net.shibboleth.idp.plugin.authn.webauthn.storage.CredentialRegistration;
+import net.shibboleth.shared.collection.CollectionSupport;
+import net.shibboleth.shared.component.ComponentInitializationException;
+
+/**
+ * Tests for {@link SecondFactorOnlyCredentialPolicyRule}.
+ */
+public class SecondFactorOnlyCredentialPolicyRuleTest  extends AbstractWebAuthnTest {
+    
+    private SecondFactorOnlyCredentialPolicyRule policy;
+    
+    private CredentialRegistration credential;
+    
+    @Override
+    @BeforeMethod
+    public void setup() throws Exception {
+        super.setup();
+        policy = new SecondFactorOnlyCredentialPolicyRule();
+        policy.setId("SecondFactorOnlyPolicy");
+        credential = createCredentialRegistration();
+        
+    }
+    
+    @Test
+    public void testAllowed() throws ComponentInitializationException {        
+        policy.initialize();
+        
+        final CredentialPolicyOutcome accepted = policy.evaluate(credential, prc);
+        assertTrue(accepted == CredentialPolicyOutcome.ACCEPT);
+        
+    }
+    
+    @Test
+    public void testRejected_IsSoleFactor() throws Exception {   
+        credential = createCredentialRegistration(
+                CollectionSupport.singletonMap(SecondFactorOnlyAuthenticatorInspector.CAPABILITY_NAME, "true"));
+        policy.initialize();
+        
+        final CredentialPolicyOutcome accepted = policy.evaluate(credential, prc);
+        assertTrue(accepted == CredentialPolicyOutcome.REJECT);
+        
+    }
+}
diff --git a/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/InMemoryRegistrationStorage.java b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/InMemoryRegistrationStorage.java
index 6111619..960fa30 100644
--- a/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/InMemoryRegistrationStorage.java
+++ b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/InMemoryRegistrationStorage.java
@@ -198,4 +198,16 @@ public class InMemoryRegistrationStorage implements WebAuthnCredentialRepository
         // TODO Auto-generated method stub
         return false;
     }
+
+    /** {@inheritDoc} */
+    @Override
+    public Optional<CredentialRegistration> getRegistrationByUserHandleAndCredentialId(final ByteArray credentialId,
+            final ByteArray userHandle) {
+        final Collection<CredentialRegistration> existingRegistrations = getRegistrationsByUserHandle(userHandle);
+        final Optional<CredentialRegistration> registration = existingRegistrations.stream()
+                .filter(credReg -> credentialId.equals(credReg.getCredential().getCredentialId()))
+                .findFirst();
+        assert registration != null;
+        return registration;
+    }
 }
diff --git a/webauthn-impl/src/test/resources/logback-webauthn-flow-test.xml b/webauthn-impl/src/test/resources/logback-webauthn-flow-test.xml
new file mode 100644
index 0000000..d71e445
--- /dev/null
+++ b/webauthn-impl/src/test/resources/logback-webauthn-flow-test.xml
@@ -0,0 +1,25 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<configuration>
+
+    <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
+        <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
+            <pattern>%level [%logger:%line] - %msg%n</pattern>
+            <charset>UTF-8</charset>
+        </encoder>
+    </appender>
+
+    <root>
+        <level value="WARN" />
+        <appender-ref ref="STDOUT" />
+    </root>
+    
+    <logger name="net.shibboleth.idp.plugin.authn" level="TRACE" additivity="false">
+        <appender-ref ref="STDOUT" />
+    </logger>
+     
+    <logger name="org.springframework.webflow" level="INFO" additivity="false">
+        <appender-ref ref="STDOUT" />
+    </logger>
+    
+</configuration>
\ No newline at end of file

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


More information about the commits mailing list