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

Phil Smart philip.smart at jisc.ac.uk
Fri Oct 18 17:13:28 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=f6b04893d1b2ef13f4b74b2d00dc6890e4c14175

commit f6b04893d1b2ef13f4b74b2d00dc6890e4c14175
Author: Phil Smart <philip.smart at jisc.ac.uk>
AuthorDate: Thu Oct 3 11:43:41 2024 +0100

    JWEBAUTHN-27 - Add basic authenticator policy
    
     - Add engine to allow 'assessors' to derive qualities, capabilities, or
    properties of authenticators which are stored inside the credential
    registration.
     - These properties are not yet used. Still a WIP.
    
    https://shibboleth.atlassian.net/browse/JWEBAUTHN-27
---
 .../policy/AuthenticatorCapabilitiesAssessor.java  |  47 ++++++++
 .../context/WebAuthnRegistrationContext.java       |  34 ++++++
 .../webauthn/storage/CredentialRegistration.java   |  46 +++++++-
 .../impl/AttachAuthenticatorCapabilities.java      | 124 +++++++++++++++++++++
 .../admin/impl/StorePublicKeyCredential.java       |  51 +++++----
 .../AbstractAuthenticatorCapabilitiesAssessor.java | 121 ++++++++++++++++++++
 .../ChainingAuthenticatorCapabilitiesAssessor.java |  88 +++++++++++++++
 .../impl/ChainingAuthenticatorPolicyRule.java      |   2 +-
 .../SecondFactorOnlyAuthenticatorAssessor.java     |  86 ++++++++++++++
 .../webauthn-registration-beans.xml                |  24 +++-
 .../webauthn-registration-flow.xml                 |   1 +
 .../authn/webauthn/conf/authn/webauthn.properties  |  12 ++
 12 files changed, 612 insertions(+), 24 deletions(-)

diff --git a/webauthn-api/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/admin/policy/AuthenticatorCapabilitiesAssessor.java b/webauthn-api/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/admin/policy/AuthenticatorCapabilitiesAssessor.java
new file mode 100644
index 0000000..170c80e
--- /dev/null
+++ b/webauthn-api/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/admin/policy/AuthenticatorCapabilitiesAssessor.java
@@ -0,0 +1,47 @@
+/*
+ * 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.admin.policy;
+
+import java.util.Map;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.opensaml.profile.context.ProfileRequestContext;
+
+import com.yubico.fido.metadata.AAGUID;
+
+import net.shibboleth.shared.annotation.constraint.NotLive;
+import net.shibboleth.shared.annotation.constraint.Unmodifiable;
+import net.shibboleth.shared.component.IdentifiedComponent;
+
+/**
+ * An API for assessing the capabilities of an authenticator and describing that in a Map. The authenticator is 
+ * identified by its AAGUID.
+ */
+public interface AuthenticatorCapabilitiesAssessor extends IdentifiedComponent {
+    
+    /**
+     * Assess the capabilities of this authenticator and return any capabilities/properties/labels/tags appropriate. 
+     * 
+     * @param aaguid the authenticator attestation GUID.
+     * @param prc the profile request context
+     * 
+     * @return a map of capabilities/properties/labels/tags relating to the authenticator, could be empty.
+     */
+    @Nonnull @NotLive @Unmodifiable 
+    Map<String, String> assess(@Nullable final AAGUID aaguid, @Nullable final ProfileRequestContext prc);
+
+}
diff --git a/webauthn-api/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/context/WebAuthnRegistrationContext.java b/webauthn-api/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/context/WebAuthnRegistrationContext.java
index ec4d421..a2e9765 100644
--- a/webauthn-api/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/context/WebAuthnRegistrationContext.java
+++ b/webauthn-api/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/context/WebAuthnRegistrationContext.java
@@ -14,6 +14,8 @@
 
 package net.shibboleth.idp.plugin.authn.webauthn.context;
 
+import java.util.Map;
+
 import javax.annotation.Nonnull;
 import javax.annotation.Nullable;
 import javax.annotation.concurrent.NotThreadSafe;
@@ -27,6 +29,9 @@ import com.yubico.webauthn.data.PublicKeyCredentialCreationOptions;
 import com.yubico.webauthn.data.ResidentKeyRequirement;
 
 import net.shibboleth.idp.plugin.authn.webauthn.admin.RegistrationResult;
+import net.shibboleth.shared.annotation.constraint.NotLive;
+import net.shibboleth.shared.annotation.constraint.Unmodifiable;
+import net.shibboleth.shared.collection.CollectionSupport;
 
 
 /** 
@@ -79,6 +84,12 @@ public final class WebAuthnRegistrationContext extends BaseWebAuthnContext {
      */
     @Nullable private String displayName;
     
