[java-idp-plugin-webauthn] branch main updated: JWEBAUTHN-5 - Add support for FIDO Alliance Metadata

Phil Smart philip.smart at jisc.ac.uk
Fri Mar 15 10:37:46 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=9b3b2d7b9f507b31fc73c7ef5bebf79a64aebc75

The following commit(s) were added to refs/heads/main by this push:
     new 9b3b2d7  JWEBAUTHN-5 - Add support for FIDO Alliance Metadata
9b3b2d7 is described below

commit 9b3b2d7b9f507b31fc73c7ef5bebf79a64aebc75
Author: Phil Smart <philip.smart at jisc.ac.uk>
AuthorDate: Fri Mar 15 10:37:41 2024 +0000

    JWEBAUTHN-5 - Add support for FIDO Alliance Metadata
    
     - Added support for the Yubico metadata service if enabled
     - Allows verification of authenticator attestations during registration
    if the attestation statment is requested and supplied by the
    authenticator
     - Added to credential storage record when stored
     - Enhances the registration UI to show Authenticator information
     - Add local metadata for testing
    
    https://shibboleth.atlassian.net/browse/JWEBAUTHN-5
---
 pom.xml                                            |  25 ++-
 webauthn-api/pom.xml                               |   5 +
 .../webauthn/storage/CredentialRegistration.java   | 184 +++++++++++++---
 webauthn-impl/pom.xml                              |  15 ++
 .../impl/AbstractWebAuthnRegistrationAction.java   |  35 ++-
 .../admin/impl/StorePublicKeyCredential.java       |  43 +++-
 .../client/impl/YubicoWebauthnClientFactory.java   |  74 ++++++-
 .../metadata/FidoMetadataServiceFactory.java       | 241 +++++++++++++++++++++
 .../META-INF/net.shibboleth.idp/postconfig.xml     |  16 +-
 .../webauthn-registration-beans.xml                |  14 +-
 .../authn/WebAuthn/webauthn-abstract-beans.xml     |  10 +-
 .../idp/flows/authn/WebAuthn/webauthn-beans.xml    |   6 +
 .../idp/plugin/authn/webauthn/css/webauthn.css     |   6 +
 .../authn/webauthn/views/webauthn-register.vm      |  20 +-
 .../authn/webauthn/impl/AbstractWebAuthnTest.java  |  42 ++++
 .../FidoMetadataServiceResolverFactoryTest.java    | 101 +++++++++
 webauthn-impl/src/test/resources/fido-metadata.bin |   1 +
 webauthn-impl/src/test/resources/root-r3.crt       | Bin 0 -> 867 bytes
 18 files changed, 770 insertions(+), 68 deletions(-)

diff --git a/pom.xml b/pom.xml
index b68127b..d446944 100644
--- a/pom.xml
+++ b/pom.xml
@@ -19,8 +19,10 @@
     <description>WebAuthn plugin for the Shibboleth IdP.</description>
 
     <properties>
-        <maven-dist-enforcer-data.version>1.0.15-SNAPSHOT</maven-dist-enforcer-data.version>
+        <maven-dist-enforcer-data.version>1.0.15</maven-dist-enforcer-data.version>
         <shibboleth.projectName>java-idp-plugin-webauthn</shibboleth.projectName>
+        <okhttp3.mockserver.version>4.9.3</okhttp3.mockserver.version>
+        <okhttp3.tls.version>4.9.3</okhttp3.tls.version>
         <idp.groupId>net.shibboleth.idp</idp.groupId>
         <idp.version>5.0.0</idp.version>
         <opensaml.groupId>org.opensaml</opensaml.groupId>
@@ -39,8 +41,7 @@
         <com-upokecenter.version>4.5.2</com-upokecenter.version>
         <numbers.groupId>com.github.peteroupc</numbers.groupId>
         <numbers.version>1.8.2</numbers.version>
-        <checkstyle.configLocation>
-            ${project.basedir}/resources/checkstyle/checkstyle.xml</checkstyle.configLocation>
+        <checkstyle.configLocation>${project.basedir}/resources/checkstyle/checkstyle.xml</checkstyle.configLocation>
     </properties>
 
     <modules>
@@ -86,6 +87,11 @@
                 <artifactId>idp-plugin-webauthn-impl</artifactId>
                 <version>${project.version}</version>
             </dependency>
+            <dependency>
+                <groupId>${yubico.groupId}</groupId>
+                <artifactId>webauthn-server-attestation</artifactId>
+                <version>${yubico-webauthn.version}</version>
+            </dependency>
             <dependency>
                 <groupId>${yubico.groupId}</groupId>
                 <artifactId>webauthn-server-core</artifactId>
@@ -161,6 +167,19 @@
                 <type>pom</type>
                 <scope>import</scope>
             </dependency>
+            <!-- Test dependencies -->
+             <dependency>
+                <groupId>com.squareup.okhttp3</groupId>
+                <artifactId>mockwebserver</artifactId>
+                <version>${okhttp3.mockserver.version}</version>
+                <scope>test</scope>
+            </dependency>
+            <dependency>
+                <groupId>com.squareup.okhttp3</groupId>
+                <artifactId>okhttp-tls</artifactId>
+                <version>${okhttp3.tls.version}</version>
+                <scope>test</scope>
+            </dependency>
         </dependencies>
     </dependencyManagement>
 
diff --git a/webauthn-api/pom.xml b/webauthn-api/pom.xml
index 018f0aa..7603c4a 100644
--- a/webauthn-api/pom.xml
+++ b/webauthn-api/pom.xml
@@ -55,6 +55,11 @@
             <artifactId>webauthn-server-core</artifactId>
             <scope>provided</scope>
         </dependency>
+        <dependency>
+            <groupId>${yubico.groupId}</groupId>
+            <artifactId>webauthn-server-attestation</artifactId>
+            <scope>provided</scope>
+        </dependency>
         <dependency>
             <groupId>com.google.code.findbugs</groupId>
             <artifactId>jsr305</artifactId>
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 126a0b9..e65b8d7 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
@@ -21,6 +21,7 @@ import java.time.Instant;
 import java.util.Collections;
 import java.util.Objects;
 import java.util.Optional;
+import java.util.Set;
 import java.util.SortedSet;
 
 import javax.annotation.Nonnull;
@@ -33,11 +34,16 @@ import com.fasterxml.jackson.annotation.JsonIgnore;
 import com.fasterxml.jackson.annotation.JsonProperty;
 import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
 import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder;
+import com.yubico.fido.metadata.MetadataBLOBPayloadEntry;
 import com.yubico.webauthn.RegisteredCredential;
 import com.yubico.webauthn.data.AuthenticatorTransport;
 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.Unmodifiable;
+import net.shibboleth.shared.collection.CollectionSupport;
+
 /**
  * Registration record used to hold registered credentials.
  * 
@@ -58,19 +64,19 @@ public class CredentialRegistration {
      * The set of {@link AuthenticatorTransport transports} the authenticator can
      * use to communicate with the client.
      */
-    @Nonnull private final SortedSet<AuthenticatorTransport> transports;
+    @Nonnull @Unmodifiable @NonnullElements private final SortedSet<AuthenticatorTransport> transports;
 
     /** The time the registration took place. */
     @Nonnull private final Instant registrationTime;
 
-    /** Is the credential a discovery type (passkey). Empty if not known. */
+    /** Is the credential a discoverable type (passkey). Empty if not known. */
     @Nonnull private final Optional<Boolean> discoverable;
 
     /** The credential to register. */
-    @Nonnull private final RegisteredCredential credential;
+    @Nonnull @Unmodifiable @NonnullElements private final RegisteredCredential credential;
 