+    /** 
+     * A Map of authenticator capabilities. For example, should this authenticator only be used for second-factor 
+     * authentication.
+     */
+    @Nullable private Map<String, String> authenticatorCapabilities;
+    
     
 
     /**
@@ -305,5 +316,28 @@ public final class WebAuthnRegistrationContext extends BaseWebAuthnContext {
     @Nullable public String getDisplayName() {
         return displayName;
     }
+
+    /**
+     * Set the capabilities map of the authenticator. 
+     * 
+     * @param capabilities the map of capabilities to set
+     */
+    @Nonnull public BaseWebAuthnContext setAuthenticatorCapabilities(@Nullable final Map<String, String> capabilities) {
+        authenticatorCapabilities = capabilities;
+        return this;        
+    }
+    
+    /**
+     * Get the capabilities map of the authenticator. 
+     * 
+     * @return Returns the authenticatorCapabilities.
+     */
+    @Nonnull @NotLive @Unmodifiable public Map<String, String> getAuthenticatorCapabilities() {
+        if (authenticatorCapabilities != null) {
+            return CollectionSupport.copyToMap(authenticatorCapabilities);
+        } else {
+            return CollectionSupport.emptyMap();
+        }
+    }
     
 }
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 8134a71..829bf16 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
@@ -16,6 +16,7 @@ package net.shibboleth.idp.plugin.authn.webauthn.storage;
 
 import java.time.Instant;
 import java.util.Collections;
+import java.util.Map;
 import java.util.Objects;
 import java.util.Optional;
 import java.util.SortedSet;
@@ -36,7 +37,9 @@ import com.yubico.webauthn.data.PublicKeyCredentialDescriptor;
 import com.yubico.webauthn.data.UserIdentity;
 
 import net.shibboleth.shared.annotation.constraint.NonnullElements;
+import net.shibboleth.shared.annotation.constraint.NotLive;
 import net.shibboleth.shared.annotation.constraint.Unmodifiable;
+import net.shibboleth.shared.collection.CollectionSupport;
 import net.shibboleth.shared.logic.Constraint;
 
 /**
@@ -73,11 +76,17 @@ public final class CredentialRegistration {
     /** The credential to register. */
     @Nonnull @Unmodifiable @NonnullElements private final RegisteredCredential credential;
     
-    /** The AAGUID of the authenticator.*/
+    /** The AAGUID of the authenticator that created this credential.*/
     @Nullable private final byte[] aaguid;
 
     /** Was the user verified during registration. */
     private final boolean userVerified;
+    
+    /** 
+     * Capabilities of the authenticator as determined by the registration process and any configured authenticator 
+     * assessors (not necessarily just from FIDO metadata). 
+     */
+    @Nonnull private final Map<String,String> authenticatorCapabilities;
 
     /**
      * 
@@ -95,6 +104,7 @@ public final class CredentialRegistration {
         this.discoverable = builder.discoverable;
         this.userVerified = builder.userVerified;
         this.aaguid = builder.aaguid;
+        this.authenticatorCapabilities = builder.authenticatorCapabilities;
 
     }
 
@@ -214,6 +224,16 @@ public final class CredentialRegistration {
         return credential.getCredentialId().getBase64Url();
     }
     
+    /**
+     * Get the capabilities of the authenticator that created this credential.
+     * 
+     * @return the authenticatorCapabilities.
+     */
+    @JsonGetter("authenticatorCapabilities")
+    @Nonnull @NotLive @Unmodifiable public Map<String, String> getAuthenticatorCapabilities() {
+        return CollectionSupport.copyToMap(authenticatorCapabilities);
+    }
+    
     /**
      * Convert the credential registration into a {@link PublicKeyCredentialDescriptor}.
      * 
@@ -371,6 +391,15 @@ public final class CredentialRegistration {
          * @return the next builder stage
          */
         @Nonnull public IBuildStage withAaguid(byte[] aaguid);
+        
+        /**
+         * Set the capabilities of the authenticator as determined by the registration process 
+         * (not FIDO necessarily FIDO metadata). 
+         * 
+         * @param capabilities the capabilities of the authenticator
+         * @return the next builder stage
+         */
+        @Nonnull public IBuildStage withAuthenticatorCapabilities(@Nullable final Map<String,String> capabilities);
 
         /**
          * Build this credential registration.
@@ -402,6 +431,11 @@ public final class CredentialRegistration {
         private boolean userVerified;        
         /** The AAGUID of the authenticator.*/
         @Nullable private byte[] aaguid;
+        /** 
+         * Capabilities of the authenticator as determined by the registration process 
+         * (not FIDO neccisarily FIDO metadata). 
+         */
+        @Nonnull private Map<String,String> authenticatorCapabilities;
 
         /** Constructor.*/
         @SuppressWarnings("null")
@@ -410,6 +444,7 @@ public final class CredentialRegistration {
             discoverable = Optional.empty();            
             userVerified = false;
             transports = Collections.emptySortedSet();
+            authenticatorCapabilities = Collections.emptyMap();
         }
 
         @Override
@@ -484,6 +519,15 @@ public final class CredentialRegistration {
             aaguid = authenticatorGuid;
             return this;
         }
+        
+        @Override
+        @JsonProperty("authenticatorCapabilities")
+        public IBuildStage withAuthenticatorCapabilities(@Nullable final Map<String, String> capabilities) {
+            if (capabilities != null) {
+                authenticatorCapabilities = CollectionSupport.copyToMap(capabilities);
+            }
+            return this;
+        }
     }
 
 }
diff --git a/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/admin/impl/AttachAuthenticatorCapabilities.java b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/admin/impl/AttachAuthenticatorCapabilities.java
new file mode 100644
index 0000000..c2816c7
--- /dev/null
+++ b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/admin/impl/AttachAuthenticatorCapabilities.java
@@ -0,0 +1,124 @@
+/*
+ * 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.admin.impl;
+
+import java.util.Map;
+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.context.ProfileRequestContext;
+import org.slf4j.Logger;
+
+import com.yubico.fido.metadata.AAGUID;
+import com.yubico.webauthn.data.AttestedCredentialData;
+import com.yubico.webauthn.data.AuthenticatorAttestationResponse;
+import com.yubico.webauthn.data.ByteArray;
+import com.yubico.webauthn.data.ClientRegistrationExtensionOutputs;
+import com.yubico.webauthn.data.PublicKeyCredential;
+
+import net.shibboleth.idp.plugin.authn.webauthn.admin.WebAuthnRegistrationEventIds;
+import net.shibboleth.idp.plugin.authn.webauthn.admin.policy.AuthenticatorCapabilitiesAssessor;
+import net.shibboleth.idp.plugin.authn.webauthn.context.WebAuthnRegistrationContext;
+import net.shibboleth.idp.plugin.authn.webauthn.impl.AbstractWebAuthnAction;
+import net.shibboleth.shared.annotation.constraint.NonnullBeforeExec;
+import net.shibboleth.shared.primitive.LoggerFactory;
+
+/**
+ * Attach capabilities to the authenticator that has created the credential registration. For example, this 
+ * authenticator should only be used for 2FA. 
+ */
+public class AttachAuthenticatorCapabilities extends AbstractWebAuthnAction<WebAuthnRegistrationContext> {
+    
+    /** Class logger. */
+    @Nonnull private final Logger log = LoggerFactory.getLogger(AttachAuthenticatorCapabilities.class);
+    
+    /** The stashed attestation response.*/
+    @NonnullBeforeExec 
+    private PublicKeyCredential<AuthenticatorAttestationResponse, ClientRegistrationExtensionOutputs> attestation;
+    
+    /** The assessor that checks the capabilities of the authenticator.*/
+    @Nullable private AuthenticatorCapabilitiesAssessor authenticatorAssessor;
+    
+    /**
+     * Constructor.
+     */
+    protected AttachAuthenticatorCapabilities() {
+        super(new ChildContextLookup<>(WebAuthnRegistrationContext.class));
+    }
+    
+    /**
+     * Set the assessor that checks the capabilities of the authenticator.
+     * 
+     * @param assessor the authenticator assessor to set.
+     */
+    public void setAuthenticatorAssessor(@Nullable final AuthenticatorCapabilitiesAssessor assessor) {
+        checkSetterPreconditions();
+        authenticatorAssessor = assessor;
+    }
+    
+    /** {@inheritDoc} */
+    @Override
+    protected boolean doPreExecute(@Nonnull final ProfileRequestContext profileRequestContext,
+            @Nonnull final WebAuthnRegistrationContext context) {
+        
+        if (!super.doPreExecute(profileRequestContext, context)) {
+            return false;
+        }
+       
+        attestation = context.getPublicKeyCredentialAttestationResponse();
+        if (attestation == null) {
+            log.error("{} Attestaion not available in registration context", getLogPrefix());
+            ActionSupport.buildEvent(profileRequestContext, WebAuthnRegistrationEventIds.INVALID_REGISTRATION_CTX);
+            return false;
+        }
+        
+        return true;
+    }
+    
+    @Override protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext,
+            @Nonnull final WebAuthnRegistrationContext context) {
+        
+        log.trace("{} Attaching authenticator capabilities", getLogPrefix());
+        
+        final AuthenticatorCapabilitiesAssessor localAssessor = authenticatorAssessor;
+        if (localAssessor == null) {
+            // If no assessor, there is nothing to do
+            log.trace("{} No authenticator assessor to apply", getLogPrefix());
+            return;
+        }
+        
+        final Optional<AttestedCredentialData> attestedCredData = 
+                attestation.getResponse().getParsedAuthenticatorData().getAttestedCredentialData();
+        
+        if (attestedCredData.isEmpty()) {
+            log.warn("{} Attested Credential Data can not assess authenticator capabilities", getLogPrefix());
+            return;
+            
+        }
+        
+        final ByteArray aaguid = attestedCredData.get().getAaguid();        
+        final AAGUID authenticatorAttestationGUID = new AAGUID(aaguid);
+        final Map<String, String> capabilities = 
+                localAssessor.assess(authenticatorAttestationGUID, profileRequestContext);
+        context.setAuthenticatorCapabilities(capabilities);
+        
+        
+    }
+
+}
diff --git a/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/admin/impl/StorePublicKeyCredential.java b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/admin/impl/StorePublicKeyCredential.java
index f7a7177..22a5447 100644
--- a/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/admin/impl/StorePublicKeyCredential.java
+++ b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/admin/impl/StorePublicKeyCredential.java
@@ -19,6 +19,7 @@ import java.util.Optional;
 import java.util.TreeSet;
 
 import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
 
 import org.opensaml.messaging.context.navigate.ChildContextLookup;
 import org.opensaml.profile.action.ActionSupport;