-    /** Optional attestation metadata about the authenticator. */
-    @Nullable private final Object attestationMetadata;
+    /** Optional attestation metadata about the authenticator. Will be an empty set if not used. */
+    @Nonnull @Unmodifiable @NonnullElements private final Set<MetadataBLOBPayloadEntry> attestationMetadata;
 
     /** Was the user verified during registration. */
     private final boolean userVerified;
@@ -81,6 +87,7 @@ public class CredentialRegistration {
      *
      * @param builder the builder
      */
+    //TODO look at nullable here.
     private CredentialRegistration(final Builder builder) {
         this.userIdentity = builder.userIdentity;
         this.transports = builder.transports;
@@ -90,53 +97,158 @@ public class CredentialRegistration {
         this.discoverable = builder.discoverable;
         this.attestationMetadata = builder.attestationMetadata;
         this.userVerified = builder.userVerified;
+
     }
 
+    /**
+     * Was user verification performed during registration?
+     * 
+     * @return true if user registration was performed, false otherwise
+     */
     @JsonGetter("userVerified")
     public boolean isUserVerified() {
         return userVerified;
     }
 
+    /**
+     * Is the created credential discoverable? 
+     * 
+     * @return true if the credential is discoverable, false if it isn't, empty if unknown.
+     */
     @JsonGetter("discoverable")
     @Nonnull public Optional<Boolean> isDiscoverable() {
         return discoverable;
     }
     
+    /**
+     * Get the nickname of this credential. 
+     * 
+     * @return the nickname
+     */
     @JsonGetter("nickname")
-    public String getNickname() {
+    @Nullable public String getNickname() {
         return credentialNickname;
     }
 
+    /**
+     * Get the time the registration took place.
+     * 
+     * @return the time of registration
+     */
     @JsonGetter("registrationTime")
-    public Instant getRegistrationTime() {
+    @Nonnull public Instant getRegistrationTime() {
         return registrationTime;
     }
 
+    /**
+     * Get the name of the user identity registered.
+     * 
+     * @return the name of the user identity
+     */
     @JsonIgnore
-    public String getUsername() {
+    @Nonnull public String getUsername() {
         return userIdentity.getName();
     }
 
+    /**
+     * Get the identity of the user this credential has been registered for.
+     * 
+     * @return the users identity
+     */
     @JsonGetter("userIdentity")
-    public UserIdentity getUserIdentity() {
+    @Nonnull public UserIdentity getUserIdentity() {
         return userIdentity;
     }
 
+    /**
+     * Get the registered credential
+     * 
+     * @return the registered credential
+     */
     @JsonGetter("credential")
-    public RegisteredCredential getCredential() {
+    @Nonnull public RegisteredCredential getCredential() {
         return credential;
     }
 
+    /**
+     * Get the set of {@link AuthenticatorTransport transports} the authenticator can
+     * use to communicate with the client.
+     * 
+     * @return the set of transports the authenticator can use to communicate with the client
+     */
     @JsonGetter("transports")
-    public SortedSet<AuthenticatorTransport> getTransports() {
+    @Nonnull @Unmodifiable @NonnullElements public SortedSet<AuthenticatorTransport> getTransports() {
         return transports;
     }
     
-    // TODO do we need these here. They should be transformed where needed
+    /**
+     * Get metadata about an authenticators attestation.
+     * 
+     * @return the metadata about an authenticators attestation
+     */
+    @JsonGetter("attestationMetadata")
+    @Nonnull @Unmodifiable @NonnullElements public Set<MetadataBLOBPayloadEntry> getAttestationMetadata(){
+        return attestationMetadata;
+        
+    }
+    
+    /**
+     * Get the credential ID as a base64URL encoded string.
+     * 
+     * @return the credential ID base64URL encoded
+     */
     @JsonIgnore
     public String getCredentialIdBase64Url() {
         return credential.getCredentialId().getBase64Url();
     }
+    
+    /**
+     * Get the human-readable, short description of the authenticator (in English) iff the attestation metadata exist.
+     * 
+     * <p>If there is more than one metadata entry, it picks the first it can find with a description.</p>
+     * 
+     * @return the  human-readable, short description of the authenticator (in English), or <code>null</code>.
+     */
+    @JsonIgnore
+    @Nullable public String getAuthenticatorDescription() {
+        if (attestationMetadata != null && !attestationMetadata.isEmpty()) {
+            final Optional<Optional<String>> descriptionFound = attestationMetadata.stream()
+                .filter(mtd -> mtd.getMetadataStatement().isPresent())
+                .map(mtd -> mtd.getMetadataStatement().get().getDescription()).findFirst();
+            
+            if (descriptionFound.isEmpty() || descriptionFound.get().isEmpty() ||  
+                    descriptionFound.get().get().isEmpty()) {
+                return null;
+            }
+            return descriptionFound.get().get();            
+        }
+        return null;
+    }
+    
+    /** 
+     * Get a <code>data:</code> URL encoded PNG icon for the authenticator.
+     * 
+     * <p>If there is more than one metadata entry, it picks the first it can find with an icon.</p>
+     * 
+     * @return the icon encoded as a PNG <code>data:</code> URL 
+     */
+    @JsonIgnore
+    @Nullable public String getIcon() {
+        if (attestationMetadata != null && !attestationMetadata.isEmpty()) {
+            final Optional<Optional<String>> iconFound = attestationMetadata.stream()
+                .filter(mtd -> mtd.getMetadataStatement().isPresent())
+                .map(mtd -> mtd.getMetadataStatement().get().getIcon()).findFirst();
+            
+            if (iconFound.isEmpty() || iconFound.get().isEmpty() ||  
+                    iconFound.get().get().isEmpty()) {
+                return null;
+            }
+            return iconFound.get().get();            
+        }
+        return null;
+        
+    }
+    
     /**
      * Convert the credential registration into a {@link PublicKeyCredentialDescriptor}.
      * 
@@ -181,7 +293,7 @@ public class CredentialRegistration {
      * @return a new {@link CredentialRegistration} instance
      */
     @JsonIgnore
-    public CredentialRegistration withCredential(final RegisteredCredential newRegisteredCred) {
+    public CredentialRegistration withCredential(@Nonnull final RegisteredCredential newRegisteredCred) {
         return CredentialRegistration.builder()
                 .withUserIdentity(userIdentity)
                 .withTransports(transports)
@@ -201,33 +313,34 @@ public class CredentialRegistration {
 
     /** Builder stage.*/
     public interface IUserIdentityStage {
-        public ITransportsStage withUserIdentity(UserIdentity userIdentity);
+        @Nonnull public ITransportsStage withUserIdentity(@Nonnull final UserIdentity userIdentity);
     }
 
     /** Builder stage.*/
     public interface ITransportsStage {
-        public IRegistrationTimeStage withTransports(SortedSet<AuthenticatorTransport> transports);
+        @Nonnull public IRegistrationTimeStage withTransports(@Nonnull SortedSet<AuthenticatorTransport> transports);
     }
 
     /** Builder stage.*/
     public interface IRegistrationTimeStage {
-        public ICredentialStage withRegistrationTime(Instant registrationTime);
+        @Nonnull public ICredentialStage withRegistrationTime(@Nonnull final Instant registrationTime);
     }
 
     /** Builder stage.*/
     public interface ICredentialStage {
-        public IBuildStage withCredential(RegisteredCredential credential);
+        @Nonnull public IBuildStage withCredential(@Nonnull final RegisteredCredential credential);
     }
 
     /** Builder stage.*/
     public interface IBuildStage {
-        public IBuildStage withCredentialNickname(String credentialNickname);
+        @Nonnull public IBuildStage withCredentialNickname(@Nullable final String credentialNickname);
 
-        public IBuildStage withDiscoverable(Optional<Boolean> discoverable);
+        @Nonnull public IBuildStage withDiscoverable(@Nonnull final Optional<Boolean> discoverable);
 
-        public IBuildStage withAttestationMetadata(Object attestationMetadata);
+        @Nonnull public IBuildStage withAttestationMetadata(
+                @Nonnull final Set<MetadataBLOBPayloadEntry> attestationMetadata);
 
-        public IBuildStage withUserVerified(boolean userVerified);
+        @Nonnull public IBuildStage withUserVerified(boolean userVerified);
 
         @Nonnull public CredentialRegistration build();
     }
@@ -240,78 +353,79 @@ public class CredentialRegistration {
         private SortedSet<AuthenticatorTransport> transports;
         private Instant registrationTime;
         private RegisteredCredential credential;
-        private String credentialNickname;
-        private Optional<Boolean> discoverable;
-        private Object attestationMetadata;
+        @Nullable private String credentialNickname;
+        @Nonnull private Optional<Boolean> discoverable;
+        @Nonnull private Set<MetadataBLOBPayloadEntry> attestationMetadata;
         private boolean userVerified;
 
         /** Constructor.*/
         private Builder() {
             // Create empty, corresponds to 'unknown'
-            discoverable = Optional.empty();
-            
+            discoverable = Optional.empty();            
             userVerified = false;
             transports = Collections.emptySortedSet();
+            attestationMetadata = CollectionSupport.emptySet();
         }
 
         @Override
         @JsonProperty("userIdentity")
-        public ITransportsStage withUserIdentity(final UserIdentity user) {
+        @Nonnull public ITransportsStage withUserIdentity(@Nonnull final UserIdentity user) {
             userIdentity = user;
             return this;
         }
 
         @Override
         @JsonProperty("transports")
-        public IRegistrationTimeStage withTransports(final SortedSet<AuthenticatorTransport> authenticatorTransports) {
+        @Nonnull public IRegistrationTimeStage withTransports(@Nonnull
+                final SortedSet<AuthenticatorTransport> authenticatorTransports) {
             transports = authenticatorTransports;
             return this;
         }
 
         @Override
         @JsonProperty("registrationTime")
-        public ICredentialStage withRegistrationTime(final Instant time) {
+        @Nonnull public ICredentialStage withRegistrationTime(@Nonnull final Instant time) {
             registrationTime = time;
             return this;
         }
 
         @Override
         @JsonProperty("credential")
-        public IBuildStage withCredential(final RegisteredCredential cred) {
+        @Nonnull public IBuildStage withCredential(@Nonnull final RegisteredCredential cred) {
             credential = cred;
             return this;
         }
 
         @Override
         @JsonProperty("nickname")
-        public IBuildStage withCredentialNickname(final String credNickname) {
+        @Nonnull public IBuildStage withCredentialNickname(@Nullable final String credNickname) {
             credentialNickname = credNickname;
             return this;
         }
 
         @Override
         @JsonProperty("discoverable")
-        public IBuildStage withDiscoverable(final Optional<Boolean> isDiscoverable) {
+        @Nonnull public IBuildStage withDiscoverable(@Nonnull final Optional<Boolean> isDiscoverable) {
             discoverable = isDiscoverable;
             return this;
         }
 
         @Override
         @JsonProperty("attestationMetadata")
-        public IBuildStage withAttestationMetadata(final Object attestationMtd) {
+        @Nonnull public IBuildStage withAttestationMetadata(@Nonnull final Set<MetadataBLOBPayloadEntry> attestationMtd) {
             attestationMetadata = attestationMtd;
             return this;
         }
 
         @Override
         @JsonProperty("userVerified")
-        public IBuildStage withUserVerified(final boolean isUserVerified) {
+        @Nonnull public IBuildStage withUserVerified(final boolean isUserVerified) {
             userVerified = isUserVerified;
             return this;
         }
 
         @Override
-        public CredentialRegistration build() {
+        @Nonnull public CredentialRegistration build() {
             return new CredentialRegistration(this);
         }
     }
diff --git a/webauthn-impl/pom.xml b/webauthn-impl/pom.xml
index e8296ca..c3c4bae 100644
--- a/webauthn-impl/pom.xml
+++ b/webauthn-impl/pom.xml
@@ -31,6 +31,11 @@
             <artifactId>webauthn-server-core</artifactId>
             <scope>compile</scope>
         </dependency>
+        <dependency>
+            <groupId>${yubico.groupId}</groupId>
+            <artifactId>webauthn-server-attestation</artifactId>
+            <scope>compile</scope>
+        </dependency>
         <dependency><!-- TODO check the IdP provides this -->
             <groupId>com.fasterxml.jackson.datatype</groupId>
             <artifactId>jackson-datatype-jdk8</artifactId>
@@ -172,6 +177,16 @@
             <scope>provided</scope>
         </dependency>
         <!-- Test dependencies -->
+        <dependency>
+            <groupId>com.squareup.okhttp3</groupId>
+            <artifactId>mockwebserver</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>com.squareup.okhttp3</groupId>
+            <artifactId>okhttp-tls</artifactId>
+            <scope>test</scope>
+        </dependency>
         <dependency>
             <groupId>${idp.groupId}</groupId>
             <artifactId>idp-testing</artifactId>
diff --git a/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/admin/impl/AbstractWebAuthnRegistrationAction.java b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/admin/impl/AbstractWebAuthnRegistrationAction.java
index 946b65e..7edcac6 100644
--- a/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/admin/impl/AbstractWebAuthnRegistrationAction.java
+++ b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/admin/impl/AbstractWebAuthnRegistrationAction.java
@@ -20,6 +20,7 @@ package net.shibboleth.idp.plugin.authn.webauthn.admin.impl;
 import java.util.function.Function;
 
 import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
 
 import org.opensaml.messaging.context.navigate.ChildContextLookup;
 import org.opensaml.profile.action.ActionSupport;
@@ -27,6 +28,8 @@ import org.opensaml.profile.action.EventIds;
 import org.opensaml.profile.context.ProfileRequestContext;
 import org.slf4j.Logger;
 
+import com.yubico.fido.metadata.FidoMetadataService;
+
 import net.shibboleth.idp.plugin.authn.webauthn.client.WebAuthnAuthenticationClient;
 import net.shibboleth.idp.plugin.authn.webauthn.context.WebAuthnRegistrationContext;
 import net.shibboleth.idp.plugin.authn.webauthn.storage.StorageServiceCredentialRepository;
@@ -69,6 +72,14 @@ public abstract class AbstractWebAuthnRegistrationAction extends AbstractProfile
     /** The credential respository to store valid credentials in.*/
     @NonnullAfterInit private StorageServiceCredentialRepository credentialRepository;
     
+    /** Optional FIDO metadata service resolver.*/ 
+    @Nullable private FidoMetadataService fidoMetadataService;    
+    
+    /** Constructor.*/
+    protected AbstractWebAuthnRegistrationAction() {
+        //prc -> WebAuthnContext
+        webauthnRegistrationContextLookupStrategy = new ChildContextLookup<>(WebAuthnRegistrationContext.class);
+    }
     
     /**
      * Set the WebAuthn client used to handle registration and authentication ceremonies.
@@ -85,18 +96,30 @@ public abstract class AbstractWebAuthnRegistrationAction extends AbstractProfile
      * 
      * @return the webAuthnClient.
      */
-    @NonnullBeforeExec public WebAuthnAuthenticationClient getWebAuthnClient() {
+    @NonnullBeforeExec protected WebAuthnAuthenticationClient getWebAuthnClient() {
         checkComponentActive();
         return webAuthnClient;
     }
-        
     
-    /** Constructor.*/
-    protected AbstractWebAuthnRegistrationAction() {
-        //prc -> WebAuthnContext
-        webauthnRegistrationContextLookupStrategy = new ChildContextLookup<>(WebAuthnRegistrationContext.class);
+    /**
+     * Set the FIDO Alliance metadata service resolver to use as the attestation trust source.
+     * 
+     * @param service The FIDO metadata service to set.
+     */
+    public void setFidoMetadataService(@Nullable final FidoMetadataService service) {
+        checkSetterPreconditions();
+        fidoMetadataService = service;
     }
     
+    /**
+     * Get the FIDO Alliance metadata service resolver to use as the attestation trust source.
+     * 
+     * @return the fido metadata service.
+     */
+    @Nullable protected FidoMetadataService getFidoMetadataService() {
+        checkComponentActive();
+        return fidoMetadataService;
+    }
     
     /**
      * Set WebAuthn registration context lookup strategy to use.
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 ce0bd73..90e40bd 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
@@ -18,6 +18,7 @@
 package net.shibboleth.idp.plugin.authn.webauthn.admin.impl;
 
 import java.time.Instant;
+import java.util.Optional;
 import java.util.Set;
 import java.util.TreeSet;
 
@@ -30,6 +31,9 @@ import org.opensaml.storage.StorageSerializer;
 import org.opensaml.storage.StorageService;
 import org.slf4j.Logger;
 
+import com.yubico.fido.metadata.AAGUID;
+import com.yubico.fido.metadata.FidoMetadataService;
+import com.yubico.fido.metadata.MetadataBLOBPayloadEntry;
 import com.yubico.webauthn.RegisteredCredential;
 import com.yubico.webauthn.RegistrationResult;
 import com.yubico.webauthn.data.ByteArray;
@@ -40,6 +44,9 @@ import net.shibboleth.idp.plugin.authn.webauthn.context.WebAuthnRegistrationCont
 import net.shibboleth.idp.plugin.authn.webauthn.storage.CredentialRegistration;
 import net.shibboleth.idp.plugin.authn.webauthn.storage.impl.CredentialRegistrationSerializer;
 import net.shibboleth.shared.annotation.constraint.NonnullAfterInit;
+import net.shibboleth.shared.annotation.constraint.NonnullElements;
+import net.shibboleth.shared.annotation.constraint.NotLive;
+import net.shibboleth.shared.collection.CollectionSupport;
 import net.shibboleth.shared.component.ComponentInitializationException;
 import net.shibboleth.shared.logic.Constraint;
 import net.shibboleth.shared.primitive.LoggerFactory;
@@ -114,21 +121,28 @@ public class StorePublicKeyCredential extends AbstractWebAuthnRegistrationAction
                     .publicKeyCose(registrationResult.getPublicKeyCose())
                     .build();
             
+            //TODO user identity should come from somewhere else?
             final UserIdentity user = UserIdentity.builder()
                     .name(username)
                     .displayName(username)
                     .id(new ByteArray(context.getUserId()))
                     .build();
             
+            assert user != null;
+            assert credential != null;
+            final Instant now = Instant.now();
+            assert now != null;
+            final  Optional<Boolean> isDiscoverable = registrationResult.isDiscoverable();
+            assert isDiscoverable != null;
+            
             final CredentialRegistration registration = CredentialRegistration.builder()
                     .withUserIdentity(user)
                     .withTransports(registrationResult.getKeyId().getTransports().orElse(new TreeSet<>()))
-                    .withRegistrationTime(Instant.now())
+                    .withRegistrationTime(now)
                     .withCredential(credential)
-                    //TODO this should not be null, we should support attestation even if not initially used
-                    .withAttestationMetadata(null)
+                    .withAttestationMetadata(getAttestationMetadata(registrationResult.getAaguid()))
                     .withCredentialNickname(context.getCredentialNickname())
-                    .withDiscoverable(registrationResult.isDiscoverable())
+                    .withDiscoverable(isDiscoverable)
                     .withUserVerified(registrationResult.isUserVerified())
                     .build();
             
@@ -150,4 +164,25 @@ public class StorePublicKeyCredential extends AbstractWebAuthnRegistrationAction
 
     }
 
+    /**
+     * Find attestation metadata for the authenticator. 
+     * 
+     * @param authenticatorId the authenticator attestation GUID
+     * 
+     * @return the attestation metadata relating to the authenticator attestation GUID
+     */
+    @Nonnull @NotLive @NonnullElements private Set<MetadataBLOBPayloadEntry> getAttestationMetadata(
+            final ByteArray authenticatorId) {
+        final FidoMetadataService localMetadataService = getFidoMetadataService();
+        if (localMetadataService != null) {
+            final Set<MetadataBLOBPayloadEntry> found =
+                    localMetadataService.findEntries(new AAGUID(authenticatorId));
+            if (found == null) {
+                return CollectionSupport.emptySet();
+            }
+            return CollectionSupport.copyToSet(found);
+        }
+        return CollectionSupport.emptySet();
+    }
+
 }
diff --git a/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/client/impl/YubicoWebauthnClientFactory.java b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/client/impl/YubicoWebauthnClientFactory.java
index 5fa6d0c..85b6c31 100644
--- a/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/client/impl/YubicoWebauthnClientFactory.java
+++ b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/client/impl/YubicoWebauthnClientFactory.java
@@ -25,9 +25,11 @@ import javax.annotation.Nullable;
 import javax.annotation.concurrent.GuardedBy;
 import javax.annotation.concurrent.ThreadSafe;
 
+import org.slf4j.Logger;
 import org.springframework.beans.factory.FactoryBean;
 
 import com.google.common.base.Predicates;
+import com.yubico.fido.metadata.FidoMetadataService;
 import com.yubico.webauthn.CredentialRepository;
 import com.yubico.webauthn.RelyingParty;
 import com.yubico.webauthn.data.COSEAlgorithmIdentifier;
@@ -42,6 +44,7 @@ import net.shibboleth.shared.collection.CollectionSupport;
 import net.shibboleth.shared.component.AbstractInitializableComponent;
 import net.shibboleth.shared.component.ComponentInitializationException;
 import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.primitive.LoggerFactory;
 import net.shibboleth.shared.primitive.StringSupport;
 
 /**
@@ -50,6 +53,9 @@ import net.shibboleth.shared.primitive.StringSupport;
 @ThreadSafe
 public class YubicoWebauthnClientFactory extends AbstractInitializableComponent 
             implements FactoryBean<WebAuthnAuthenticationClient> {
+    
+    /** Class logger. */
+    @Nonnull private final Logger log = LoggerFactory.getLogger(YubicoWebauthnClientFactory.class);
 
     /** The relying party identifier.*/
     @GuardedBy("this") @NonnullAfterInit private String relyingPartyId;
@@ -75,6 +81,12 @@ public class YubicoWebauthnClientFactory extends AbstractInitializableComponent
     /** List of acceptable public key algorithms.*/
     @GuardedBy("this") @Nonnull @NonnullElements private List<PublicKeyCredentialParameters> preferredPublickeyParams;
     
+    /** Should we allow an untrusted attestation? */
+    @GuardedBy("this") private boolean allowUntrustedAttestation;
+    
+    /** If configured, a FIDO metadata service resolver.*/ 
+    @GuardedBy("this") @Nullable private FidoMetadataService fidoMetadataService;
+    
     /** Constructor.*/
     public YubicoWebauthnClientFactory() {
         allowOriginPort = false;
@@ -117,17 +129,32 @@ public class YubicoWebauthnClientFactory extends AbstractInitializableComponent
                 .name(getRelyingPartyName())
                 .build()).credentialRepository(getCredentialRepository())
         .allowOriginPort(isAllowOriginPort())
-        .allowOriginSubdomain(isAllowOriginSubdomain());
+        .allowOriginSubdomain(isAllowOriginSubdomain())
+        .allowUntrustedAttestation(isAllowUntrustedAttestation())
+        .validateSignatureCounter(allowOriginPort);
+        
+        // Add the fido metadata service if one has been configured
+        final FidoMetadataService localMetadataService = getFidoMetadataService();
+        if (localMetadataService != null) {
+            builder.attestationTrustSource(localMetadataService);
+        }
+        
+        log.info("Built Yubico WebAuthn Client for RelyingParty '{}', using FIDO metadata '{}', allowOriginPort '{}', "
+                + "allowOriginSubdomain '{}', allowUntrustedMetadata '{}'", getRelyingPartyId(), 
+                localMetadataService != null ? "yes" : "no", isAllowOriginPort(), isAllowOriginSubdomain(), 
+                        isAllowUntrustedAttestation());
         
         if (!getOrigins().isEmpty()) {            
             final RelyingParty rp = builder.origins(getOrigins()).build();
-            assert rp != null;
+            assert rp != null;            
             return new YubicoWebAuthnAuthenticationClient(rp, getPreferredPublickeyParams());
         } else {
             final RelyingParty rp = builder.build();
             assert rp != null;
             return new YubicoWebAuthnAuthenticationClient(rp, getPreferredPublickeyParams());
-        }        
+        }   
+        
+        
         
     }
     
@@ -331,6 +358,47 @@ public class YubicoWebauthnClientFactory extends AbstractInitializableComponent
         checkSetterPreconditions(); 
         allowOriginSubdomain = allow;
     }
+    
+    /**
+     * Set if untrusted attestations (registrations) are allowed.
+     * 
+     * @param allow are untrusted attestations allowed?
+     */
+    public synchronized void setAllowUntrustedAttestation(final boolean allow) {
+        checkSetterPreconditions(); 
+        allowUntrustedAttestation = allow;
+
+    }
+    
+    /**
+     * Allow untrusted attestations?
+     * 
+     * @return if untrusted attestations are allowed.
+     */
+    private synchronized boolean isAllowUntrustedAttestation() {
+        checkComponentActive();
+        return allowUntrustedAttestation;
+    }
+    
+    /**
+     * Set the FIDO Alliance metadata service resolver to use as the attestation trust source.
+     * 
+     * @param service The FIDO metadata service to set.
+     */
+    public synchronized void setFidoMetadataService(@Nullable final FidoMetadataService service) {
+        checkSetterPreconditions();
+        fidoMetadataService = service;
+    }
+    
+    /**
+     * Get the FIDO Alliance metadata service resolver to use as the attestation trust source.
+     * 
+     * @return the fido metadata service.
+     */
+    @Nullable public synchronized FidoMetadataService getFidoMetadataService() {
+        checkComponentActive();
+        return fidoMetadataService;
+    }
  
 
 }
diff --git a/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/metadata/FidoMetadataServiceFactory.java b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/metadata/FidoMetadataServiceFactory.java
new file mode 100644
index 0000000..0126fb2
--- /dev/null
+++ b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/metadata/FidoMetadataServiceFactory.java
@@ -0,0 +1,241 @@
+/*
+ * 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.metadata;
+
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import javax.annotation.concurrent.GuardedBy;
+
+import org.opensaml.security.x509.X509Support;
+import org.slf4j.Logger;
+import org.springframework.beans.FatalBeanException;
+import org.springframework.beans.factory.FactoryBean;
+import org.springframework.core.io.Resource;
+
+import com.yubico.fido.metadata.FidoMetadataDownloader;
+import com.yubico.fido.metadata.FidoMetadataService;
+
+import net.shibboleth.shared.annotation.constraint.NonnullAfterInit;
+import net.shibboleth.shared.component.AbstractIdentifiableInitializableComponent;
+import net.shibboleth.shared.component.ComponentInitializationException;
+import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.primitive.LoggerFactory;
+
+
+/**
+ * Spring factory bean for creating a {@link FidoMetadataService}.
+ */
+public class FidoMetadataServiceFactory extends AbstractIdentifiableInitializableComponent 
+        implements FactoryBean<FidoMetadataService> {
+    
+    /** Class logger. */
+    @Nonnull private final Logger log = LoggerFactory.getLogger(FidoMetadataServiceFactory.class);
+    
+    /** The file location of the trust root that verifies the downloaded metadata's signature.*/
+    @GuardedBy("this") @NonnullAfterInit private Resource trustRootFile;
+    
+    /** Where to cache the metadata blob.*/
+    @GuardedBy("this") @Nullable private Resource cacheFile;
+    
+    /** The HTTPS location of the metadata blob to download.*/
+    @GuardedBy("this") @Nullable private Resource metadataBlobUrl;
+    
+    /** The File location of the metadata blob. If this file is specified, metadata will never be downloaded over HTTP.*/
+    @GuardedBy("this") @Nullable private Resource metadataBlobFile;
+    
+    /** The expected set of legal headers on the FIDO metadata blob.*/
+    @GuardedBy("this") @NonnullAfterInit private String[] expectedLegalHeaders;
+
+    /** {@inheritDoc} */
+    @Override
+    public FidoMetadataService getObject() throws Exception {
+        
+        FidoMetadataDownloader downloader = null;
+        final Resource localMetadataBlobUrl = getMetadataBlobUrl();
+        final Resource localMetadataBlobFile = getMetadataBlobFile();
+        final Resource localMetadataCacheFile = getCacheFile();
+        
+        if (localMetadataBlobFile != null) {
+            log.debug("{}: Loading FIDO metadata blob from local file '{}'",getId(), metadataBlobFile);
+            downloader = FidoMetadataDownloader.builder()
+                .expectLegalHeader(getExpectedLegalHeaders())
+                .useTrustRoot(X509Support.decodeCertificate(getTrustRootFile().getFile()))
+                .useBlob(loadMetadataJwt(localMetadataBlobFile))
+                .build();
+        } else if (localMetadataBlobUrl != null && localMetadataCacheFile != null){
+            log.debug("{}: Loading FIDO metadata blob from '{}'", getId(), metadataBlobUrl);
+            downloader = FidoMetadataDownloader.builder()
+            .expectLegalHeader(getExpectedLegalHeaders())
+            .useTrustRoot(X509Support.decodeCertificate(getTrustRootFile().getFile()))
+            .downloadBlob(localMetadataBlobUrl.getURL())
+            .useBlobCacheFile(localMetadataCacheFile.getFile())
+            .verifyDownloadsOnly(true)
+            .build();
+        } else {
+            throw new FatalBeanException("Local FIDO metadata blob file not specified or the metadata blob URL and "
+                    + "local cache file not specified. Please use either a local file or a known URL");
+        }
+        assert downloader != null;
+        try {
+            final FidoMetadataService mds = FidoMetadataService.builder()
+                .useBlob(downloader.loadCachedBlob())
+                .build(); 
+            log.debug("{}: loaded FIDO metadata blob", getId());
+            return mds;
+        } catch (final Exception e) {
+            throw new FatalBeanException("Can not construct FIDO Metadata service", e);
+        }
+    }
+
+    
+    /**
+     * Load the given metadata blob file. 
+     * 
+     * @param file the metadata blob file to load 
+     * 
+     * @return the metadata blob as a string
+     * 
+     * @throws FileNotFoundException if the file does not exist 
+     */
+    @Nonnull private String loadMetadataJwt(@Nonnull final Resource file) throws IOException {
+        if (!file.exists()) {
+            throw new FileNotFoundException("Metadata blob file does not exist");
+        }
+        return file.getContentAsString(StandardCharsets.UTF_8);
+    }
+
+
+    @Override protected void doInitialize() throws ComponentInitializationException {
+        super.doInitialize();
+
+        if (trustRootFile ==  null) {
+            throw new ComponentInitializationException("trustRootFile cannot be null");
+        }
+        if (metadataBlobUrl ==  null && metadataBlobFile == null) {
+            throw new ComponentInitializationException("Metadata blob URL or file must be set");
+        }
+
+    }
+    
+    /**
+     * Set where to cache the metadata blob. 
+     * 
+     * @param file The cacheFile to set.
+     */
+    public synchronized void setCacheFile(@Nullable final Resource file) {
+        checkSetterPreconditions();
+        cacheFile = file;
+    }
+    
+    /**
+     * Get where to cache the metadata blob. 
+     * 
+     * @return where to cache the metadata blob.
+     */
+    @Nullable public synchronized Resource getCacheFile() {
+        checkComponentActive();
+        return cacheFile;
+    }
+    
+    /**
+     * Set the trust root file.
+     * 
+     * @param file the trust root file
+     */
+    public synchronized void setTrustRootFile(@Nonnull final Resource file) {
+        checkSetterPreconditions();
+        trustRootFile = Constraint.isNotNull(file, "trustRootCacheFile can not be null");
+    }
+    
+    /**
+     * Get the trust root file.
+     * 
+     * @return the trust root file
+     */
+    @NonnullAfterInit public synchronized Resource getTrustRootFile() {
+        checkComponentActive();
+        return trustRootFile;
+    }
+    
+    /**
+     * Get the URL of the metadata blob file to fetch.
+     * 
+     * @return the metadata blob file URL.
+     */
+    @Nullable public synchronized Resource getMetadataBlobUrl() {
+        checkComponentActive();
+        return metadataBlobUrl;
+    }
+    
+    /**
+     * Set the URL of the metadata blob file to fetch.
+     * 
+     * @param url the metadata blob file
+     */
+    public synchronized void setMetadataBlobUrl(@Nullable final Resource url) {
+        checkSetterPreconditions();
+        metadataBlobUrl = url;
+    }
+    
+    /**
+     * Set the location of the local metadata blob file. If this file is specified, metadata will never be 
+     * downloaded over HTTP.
+     * 
+     * @param metadataBlobFile The metadataBlobFile to set.
+     */
+    public synchronized void setMetadataBlobFile(@Nullable final Resource file) {
+        checkSetterPreconditions();
+        metadataBlobFile = file;
+    }
+    
+    /**
+     * Get the metadata blob file. 
+     * 
+     * @return the metadata blob file.
+     */
+    @Nullable public synchronized Resource getMetadataBlobFile() {
+        return metadataBlobFile;
+    }
+    
+    /**
+     * Set the expected legal headers on the FIDO metadata blob.
+     *  
+     * @param headers The expected legal headers to set.
+     */
+    public synchronized void setExpectedLegalHeaders(@Nonnull final String[] headers) {
+        checkSetterPreconditions();
+        expectedLegalHeaders = Constraint.isNotNull(headers, "expectedLegalHeaders can not be null");
+    }
+    
+    /**
+     * Get the expected legal headers on the FIDO metadata blob.
+     * 
+     * @return the expected legal headers.
+     */
+    @NonnullAfterInit public synchronized String[] getExpectedLegalHeaders() {
+        return expectedLegalHeaders;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Class<?> getObjectType() {
+        return FidoMetadataService.class;
+    }
+
+}
diff --git a/webauthn-impl/src/main/resources/META-INF/net.shibboleth.idp/postconfig.xml b/webauthn-impl/src/main/resources/META-INF/net.shibboleth.idp/postconfig.xml
index 9047d51..269b7d6 100644
--- a/webauthn-impl/src/main/resources/META-INF/net.shibboleth.idp/postconfig.xml
+++ b/webauthn-impl/src/main/resources/META-INF/net.shibboleth.idp/postconfig.xml
@@ -72,9 +72,11 @@
         p:relyingPartyName="%{idp.authn.webauthn.relyingPartyName}"
         p:allowOriginPort="%{idp.authn.webauthn.allowOriginPort:false}"
         p:allowOriginSubdomain="%{idp.authn.webauthn.allowOriginSubdomain:false}"
+        p:allowUntrustedAttestation="%{idp.authn.webauthn.allowUntrustedAttestation:false}"
         p:origins="%{idp.authn.webauthn.origins:}"
         p:preferredPublickeyParams="%{idp.authn.webauthn.preferredPublicKeyParams:EdDSA,ES256,ES384,ES512,RS1,RS256,RS384,RS512}"
-        p:credentialRepository-ref="shibboleth.authn.webauthn.DefaultCredentialRepository"/>
+        p:credentialRepository-ref="shibboleth.authn.webauthn.DefaultCredentialRepository"
+        p:fidoMetadataService="#{'false'.equals('%{idp.authn.webauthn.metadata.enabled:false}') ? null : getObject('shibboleth.authn.webauthn.DefaultWebAuthnFidoMetadataServiceFactory')}"/>
     
         
     <bean id="shibboleth.authn.webauthn.DefaultCredentialRepository" scope="singleton"
@@ -83,7 +85,17 @@
         p:serializer-ref="shibboleth.authn.webauthn.DefaultCredentialRepositoryStorageSerializer"/>        
         
      <bean id="shibboleth.authn.webauthn.DefaultCredentialRepositoryStorageSerializer"
-        class="net.shibboleth.idp.plugin.authn.webauthn.storage.impl.CredentialRegistrationSerializer"/>    
+        class="net.shibboleth.idp.plugin.authn.webauthn.storage.impl.CredentialRegistrationSerializer"/> 
+        
+    <!-- The optional FIDO Metadata service. Lazy-init, only constructed if used. -->   
+    <bean id="shibboleth.authn.webauthn.DefaultWebAuthnFidoMetadataServiceFactory" 
+        class="net.shibboleth.idp.plugin.authn.webauthn.metadata.FidoMetadataServiceFactory" scope="singleton"
+        lazy-init="true"
+        p:trustRootFile="%{idp.authn.webauthn.metadata.trustRootFile:}"
+        p:cacheFile="%{idp.authn.webauthn.metadata.cacheFile:}"
+        p:metadataBlobUrl="%{idp.authn.webauthn.metadata.metadataBlobUrl:https://mds.fidoalliance.org}"
+        p:metadataBlobFile="%{idp.authn.webauthn.metadata.metadataBlobFile:}"
+        p:expectedLegalHeaders="%{idp.authn.webauthn.metadata.expectedLegalHeaders:Retrieval and use of this BLOB indicates acceptance of the appropriate agreement located at https://fidoalliance.org/metadata/metadata-legal-terms/}"/>
            
      <!-- 
      
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 eb55081..ce418d5 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
@@ -17,7 +17,17 @@
     <bean id="shibboleth.ChildLookup.WebAuthnRegistrationContext"
         class="org.opensaml.messaging.context.navigate.ChildContextLookup"
         c:type="#{ T(net.shibboleth.idp.plugin.authn.webauthn.context.WebAuthnRegistrationContext) }" />
-
+        
+    <!-- Abstract parent beans -->    
+          
+    <bean id="AbstractWebAuthnRegistrationAction" scope="prototype" abstract="true"
+        p:webAuthnClient="#{getObject('shibboleth.authn.webauthn.DefaultWebAuthnAuthenticationClientFactory')}"
+        p:credentialRepository="#{getObject('shibboleth.authn.webauthn.DefaultCredentialRepository')}"
+        p:fidoMetadataService="#{'false'.equals('%{idp.authn.webauthn.metadata.enabled:false}') ? null : getObject('shibboleth.authn.webauthn.DefaultWebAuthnFidoMetadataServiceFactory')}"/>
+  
+    
+    <!-- Flow beans -->
+    
     <bean id="PopulateInitialWebAuthnRegistrationContext" scope="prototype"
         class="net.shibboleth.idp.plugin.authn.webauthn.admin.impl.PopulateWebAuthnRegistrationContext">
          <property name="usernameLookupStrategy">
@@ -53,7 +63,7 @@
         p:webAuthnBaseContextLookupStrategy-ref="shibboleth.ChildLookup.WebAuthnRegistrationContext"
         p:userVerificationRequirement="%{idp.authn.webauthn.registration.userVerification:discouraged}" />
 
-    <bean id="LookupRegisteredCredentials" parent="AbstractWebAuthnRegistrationAction"
+    <bean id="LookupRegisteredCredentials" parent="AbstractWebAuthnBaseAction"
         class="net.shibboleth.idp.plugin.authn.webauthn.impl.LookupRegisteredCredentials"
         p:webAuthnBaseContextLookupStrategy-ref="shibboleth.ChildLookup.WebAuthnRegistrationContext" />
 
diff --git a/webauthn-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/authn/WebAuthn/webauthn-abstract-beans.xml b/webauthn-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/authn/WebAuthn/webauthn-abstract-beans.xml
index c2c9107..745baf1 100644
--- a/webauthn-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/authn/WebAuthn/webauthn-abstract-beans.xml
+++ b/webauthn-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/authn/WebAuthn/webauthn-abstract-beans.xml
@@ -9,14 +9,8 @@
 
     default-init-method="initialize" default-destroy-method="destroy">
 
-    <!-- Parent beans -->        
-    <bean id="AbstractWebAuthnAuthenticationAction" scope="prototype" abstract="true"
-        p:webAuthnClient="#{getObject('shibboleth.authn.webauthn.DefaultWebAuthnAuthenticationClientFactory')}"/>
-        
-    <bean id="AbstractWebAuthnRegistrationAction" scope="prototype" abstract="true"
-        p:webAuthnClient="#{getObject('shibboleth.authn.webauthn.DefaultWebAuthnAuthenticationClientFactory')}"
-        p:credentialRepository="#{getObject('shibboleth.authn.webauthn.DefaultCredentialRepository')}"/>
-    
+    <!-- Abstract parent beans -->        
+      
     <bean id="AbstractWebAuthnBaseAction" scope="prototype" abstract="true"
         p:webAuthnClient="#{getObject('shibboleth.authn.webauthn.DefaultWebAuthnAuthenticationClientFactory')}"
         p:credentialRepository="#{getObject('shibboleth.authn.webauthn.DefaultCredentialRepository')}"/>
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 87ec29c..d476edd 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
@@ -14,7 +14,13 @@
         parent="shibboleth.Functions.Compose" c:f-ref="shibboleth.ChildLookup.AuthenticationContext"
         c:g-ref="shibboleth.ChildLookup.WebAuthnAuthenticationContext" />
 
+    <!-- Abstract parent beans -->
+    
+    <bean id="AbstractWebAuthnAuthenticationAction" scope="prototype" abstract="true"
+        p:webAuthnClient="#{getObject('shibboleth.authn.webauthn.DefaultWebAuthnAuthenticationClientFactory')}"/>
 
+    <!-- Flow beans -->
+    
     <bean id="PopulateWebAuthnAuthenticationContextPasswordless" scope="prototype"
         class="net.shibboleth.idp.plugin.authn.webauthn.impl.PopulateWebAuthnAuthenticationContext">
         <property name="usernameLookupStrategy">
diff --git a/webauthn-impl/src/main/resources/net/shibboleth/idp/plugin/authn/webauthn/css/webauthn.css b/webauthn-impl/src/main/resources/net/shibboleth/idp/plugin/authn/webauthn/css/webauthn.css
index 0f98e1e..e9e2433 100644
--- a/webauthn-impl/src/main/resources/net/shibboleth/idp/plugin/authn/webauthn/css/webauthn.css
+++ b/webauthn-impl/src/main/resources/net/shibboleth/idp/plugin/authn/webauthn/css/webauthn.css
@@ -85,4 +85,10 @@ tr:hover {
     text-decoration: none;
     display: inline-block;
     font-size: 16px;
+}
+
+.authenticator-logo {
+    display: inline;
+    margin-right: auto;
+    margin-left: auto;
 }
\ No newline at end of file
diff --git a/webauthn-impl/src/main/resources/net/shibboleth/idp/plugin/authn/webauthn/views/webauthn-register.vm b/webauthn-impl/src/main/resources/net/shibboleth/idp/plugin/authn/webauthn/views/webauthn-register.vm
index f4b7319..751808a 100644
--- a/webauthn-impl/src/main/resources/net/shibboleth/idp/plugin/authn/webauthn/views/webauthn-register.vm
+++ b/webauthn-impl/src/main/resources/net/shibboleth/idp/plugin/authn/webauthn/views/webauthn-register.vm
@@ -121,15 +121,25 @@
                      #if ($webauthnRegContext.existingCredentials)
                          <table>
                             <tr>
-                               <th>#springMessageText("idp.webauthn.register.table.keyName", "Key Name")</th>
-                               <th>#springMessageText("idp.webauthn.register.table.transports", "Transports")</th>
-                               <th>#springMessageText("idp.webauthn.register.table.passkey", "Passkey?")</th>
-                               <th>#springMessageText("idp.webauthn.register.table.registrationTime", "Registration Time")</th>
-                               <th>#springMessageText("idp.webauthn.register.table.action", "Action")</th>
+                               <th>#springMessageText("idp.webauthn.register.table.header.keyName", "Key Name")</th>
+                               <th>#springMessageText("idp.webauthn.register.table.header.authenticatorDescription", "Authenticator")</th>
+                               <th>#springMessageText("idp.webauthn.register.table.header.transports", "Transports")</th>
+                               <th>#springMessageText("idp.webauthn.register.table.header.passkey", "Passkey?")</th>
+                               <th>#springMessageText("idp.webauthn.register.table.header.registrationTime", "Registration Time")</th>
+                               <th>#springMessageText("idp.webauthn.register.table.header.action", "Action")</th>
                             </tr>
                             #foreach($cred in $webauthnRegContext.existingCredentials)
                             <tr>
                                <td>$encoder.encodeForHTML($cred.nickname)</td>
+                               #if ($cred.authenticatorDescription)
+                                   <td>
+                                   #if ($cred.icon)
+                                        <img class="authenticator-logo" src="$encoder.encodeForHTML($cred.icon) alt="authenticator-icon"/> 
+                                   #end 
+                                   $encoder.encodeForHTML($cred.authenticatorDescription)</td>
+                               #else
+                                    <td>#springMessageText("idp.webauthn.register.table.unknownCredential", "unknown")</td>
+                               #end
                                <td>$encoder.encodeForHTML($webAuthnEncoder.formatTransports($cred.transports))</td>
                                <td>$encoder.encodeForHTML($webAuthnEncoder.formatDiscoverable($cred.isDiscoverable()))</td>
                                <td>$encoder.encodeForHTML($webAuthnEncoder.formatInstant($cred.registrationTime))</td>
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 53186ee..098391a 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
@@ -14,6 +14,7 @@
 
 package net.shibboleth.idp.plugin.authn.webauthn.impl;
 
+import java.net.UnknownHostException;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -39,6 +40,10 @@ import net.shibboleth.idp.profile.context.navigate.WebflowRequestContextProfileR
 import net.shibboleth.idp.profile.testing.RequestContextBuilder;
 import net.shibboleth.shared.annotation.constraint.NotEmpty;
 import net.shibboleth.shared.collection.CollectionSupport;
+import okhttp3.mockwebserver.MockResponse;
+import okhttp3.mockwebserver.MockWebServer;
+import okhttp3.tls.HandshakeCertificates;
+import okhttp3.tls.HeldCertificate;
 
 /** Abstract class for tests that require context setup.*/
 public abstract class AbstractWebAuthnTest {
@@ -116,6 +121,43 @@ public abstract class AbstractWebAuthnTest {
         prc.addSubcontext(webAuthnRegContext);
     }  
     
+    /**
+     * Create a running server that mimics responses from a WebAuthn Metadata Service.
+     * Creates a new self-signed certificate.
+     * 
+     * @return the simple server.
+     * 
+     * @throws UnknownHostException on error.
+     */
+    protected MockWebServer createSimpleServer() throws UnknownHostException {
+        //start mock server
+        final MockWebServer mockServer = new MockWebServer();
+        final HeldCertificate localhostCertificate = new HeldCertificate.Builder()
+            .addSubjectAlternativeName("localhost")
+            .build();
+        final HandshakeCertificates serverCertificates = new HandshakeCertificates.Builder()
+                .heldCertificate(localhostCertificate)
+                .build();
+        mockServer.useHttps(serverCertificates.sslSocketFactory(), false);      
+        
+        return mockServer;
+    }
+    
+    /**
+     * Queue a mock response. Simulating a response from the OP.
+     * 
+     * @param mockOPServer the mock server
+     * @param code the response HTTP code
+     * @param body the response body
+     * @param contentType the content type header
+     */
+    protected void queueMockServerResponse(final MockWebServer mockOPServer, final int code, 
+            final String body, final String contentType) {
+        mockOPServer.enqueue(new MockResponse().setResponseCode(code)
+                .setHeader("content-type", contentType)
+                .setBody(body));
+    }
+    
     
     
     /**
diff --git a/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/metadata/FidoMetadataServiceResolverFactoryTest.java b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/metadata/FidoMetadataServiceResolverFactoryTest.java
new file mode 100644
index 0000000..a54c035
--- /dev/null
+++ b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/metadata/FidoMetadataServiceResolverFactoryTest.java
@@ -0,0 +1,101 @@
+/*
+ * 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.metadata;
+
+import static org.testng.Assert.assertNotNull;
+
+import java.net.MalformedURLException;
+import java.nio.charset.StandardCharsets;
+
+import org.springframework.beans.FatalBeanException;
+import org.springframework.core.io.ClassPathResource;
+import org.springframework.core.io.FileSystemResource;
+import org.springframework.core.io.Resource;
+import org.springframework.core.io.UrlResource;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+import com.yubico.fido.metadata.FidoMetadataService;
+
+import net.shibboleth.idp.plugin.authn.webauthn.impl.AbstractWebAuthnTest;
+import okhttp3.mockwebserver.MockWebServer;
+
+/**
+ * Tests for the {@link FidoMetadataServiceFactory}.
+ */
+public class FidoMetadataServiceResolverFactoryTest extends AbstractWebAuthnTest{
+    
+    
+    /** The factory to test.*/
+    private FidoMetadataServiceFactory factory;
+    
+    /** The metadata blob resource to use as if provided over HTTP.*/
+    private Resource httpMetadataBlobResource;
+    
+    @Override
+    @BeforeMethod
+    public void setup() throws MalformedURLException {
+        // Turn on CRL DP checking, otherwise the Yubico downloader will fail to find revocation status        
+        System.setProperty("com.sun.security.enableCRLDP", "true");
+        
+        factory = new FidoMetadataServiceFactory();
+        factory.setCacheFile(new FileSystemResource("cache-file.bin"));        
+        factory.setTrustRootFile(new ClassPathResource("root-r3.crt"));
+        factory.setExpectedLegalHeaders(new String[]{"headers"});
+        factory.setId("Test metadata factory");
+        httpMetadataBlobResource = new ClassPathResource("fido-metadata.bin");
+    }
+    
+    
+    @Test
+    public void testSuccessfullMetadataLoadFromFile() throws Exception {  
+        // Setting the local metadata blob file will force the factory to load from the local file
+        factory.setMetadataBlobFile(new ClassPathResource("fido-metadata.bin"));
+        factory.initialize();
+        final FidoMetadataService resolver = factory.getObject();
+        assertNotNull(resolver);
+        assert resolver != null;
+    }
+    
+    @Test(expectedExceptions = FatalBeanException.class)
+    public void testUnsuccessfull_RequiresCacheFileWhenURLUsed() throws Exception {  
+        // Setting the local metadata blob file will force the factory to load from the local file
+        factory.setMetadataBlobUrl(new UrlResource("https://localhost:9918/"));
+        factory.setCacheFile(null);  
+        factory.initialize();
+        final FidoMetadataService resolver = factory.getObject();
+        assertNotNull(resolver);
+        assert resolver != null;
+    }
+    
+    
+    //TODO add this in, TLS trust is harder to fake here.
+    //@Test
+    public void testSuccessfullMetadataLoadFromURL() throws Exception {
+        
+        final MockWebServer mockOPServer = createSimpleServer();        
+        queueMockServerResponse(mockOPServer, 200, httpMetadataBlobResource.getContentAsString(StandardCharsets.UTF_8),
+                "application/octet-stream");
+
+        mockOPServer.start(9918);
+
+        factory.setMetadataBlobUrl(new UrlResource("https://localhost:9918/"));
+        factory.initialize();
+        final FidoMetadataService resolver = factory.getObject();
+        assertNotNull(resolver);
+        assert resolver != null;
+    }
+
+}
diff --git a/webauthn-impl/src/test/resources/fido-metadata.bin b/webauthn-impl/src/test/resources/fido-metadata.bin
new file mode 100644
index 0000000..27007ee
--- /dev/null
+++ b/webauthn-impl/src/test/resources/fido-metadata.bin
@@ -0,0 +1 @@
+eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsIng1YyI6WyJNSUlITURDQ0JoaWdBd0lCQWdJTU11QlUrMkZ3dGl0N1RrUEtNQTBHQ1NxR1NJYjNEUUVCQ3dVQU1HSXhDekFKQmdOVkJBWVRBa0pGTVJrd0Z3WURWUVFLRXhCSGJHOWlZV3hUYVdkdUlHNTJMWE5oTVRnd05nWURWUVFERXk5SGJHOWlZV3hUYVdkdUlFVjRkR1Z1WkdWa0lGWmhiR2xrWVhScGIyNGdRMEVnTFNCVFNFRXlOVFlnTFNCSE16QWVGdzB5TXpBMk1EZ3lNREF3TVRGYUZ3MHlOREEzTURreU1EQXdNVEJhTUlIdE1SMHdHd1lEVlFRUERCUlFjbWwyWVhSbElFOXlaMkZ1YVhwaGRHbHZiakVRTUE0R0ExVUVCUk1ITXpRMU5ESTROREVUTUJFR0N5c0dBUVFCZ2pjOEFnRURFd0pWVXpFYk1C [...]
\ No newline at end of file
diff --git a/webauthn-impl/src/test/resources/root-r3.crt b/webauthn-impl/src/test/resources/root-r3.crt
new file mode 100644
index 0000000..232c4b6
Binary files /dev/null and b/webauthn-impl/src/test/resources/root-r3.crt differ

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


More information about the commits mailing list