@@ -111,8 +112,6 @@ public class StorePublicKeyCredential extends AbstractWebAuthnAuditingAction<Web
             return;
         } 
         
-        final byte[] userId = context.getUserId();
-        
         try {
             final RegisteredCredential credential = RegisteredCredential.builder()
                     .credentialId(registrationResult.getKeyId().getId())
@@ -143,6 +142,7 @@ public class StorePublicKeyCredential extends AbstractWebAuthnAuditingAction<Web
                     .withCredentialNickname(context.getCredentialNickname())
                     .withDiscoverable(isDiscoverable)
                     .withUserVerified(registrationResult.isUserVerified())
+                    .withAuthenticatorCapabilities(context.getAuthenticatorCapabilities())
                     .build();
             
             final boolean added = repository.addRegistrationByUsername(username, registration);  
@@ -154,23 +154,8 @@ public class StorePublicKeyCredential extends AbstractWebAuthnAuditingAction<Web
                 auditFailure(profileRequestContext, "credential-added");
                 return;
             }
-            
-            if (log.isInfoEnabled()) {
-            
-                String userIdBase64;
-                try {
-                    userIdBase64 = context.getUserId()!=null ? Base64Support.encodeURLSafe(userId) : null;
-                } catch (final EncodingException e) {
-                    // Do nothing, just set a 'null' userId;
-                    userIdBase64 = "null";
-                }
-            
-                log.info("{} Added public key credential registration for user '{}' with user.id '{}' and key '{}'. "
-                        + "Using a discoverable credential '{}' and user verification '{}'", 
-                        getLogPrefix(), username, userIdBase64, registrationResult.getKeyId().getId().getBase64Url(),
-                        registrationResult.isDiscoverable().isPresent() ? registrationResult.isDiscoverable() : 
-                            "unknown", registrationResult.isUserVerified());                
-            }
+            // Else valid, so log and audit
+            logRegistration(context, username, registrationResult);           
             auditSuccess(profileRequestContext, "credential-added");
 
         } catch (final Exception e) {
@@ -180,10 +165,36 @@ public class StorePublicKeyCredential extends AbstractWebAuthnAuditingAction<Web
             auditFailure(profileRequestContext, "credential-added");
             return;
         }
-        
 
     }
     
+    /**
+     * Log a successful registration event.
+     * 
+     * @param context the registration context
+     * @param username the username of the user that registered a credential
+     * @param registrationResult the result of registration
+     */
+    private void logRegistration(@Nonnull final WebAuthnRegistrationContext context, @Nullable final String username,
+            @Nonnull final RegistrationResult registrationResult) {
+        if (log.isInfoEnabled()) {
+            String userIdBase64;
+            try {
+                final byte[] userId = context.getUserId();
+                userIdBase64 = userId !=null ? Base64Support.encodeURLSafe(userId) : null;
+            } catch (final EncodingException e) {
+                // Do nothing, just set a 'null' userId;
+                userIdBase64 = "null";
+            }
+        
+            log.info("{} Added public key credential registration for user '{}' with user.id '{}' and key '{}'. "
+                    + "Using a discoverable credential '{}' and user verification '{}'", 
+                    getLogPrefix(), username, userIdBase64, registrationResult.getKeyId().getId().getBase64Url(),
+                    registrationResult.isDiscoverable().isPresent() ? registrationResult.isDiscoverable() : 
+                        "unknown", registrationResult.isUserVerified());                
+        }
+    }
+    
     // Checkstyle: MethodLength|CyclomaticComplexity ON
 
 
diff --git a/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/admin/policy/impl/AbstractAuthenticatorCapabilitiesAssessor.java b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/admin/policy/impl/AbstractAuthenticatorCapabilitiesAssessor.java
new file mode 100644
index 0000000..d6e9bce
--- /dev/null
+++ b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/admin/policy/impl/AbstractAuthenticatorCapabilitiesAssessor.java
@@ -0,0 +1,121 @@
+/*
+ * 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.admin.policy.impl;
+
+import java.util.Map;
+import java.util.function.BiPredicate;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.slf4j.Logger;
+
+import com.yubico.fido.metadata.AAGUID;
+import com.yubico.fido.metadata.FidoMetadataService;
+
+import net.shibboleth.idp.plugin.authn.webauthn.admin.policy.AuthenticatorCapabilitiesAssessor;
+import net.shibboleth.shared.collection.CollectionSupport;
+import net.shibboleth.shared.component.AbstractIdentifiableInitializableComponent;
+import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.primitive.LoggerFactory;
+
+/**
+ * A base class for {@link AuthenticatorCapabilitiesAssessor authenticator assessors}. Can be enabled and disabled 
+ * by the activiation condition. 
+ * 
+ * <p>Returns a map of authenticator capabilities or properties.</p>
+ */
+public abstract class AbstractAuthenticatorCapabilitiesAssessor extends AbstractIdentifiableInitializableComponent 
+            implements AuthenticatorCapabilitiesAssessor {
+
+    /** Class logger. */
+    @Nonnull private final Logger log = LoggerFactory.getLogger(AbstractAuthenticatorCapabilitiesAssessor.class);
+    
+    /** FIDO metadata service resolver.*/ 
+    @Nullable private FidoMetadataService fidoMetadataService;  
+    
+    /** Does this assessor apply? Default is true. */
+    @Nonnull private BiPredicate<AAGUID, ProfileRequestContext> activationCondition;
+    
+    /** Constructor.*/
+    protected AbstractAuthenticatorCapabilitiesAssessor() {
+        //default is always true
+        activationCondition = (prc,claims) -> true;
+    }
+    
+    /**
+     * Set an activation condition for this assessor.
+     * 
+     * @param condition condition to set
+     */
+    public void setActivationConditionStrategy(@Nonnull final BiPredicate<AAGUID, ProfileRequestContext> condition) {
+        checkSetterPreconditions();        
+        activationCondition = Constraint.isNotNull(condition, "Activation condition cannot be null");
+    }
+    
+    /**
+     * Set an activation condition for this assessor.
+     * 
+     * @param flag the flag to set
+     */
+    public void setActivationCondition(final boolean flag) {
+        checkSetterPreconditions();        
+        activationCondition = flag ? (prc,claims) -> true : (prc,claims) -> false;
+    }
+    
+    /**
+     * Set the FIDO Metadata service.
+     * 
+     * @param service the FIDO Metadata service.
+     */
+    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} */
+    @Override
+    public Map<String, String> assess(@Nullable final AAGUID aaguid, @Nullable final ProfileRequestContext prc) {
+        if (!activationCondition.test(aaguid, prc)) {
+            //not active for this request
+            log.trace("AuthenticatorCapabilitiesAssessor '{}' not active for this request", getId());
+            return CollectionSupport.emptyMap();
+        }
+        return doAssess(aaguid, prc);
+    }
+
+    /**
+     * Execute the assessor. Implementations should override this method.
+     * 
+     * @param aaguid the authenticator attestation GUID.
+     * @param prc the profile request context
+     * 
+     * @return a map of capabilities.
+     */
+    @Nonnull 
+    protected abstract Map<String, String> doAssess(@Nullable AAGUID aaguid, @Nullable ProfileRequestContext prc);
+    
+    
+}
diff --git a/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/admin/policy/impl/ChainingAuthenticatorCapabilitiesAssessor.java b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/admin/policy/impl/ChainingAuthenticatorCapabilitiesAssessor.java
new file mode 100644
index 0000000..b7734ec
--- /dev/null
+++ b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/admin/policy/impl/ChainingAuthenticatorCapabilitiesAssessor.java
@@ -0,0 +1,88 @@
+/*
+ * 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.admin.policy.impl;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.slf4j.Logger;
+
+import com.yubico.fido.metadata.AAGUID;
+
+import net.shibboleth.idp.plugin.authn.webauthn.admin.policy.AuthenticatorCapabilitiesAssessor;
+import net.shibboleth.shared.collection.CollectionSupport;
+import net.shibboleth.shared.component.ComponentInitializationException;
+import net.shibboleth.shared.primitive.LoggerFactory;
+
+/**
+ * A {@link AbstractAuthenticatorCapabilitiesAssessor} implementation that is based on a chain of 
+ * configured assessors. The outcomes from each are aggregated together (last entry for a given key will win).
+ */
+public class ChainingAuthenticatorCapabilitiesAssessor extends AbstractAuthenticatorCapabilitiesAssessor {
+    
+    /** Class logger. */
+    @Nonnull private final Logger log = LoggerFactory.getLogger(ChainingAuthenticatorCapabilitiesAssessor.class);
+    
+    /** An ordered chain of authenticator assessors.*/
+    private List<AuthenticatorCapabilitiesAssessor> authenticatorAssessorChain;
+    
+    /** {@inheritDoc} */
+    @Override
+    protected void doInitialize() throws ComponentInitializationException {
+        super.doInitialize();
+        
+        if (authenticatorAssessorChain == null) {
+            throw new ComponentInitializationException("List of authenticator assessors can not be null");
+        } 
+    }
+    
+    /**
+     * Set the chain of assessors that should be used to evaluate the authenticator.
+     * 
+     * @param chain The authenticator policy chain to set.
+     */
+    public void setAuthenticatorAssessorChain(@Nullable final List<AuthenticatorCapabilitiesAssessor> chain) {
+        checkSetterPreconditions();
+        if (chain != null) {
+            authenticatorAssessorChain = chain;
+        } else {
+            authenticatorAssessorChain = CollectionSupport.emptyList();
+        }
+    }    
+
+    /** {@inheritDoc} */
+    @Override
+    protected Map<String,String> doAssess(@Nullable final AAGUID aaguid, @Nullable final ProfileRequestContext prc) {
+        
+        final Map<String, String> allOutcomes = new HashMap<>();
+        for (final AuthenticatorCapabilitiesAssessor assessor : authenticatorAssessorChain) {
+            if (log.isTraceEnabled()) {
+                log.trace("Trying AuthenticatorCapabilitiesAssessor '{}' for authenticator '{}'", assessor.getId(),
+                        aaguid != null ? aaguid.asGuidString() : "unknown");
+            }
+            final Map<String,String> outcome = assessor.assess(aaguid, prc);
+            log.trace("AuthenticatorCapabilitiesAssessor '{}' produced result '{}'", assessor.getId(), outcome);
+            allOutcomes.putAll(outcome);
+        }
+        log.trace("All AuthenticatorCapabilitiesAssessors produced result '{}'", allOutcomes);
+        return CollectionSupport.copyToMap(allOutcomes);
+    }
+
+}
diff --git a/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/admin/policy/impl/ChainingAuthenticatorPolicyRule.java b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/admin/policy/impl/ChainingAuthenticatorPolicyRule.java
index d013c3b..da43205 100644
--- a/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/admin/policy/impl/ChainingAuthenticatorPolicyRule.java
+++ b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/admin/policy/impl/ChainingAuthenticatorPolicyRule.java
@@ -49,7 +49,7 @@ public class ChainingAuthenticatorPolicyRule extends AbstractAuthenticatorPolicy
         super.doInitialize();
         
         if (authenticatorPolicyChain == null) {
-            throw new ComponentInitializationException("List of claims validators can not be null");
+            throw new ComponentInitializationException("List of authenticator policies can not be null");
         } 
     }
     
diff --git a/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/admin/policy/impl/SecondFactorOnlyAuthenticatorAssessor.java b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/admin/policy/impl/SecondFactorOnlyAuthenticatorAssessor.java
new file mode 100644
index 0000000..ae9733d
--- /dev/null
+++ b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/admin/policy/impl/SecondFactorOnlyAuthenticatorAssessor.java
@@ -0,0 +1,86 @@
+/*
+ * 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.admin.policy.impl;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import javax.annotation.Nonnull;
+
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.slf4j.Logger;
+
+import com.yubico.fido.metadata.AAGUID;
+
+import net.shibboleth.idp.plugin.authn.webauthn.authn.AuthenticatorSupport;
+import net.shibboleth.shared.annotation.constraint.NotEmpty;
+import net.shibboleth.shared.annotation.constraint.NotLive;
+import net.shibboleth.shared.annotation.constraint.Unmodifiable;
+import net.shibboleth.shared.collection.CollectionSupport;
+import net.shibboleth.shared.primitive.LoggerFactory;
+
+/**
+ * Tag credentials created by authenticators that should only be used for second-factor authentication.
+ */
+public class SecondFactorOnlyAuthenticatorAssessor extends AbstractAuthenticatorCapabilitiesAssessor {
+    
+    /** The name of the capability.*/
+    @Nonnull @NotEmpty public static final String CAPABILITY_NAME = "2faOnly";
+    
+    /** Class logger. */
+    @Nonnull private final Logger log = LoggerFactory.getLogger(SecondFactorOnlyAuthenticatorAssessor.class);
+    
+    /** A set of authenticators that should only be used for second factor authentication, based on their AAGUID.*/
+    @Nonnull @NotLive @Unmodifiable private Set<AAGUID> secondFactorOnlyAuthenticators;
+    
+    /** Constructor.*/
+    public SecondFactorOnlyAuthenticatorAssessor() {
+        secondFactorOnlyAuthenticators = CollectionSupport.emptySet();
+    }
+    
+    /**
+     * Set the authenticators that should only be used for second factor authentication based on their AAGUID. 
+     * 
+     * @param allowed The second factor authenticators to set.
+     */
+    public void setSecondFactorOnlyAuthenticators(final Set<String> allowed) {
+        checkSetterPreconditions();
+        if (allowed != null) {
+            secondFactorOnlyAuthenticators = allowed.stream().map(strAAGUID -> {
+                final var aaguidBytes = AuthenticatorSupport.parse(strAAGUID);
+                if (aaguidBytes != null) {
+                    return new AAGUID(aaguidBytes);
+                } else {
+                    log.trace("AAGUID '{}' is not valid", strAAGUID);
+                    return null;
+                }                  
+            }).filter(Objects::nonNull).collect(CollectionSupport.nonnullCollector(Collectors.toSet())).get();
+        }
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Map<String, String> doAssess(final AAGUID aaguid, final ProfileRequestContext prc) {
+        final boolean secondFactorOnly = secondFactorOnlyAuthenticators.contains(aaguid);
+        final HashMap<String,String> capabilities = new HashMap<>();
+        capabilities.put(CAPABILITY_NAME, secondFactorOnly ? "true" : "false");
+        return CollectionSupport.copyToMap(capabilities);
+    }
+
+
+}
diff --git a/webauthn-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/admin/webauthn-registration/webauthn-registration-beans.xml b/webauthn-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/admin/webauthn-registration/webauthn-registration-beans.xml
index 4eb73c8..a1558b1 100644
--- a/webauthn-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/admin/webauthn-registration/webauthn-registration-beans.xml
+++ b/webauthn-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/admin/webauthn-registration/webauthn-registration-beans.xml
@@ -178,10 +178,10 @@
         p:authenticatorPolicy="#{getObject('%{idp.authn.webauthn.registration.authenticator.policy:shibboleth.authn.webauthn.registration.ChainedAuthenticatorPolicy}')}"
         p:activationCondition="%{idp.authn.webauthn.registration.authenticator.policy.enabled:false}"/>
         
-    <bean id="shibboleth.authn.webauthn.registration.ChainedAuthenticatorPolicy" scope="prototype"
+     <bean id="shibboleth.authn.webauthn.registration.ChainedAuthenticatorPolicy" scope="prototype"
         class="net.shibboleth.idp.plugin.authn.webauthn.admin.policy.impl.ChainingAuthenticatorPolicyRule"
         p:authenticatorPolicyChain="#{getObject('%{idp.authn.webauthn.registration.authenticator.policy.chainedlist:shibboleth.authn.webauthn.registration.ChainedAuthenticatorPolicies}')}"/>
-
+    
     <util:list id="shibboleth.authn.webauthn.registration.ChainedAuthenticatorPolicies">  
       
         <bean id="AllowlistAuthenticatorPolicy" parent="AbstractAuthenticatorPolicyRule" 
@@ -197,6 +197,26 @@
          
     </util:list>
     
+    <bean id="AttachAuthenticatorCapabilities" parent="AbstractWebAuthnRegistrationAction" scope="prototype"
+        class="net.shibboleth.idp.plugin.authn.webauthn.admin.impl.AttachAuthenticatorCapabilities"
+        p:authenticatorAssessor="#{getObject('%{idp.authn.webauthn.registration.authenticator.assessor:shibboleth.authn.webauthn.registration.ChainedAuthenticatorCapabilitiesAssessor}')}"
+        p:activationCondition="%{idp.authn.webauthn.registration.authenticator.assessor.enabled:false}"/>       
+         
+    <bean id="shibboleth.authn.webauthn.registration.ChainedAuthenticatorCapabilitiesAssessor" scope="prototype"
+        class="net.shibboleth.idp.plugin.authn.webauthn.admin.policy.impl.ChainingAuthenticatorCapabilitiesAssessor"
+        p:authenticatorAssessorChain="#{getObject('%{idp.authn.webauthn.registration.authenticator.assessor.chainedlist:shibboleth.authn.webauthn.registration.ChainedAuthenticatorAssessors}')}"/>
+   
+    
+    <util:list id="shibboleth.authn.webauthn.registration.ChainedAuthenticatorAssessors">  
+      
+        <bean id="shibboleth.authn.webauthn.registration.SecondFactorOnlyAssessor" scope="prototype"
+            class="net.shibboleth.idp.plugin.authn.webauthn.admin.policy.impl.SecondFactorOnlyAuthenticatorAssessor"
+            p:secondFactorOnlyAuthenticators="%{idp.authn.webauthn.registration.authenticator.assessor.secondFactorOnlyAuthenticators:null}"
+            p:activationCondition="%{idp.authn.webauthn.registration.authenticator.assessor.secondFactorOnlyAssessor.enabled:true}"/>
+
+    </util:list>
+    
+    
     <bean id="shibboleth.authn.webauthn.registration.authenticator.policy.AuthenticatorGetInfoUVCapable"
                     class="net.shibboleth.idp.plugin.authn.webauthn.admin.policy.impl.AuthenticatorGetInfoUVCapable" scope="prototype"/>  
         
diff --git a/webauthn-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/admin/webauthn-registration/webauthn-registration-flow.xml b/webauthn-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/admin/webauthn-registration/webauthn-registration-flow.xml
index 4f80a81..c729ece 100644
--- a/webauthn-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/admin/webauthn-registration/webauthn-registration-flow.xml
+++ b/webauthn-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/admin/webauthn-registration/webauthn-registration-flow.xml
@@ -135,6 +135,7 @@
    <action-state id="AddKey">
         <evaluate expression="ExtractPublicKeyCredentialAttestationFromFormRequest"/>
         <evaluate expression="CheckAuthenticatorPolicy"/>
+        <evaluate expression="AttachAuthenticatorCapabilities"/>
         <evaluate expression="ValidateAuthenticatorAttestationResponse"/>
         <evaluate expression="StorePublicKeyCredential"/> 
         <evaluate expression="'proceed'" />
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 7e42482..a2b515e 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
@@ -97,6 +97,18 @@ idp.authn.webauthn.supportedPrincipals = \
 # When the 'authenticator capabilities' policy is enabled, which policy bean should be applied
 #idp.authn.webauthn.registration.authenticator.policy.authenticatorCapabilities = shibboleth.authn.webauthn.registration.authenticator.policy.AuthenticatorGetInfoUVCapable
 
+# Enable the authenticator assessor engine?
+#idp.authn.webauthn.registration.authenticator.assessor.enabled = false
+# Set the authenticator assessors to use, defaults to a chained set of assessors
+#idp.authn.webauthn.registration.authenticator.assessor = shibboleth.authn.webauthn.registration.ChainedAuthenticatorCapabilitiesAssessor
+# When using the chained assessors, give the name of the list of assessors
+#idp.authn.webauthn.registration.authenticator.assessor.chainedlist = shibboleth.authn.webauthn.registration.ChainedAuthenticatorAssessors
+# When using the default chained set of assessors, should we enable the 'second factor only' assessor
+#idp.authn.webauthn.registration.authenticator.assessor.secondFactorOnlyAssessor.enabled = true
+# When using the default chained set of assessors, 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.assessor.secondFactorOnlyAuthenticators =
+
+
 # Allow inline self-enrolment 
 #idp.authnwebauthn.registration.allowInline = true
 

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


More information about the commits mailing list