[java-idp-plugin-duo] branch main updated: JDUO-82 - API to access enrollment information

Phil Smart philip.smart at jisc.ac.uk
Mon Jan 8 17:32:16 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-duo.

View the commit online:
http://git.shibboleth.net/view/?p=java-idp-plugin-duo.git;a=commit;h=4640e2057c1885d83a19f80b98de35d978432996

The following commit(s) were added to refs/heads/main by this push:
     new 4640e205 JDUO-82 - API to access enrollment information
4640e205 is described below

commit 4640e2057c1885d83a19f80b98de35d978432996
Author: Phil Smart <philip.smart at jisc.ac.uk>
AuthorDate: Fri Jan 5 14:42:20 2024 +0000

    JDUO-82 - API to access enrollment information
    
     - Add DuoAdmin API client, response objects
     - Flesh out default enrollment condition and move it to API package.
     - Add admin client tests
     - Move admin beans into global context.
     - Add Spring Webflow flow test for paswordless up to initial view
    
    https://shibboleth.atlassian.net/browse/JDUO-82
---
 .../shibboleth/idp/authn/duo/DuoIntegration.java   |   6 +-
 .../idp/plugin/authn/duo/DuoAdminClient.java       |  70 ++++
 .../plugin/authn/duo/DuoAdminResponseWrapper.java  |  57 +++
 .../authn/duo/PasswordlessEnrollmentCondition.java | 143 +++++++
 .../idp/plugin/authn/duo/model/U2ftoken.java       | 110 +++++
 .../idp/plugin/authn/duo/model/User.java           | 459 +++++++++++++++++++++
 .../plugin/authn/duo/model/WebAuthnCredential.java | 149 +++++++
 idp-duo-impl/pom.xml                               |  16 +
 .../authn/duo/impl/DefaultDuoAdminClient.java      | 337 +++++++++++++++
 .../META-INF/net.shibboleth.idp/postconfig.xml     |  25 ++
 .../flows/authn/DuoOIDC/duo-oidc-authn-beans.xml   |  36 +-
 .../impl/AbstractAuthnXmlFlowExecutionTests.java   |  69 +++-
 .../authn/duo/impl/DefaultDuoAdminClientTest.java  | 397 ++++++++++++++++++
 .../plugin/authn/duo/impl/DuoAuthnFlowTest.java    | 133 +++++-
 pom.xml                                            |  17 +-
 15 files changed, 2000 insertions(+), 24 deletions(-)

diff --git a/idp-duo-api/src/main/java/net/shibboleth/idp/authn/duo/DuoIntegration.java b/idp-duo-api/src/main/java/net/shibboleth/idp/authn/duo/DuoIntegration.java
index 2248d862..da054dcc 100644
--- a/idp-duo-api/src/main/java/net/shibboleth/idp/authn/duo/DuoIntegration.java
+++ b/idp-duo-api/src/main/java/net/shibboleth/idp/authn/duo/DuoIntegration.java
@@ -15,13 +15,17 @@
 package net.shibboleth.idp.authn.duo;
 
 import net.shibboleth.idp.authn.principal.PrincipalSupportingComponent;
+import net.shibboleth.idp.plugin.authn.duo.DuoOIDCIntegration;
 import net.shibboleth.shared.annotation.constraint.NotEmpty;
 
 import javax.annotation.Nonnull;
 import javax.annotation.Nullable;
 
 /**
- * Interface to a particular Duo integration point.
+ * Interface to a particular Duo AdminAPI or AuthAPI integration point.
+ * 
+ * <p>This was superseded for "standard" authentication by the {@link DuoOIDCIntegration}
+ * interface but remains for other use cases.</p>
  * 
  * @since 3.3.0
  */
diff --git a/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/DuoAdminClient.java b/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/DuoAdminClient.java
new file mode 100644
index 00000000..28100c7d
--- /dev/null
+++ b/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/DuoAdminClient.java
@@ -0,0 +1,70 @@
+/*
+ * 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.duo;
+
+import java.util.Map;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.opensaml.profile.context.ProfileRequestContext;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+
+import net.shibboleth.idp.authn.duo.DuoIntegration;
+import net.shibboleth.idp.plugin.authn.duo.model.User;
+import net.shibboleth.shared.annotation.constraint.NonnullElements;
+import net.shibboleth.shared.annotation.constraint.NotEmpty;
+
+/**
+ * A client that supports retrieval of information from the Duo Admin API.
+ * 
+ * <p>Clients should be thread-safe and re-usable.</p>
+ */
+public interface DuoAdminClient {
+    
+    /**
+     * Get the User that corresponds to the given username from the Duo Admin API. 
+     * 
+     * @param context context the profile request context, typically used in locating the {@link DuoIntegration} to use
+     * @param username a user name (or username alias) to look up a single user
+     * 
+     * @return the User iff found
+     * 
+     * @throws DuoException on error retrieving a response from the API
+     */
+    @Nonnull User getUser(@Nonnull final ProfileRequestContext context, @Nonnull @NotEmpty final String username) 
+            throws DuoException;
+    
+    /**
+     * Generic method for returning a {@link DuoAdminResponseWrapper} response from the given path and parameters.
+     * 
+     *  <p>Note, only GET requests should be allowed and hence only retrieval operations should be supported.</p>
+     * 
+     * @param <T> the type of response
+     * @param context the profile request context, typically used in locating the {@link DuoIntegration} to use
+     * @param path the path component of the API endpoint
+     * @param parameters any name value pairs to add to the HTTP request parameters
+     * @param wrapperTypeRef the type of response to return inside the {@link DuoAdminResponseWrapper}
+     * 
+     * @return a {@link DuoAdminResponseWrapper} with the correct response type embedded
+     * 
+     * @throws DuoException on error retrieving a response from the API
+     */
+    @Nonnull <T extends DuoAdminResponseWrapper<?>> T retrieve(@Nonnull final ProfileRequestContext context,
+            @Nonnull @NotEmpty final String path, @Nullable @NonnullElements final Map<String, String> parameters,
+            @Nonnull final TypeReference<T> wrapperTypeRef) throws DuoException;
+
+}
diff --git a/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/DuoAdminResponseWrapper.java b/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/DuoAdminResponseWrapper.java
new file mode 100644
index 00000000..35c02c7c
--- /dev/null
+++ b/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/DuoAdminResponseWrapper.java
@@ -0,0 +1,57 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.shibboleth.idp.plugin.authn.duo;
+
+import javax.annotation.Nonnull;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+/**
+ * Handle a generic object returned from the response that will come from the Duo 
+ * AdminAPI.
+ *
+ * @param <T> the class being wrapped
+ */
+public class DuoAdminResponseWrapper<T> {
+    
+    /** the inner response. */
+    @JsonProperty("response")
+    private T response;
+    
+    /** the response status. */
+    @JsonProperty("stat")
+    private String stat;
+    
+    /**
+     * Get the inner response.
+     * 
+     * @return inner response
+     */
+    @Nonnull public T getResponse() {
+        assert response != null;
+        return response;
+    }
+
+    /**
+     * Get the response status.
+     * 
+     * @return response status
+     */
+    @Nonnull public String getStat() {
+        assert stat != null;
+        return stat;
+    }
+
+}
diff --git a/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/PasswordlessEnrollmentCondition.java b/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/PasswordlessEnrollmentCondition.java
new file mode 100644
index 00000000..16f77b25
--- /dev/null
+++ b/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/PasswordlessEnrollmentCondition.java
@@ -0,0 +1,143 @@
+/*
+ * 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.duo;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.Set;
+import java.util.function.BiPredicate;
+import java.util.stream.Collectors;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.slf4j.Logger;
+
+import net.shibboleth.idp.plugin.authn.duo.model.User;
+import net.shibboleth.idp.plugin.authn.duo.model.WebAuthnCredential;
+import net.shibboleth.shared.annotation.constraint.NonnullAfterInit;
+import net.shibboleth.shared.annotation.constraint.NonnullElements;
+import net.shibboleth.shared.collection.CollectionSupport;
+import net.shibboleth.shared.component.AbstractInitializableComponent;
+import net.shibboleth.shared.component.ComponentInitializationException;
+import net.shibboleth.shared.primitive.LoggerFactory;
+import net.shibboleth.shared.primitive.StringSupport;
+
+/**
+ * A BiPredicate which checks the enrollment status of a user against the Duo Admin APIs.
+ * 
+ * <p>The second parameter is the username to evaluate.</p>
+ */
+
+public class PasswordlessEnrollmentCondition extends AbstractInitializableComponent
+        implements BiPredicate<ProfileRequestContext,String> {
+    
+    /** Class logger. */
+    @Nonnull private final Logger log = LoggerFactory.getLogger(PasswordlessEnrollmentCondition.class);
+    
+    /** The admin client used to access the DuoAdmin API.*/
+    @NonnullAfterInit private DuoAdminClient adminClient;
+
+    /** Allowed WebAuthnCredential labels that qualify. */
+    @Nonnull @NonnullElements private Set<String> allowedLabels;
+    
+    /** Constructor. */
+    public PasswordlessEnrollmentCondition() {
+        allowedLabels = CollectionSupport.emptySet();
+    }
+    
+    /**
+     * Sets the {@link DuoAdminClient} to use.
+     * 
+     * @param client admin client
+     */
+    public void setDuoAdminClient(@Nonnull final DuoAdminClient client) {
+        checkSetterPreconditions();
+        adminClient = client;
+    }
+    
+    /**
+     * Sets the allowable {@link WebAuthnCredential} labels that qualify for the condition.
+     * 
+     * @param labels credential labels
+     */
+    public void setAllowedLabels(@Nonnull @NonnullElements final Collection<String> labels) {
+        checkSetterPreconditions();
+        allowedLabels = CollectionSupport.copyToSet(StringSupport.normalizeStringCollection(labels));
+    }
+    
+    /** {@inheritDoc} */
+    @Override
+    public void doInitialize() throws ComponentInitializationException {
+        super.doInitialize();
+        
+        if (adminClient == null) {
+            throw new ComponentInitializationException("DuoAdminClient cannot be null");
+        }
+    }
+    
+    /** {@inheritDoc} */
+    public boolean test(@Nullable final ProfileRequestContext profileRequestContext, @Nullable final String username) {
+        checkComponentActive();
+        
+        if (profileRequestContext == null || username == null) {
+            log.trace("Unable to checl passwordless enrollment status for '{}'", username);
+            return false;
+        }
+        
+        log.trace("Checking passwordless enrollment status for '{}'", username); 
+        try {
+            final User response = adminClient.getUser(profileRequestContext, username);
+            final Boolean enrolled = response.isEnrolled();
+            
+            if (enrolled == null || !enrolled) {
+                log.info("User '{}' not enrolled", response.getUsername());
+                return false;
+            }
+            
+            final List<WebAuthnCredential> creds = response.getWebAuthnCredentials();
+            if (creds.isEmpty()) {
+                log.info("User '{}' has no WebAuthn credentials", response.getUsername());
+                return false;
+            }
+            
+            if (allowedLabels.isEmpty()) {
+                log.debug("User '{}' is enrolled with {} WebAuthn credential(s)", response.getUsername(),
+                        response.getWebAuthnCredentials().size());
+                return true;
+            }
+            
+            final Set<String> enrolledLabels = creds.stream()
+                    .map(WebAuthnCredential::getLabel)
+                    .collect(CollectionSupport.nonnullCollector(Collectors.toUnmodifiableSet())).get();
+            for (final String label : allowedLabels) {
+                if (enrolledLabels.contains(label)) {
+                    log.debug("User '{}' has a qualifying WebAuthn credential: '{}'", response.getUsername(), label);
+                    return true;
+                }
+            }
+            
+            log.info("User '{}' has no acceptable WebAuthn credential, enrolled credentials: {}",
+                    response.getUsername(), enrolledLabels);
+            return false;
+            
+        } catch (final DuoException e) {
+            log.warn("Duo AdminAPI request failed, denying passwordless for '{}'", username, e);
+            return false;
+        }
+    }
+
+}
\ No newline at end of file
diff --git a/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/model/U2ftoken.java b/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/model/U2ftoken.java
new file mode 100644
index 00000000..35c479a1
--- /dev/null
+++ b/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/model/U2ftoken.java
@@ -0,0 +1,110 @@
+/*
+ * 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.duo.model;
+
+import javax.annotation.Nullable;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
+import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder;
+
+/**
+ * A U2F token. Deprecated by Duo in February 2022.
+ */
+ at JsonInclude(JsonInclude.Include.NON_NULL)
+ at JsonDeserialize(builder = U2ftoken.Builder.class)
+public class U2ftoken {
+
+    /** The date the U2F token was registered in Duo.*/
+    @JsonProperty("date_added")
+    @Nullable private final Integer dateAdded;
+    
+    /** The U2F token's registration identifier. Use with GET token by ID.*/
+    @JsonProperty("registration_id")
+    @Nullable private final String registrationId;
+
+    /**
+     * 
+     * Constructor.
+     *
+     * @param builder the builder
+     */
+    private U2ftoken(final Builder builder) {
+        this.dateAdded = builder.dateAdded;
+        this.registrationId = builder.registrationId;
+    }
+
+    /**
+     * The date the U2F token was registered in Duo.
+     * 
+     * @return the date the token was registered
+     */
+    @JsonProperty("date_added")
+    @Nullable public Integer getDateAdded() {
+        return dateAdded;
+    }
+
+    /**
+     * Get the U2F token's registration identifier. Use with GET token by ID.
+     * 
+     * @return the registration identifier of the token
+     */
+    @JsonProperty("registration_id")
+    @Nullable public String getRegistrationId() {
+        return registrationId;
+    }
+    
+    /**
+     * Get the builder for this class.
+     * 
+     * @return the builder
+     */
+    public static Builder builder() {
+        return new Builder();
+    }
+    
+    /** Builder used to build an instance of this class.*/
+    @JsonPOJOBuilder(buildMethodName = "build", withPrefix = "with")
+    public static final class Builder {
+        private Integer dateAdded;
+        private String registrationId;
+
+        /** Constructor.*/
+        private Builder() {
+        }
+
+        @JsonProperty("date_added")
+        public Builder withDateAdded(final Integer dateAdded) {
+            this.dateAdded = dateAdded;
+            return this;
+        }
+
+        @JsonProperty("registration_id")
+        public Builder withRegistrationId(final String registrationId) {
+            this.registrationId = registrationId;
+            return this;
+        }
+
+        public U2ftoken build() {
+            return new U2ftoken(this);
+        }
+    }
+
+
+    
+    
+
+}
diff --git a/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/model/User.java b/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/model/User.java
new file mode 100644
index 00000000..e95122b0
--- /dev/null
+++ b/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/model/User.java
@@ -0,0 +1,459 @@
+/*
+ * 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.duo.model;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import com.fasterxml.jackson.annotation.JsonAnySetter;
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
+import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder;
+
+import net.shibboleth.shared.annotation.constraint.NotLive;
+import net.shibboleth.shared.annotation.constraint.Unmodifiable;
+import net.shibboleth.shared.collection.CollectionSupport;
+
+/**
+ * A model object to hold the response from the Users Duo Admin API. 
+ * 
+ * <p>Those properties not explicitly listed will be included in the additionalProperties map for retrieval 
+ * if required.</p>
+ */
+ at JsonInclude(JsonInclude.Include.NON_NULL)
+ at JsonIgnoreProperties(ignoreUnknown = true) 
+ at JsonDeserialize(builder = User.Builder.class)
+public final class User {
+
+    /** Generated serialUID. */
+    public static final long serialVersionUID = 8122423604710803224L;
+    
+    /** The user's creation date as a UNIX timestamp .*/
+    @JsonProperty("created")
+    @Nullable private final Integer created;
+    
+    /** The user's email address.*/
+    @JsonProperty("email")
+    @Nullable private final String email;
+    
+    /** The user's given name.*/
+    @JsonProperty("firstname")
+    @Nullable private final String firstname;
+    
+    /** 
+     * Is true if the user has a phone, hardware token, U2F token, WebAuthn security key, or other 
+     * WebAuthn method available for authentication. Otherwise, false.
+     */
+    @JsonProperty("is_enrolled")
+    @Nullable private final Boolean enrolled;
+
+    /** 
+     * An integer indicating the last update to the user via directory sync as a Unix timestamp, or null if the user 
+     * has never synced with an external directory or if the directory that originally created the user has been
+     * deleted from Duo.
+     */
+    @JsonProperty("last_directory_sync")
+    @Nullable private final Object lastDirectorySync;
+
+    /** 
+     * An integer indicating the last time this user logged in, as a Unix timestamp, or null if the user has 
+     * not logged in.
+     */
+    @JsonProperty("last_login")
+    @Nullable private final Integer lastLogin;
+
+    /** The user's surname.*/
+    @JsonProperty("lastname")
+    @Nullable private final String lastname;
+
+    /** The user's lockout_reason.*/
+    @JsonProperty("lockout_reason")
+    @Nullable private final String lockoutReason;
+
+    /** Notes about this user. Viewable in the Duo Admin Panel .*/
+    @JsonProperty("notes")
+    @Nullable private final String notes;
+
+    /** The user's real name (or full name).*/
+    @JsonProperty("realname")
+    @Nullable private final String realname;
+
+    /** The user's status.*/
+    @JsonProperty("status")
+    @Nullable private final String status;   
+
+    /** A list of U2F tokens that this user can use.*/
+    @JsonProperty("u2ftokens")
+    @Nonnull @NotLive @Unmodifiable private final List<U2ftoken> u2ftokens;
+
+    /** The user's ID.*/
+    @JsonProperty("user_id")
+    @Nullable private final String userId;
+
+    /** The user's username.*/
+    @JsonProperty("username")
+    @Nullable private final String username;
+
+    /** The list of WebAuthn authenticators that this user can use.*/
+    @JsonProperty("webauthncredentials")
+    @Nonnull @NotLive @Unmodifiable private final List<WebAuthnCredential> webAuthnCredentials;
+
+    /** Any additional properties not explicitly captured by this class are added to this map.*/
+    @JsonIgnore
+    @Nonnull @NotLive @Unmodifiable private final Map<String, Object> additionalProperties;
+
+    /**
+     * 
+     * Constructor.
+     *
+     * @param builder the builder
+     */
+    private User(final Builder builder) {
+        this.created = builder.created;
+        this.email = builder.email;
+        this.firstname = builder.firstname;
+        this.enrolled = builder.enrolled;
+        this.lastDirectorySync = builder.lastDirectorySync;
+        this.lastLogin = builder.lastLogin;
+        this.lastname = builder.lastname;
+        this.lockoutReason = builder.lockoutReason;
+        this.notes = builder.notes;
+        this.realname = builder.realname;
+        this.status = builder.status;
+        this.u2ftokens = builder.u2ftokens;
+        this.userId = builder.userId;
+        this.username = builder.username;
+        this.webAuthnCredentials = builder.webAuthnCredentials;
+        this.additionalProperties = CollectionSupport.copyToMap(builder.additionalProperties);
+    }
+
+    /**
+     * Get the user's creation date as a UNIX timestamp.
+     * 
+     * @return creation date
+     */
+    @JsonProperty("created")
+    @Nullable public Integer getCreated() {
+        return created;
+    }
+
+    /**
+     * Get the user's email address.
+     * 
+     * @return email address
+     */
+    @JsonProperty("email")
+    @Nullable public String getEmail() {
+        return email;
+    }
+
+    /**
+     * Get The user's given name.
+     * 
+     * @return given name
+     */
+    @JsonProperty("firstname")
+    @Nullable public Object getFirstname() {
+        return firstname;
+    }
+
+    /**
+     * Is true if the user has a phone, hardware token, U2F token, WebAuthn security key, or other WebAuthn 
+     * method available for authentication. Otherwise, false.
+     * 
+     * @return if the user has enrolled
+     */
+    @JsonProperty("is_enrolled")
+    @Nullable public Boolean isEnrolled() {
+        return enrolled;
+    }
+
+    /**
+     * Get an integer indicating the last update to the user via directory sync as a Unix timestamp, or null if 
+     * the user has never synced with an external directory or if the directory that originally created the 
+     * user has been deleted from Duo.
+     * 
+     * @return last directory sync.
+     */
+    @JsonProperty("last_directory_sync")
+    @Nullable public Object getLastDirectorySync() {
+        return lastDirectorySync;
+    }
+
+    /**
+     * Get an integer indicating the last time this user logged in, as a Unix timestamp, or null if the user 
+     * has not logged in.
+     * 
+     * @return last login time
+     */
+    @JsonProperty("last_login")
+    @Nullable public Integer getLastLogin() {
+        return lastLogin;
+    }    
+
+    /**
+     * Get the user's surname.
+     * 
+     * @return the surname
+     */
+    @JsonProperty("lastname")
+    @Nullable public Object getLastname() {
+        return lastname;
+    }    
+
+    /**
+     * Get the user's lockout_reason.
+     *  
+     * @return the reason the user was locked out.
+     */
+    @JsonProperty("lockout_reason")
+    @Nullable public Object getLockoutReason() {
+        return lockoutReason;
+    }
+   
+    /**
+     * Get notes about this user. Viewable in the Duo Admin Panel.
+     * 
+     * @return the notes
+     */
+    @JsonProperty("notes")
+    @Nullable public String getNotes() {
+        return notes;
+    }   
+
+    /**
+     * Get the user's real name (or full name).
+     * 
+     * @return the user's real name
+     */
+    @JsonProperty("realname")
+    @Nullable public String getRealname() {
+        return realname;
+    }   
+
+    /**
+     * Get the user's status.
+     * 
+     * @return the status
+     */
+    @JsonProperty("status")
+    @Nullable public String getStatus() {
+        return status;
+    }    
+    
+    /**
+     * Get a list of U2F tokens that this user can use.
+     *  
+     * @return the list of U2F tokens
+     */
+    @JsonProperty("u2ftokens")
+    @Nonnull @NotLive @Unmodifiable public List<U2ftoken> getU2ftokens() {
+        return u2ftokens;
+    }   
+
+    /**
+     * Get the user's ID.
+     * 
+     * @return the users id.
+     */
+    @JsonProperty("user_id")
+    @Nullable public String getUserId() {
+        return userId;
+    }    
+
+    /**
+     * Get the user's username.
+     * 
+     * @return the user's username
+     */
+    @JsonProperty("username")
+    @Nullable public String getUsername() {
+        return username;
+    }   
+
+    /**
+     * Get a list of WebAuthn authenticators that this user can use.
+     * 
+     * @return the list of WebAuthn credentials to the user can use
+     */
+    @JsonProperty("webauthncredentials")
+    @Nonnull @NotLive @Unmodifiable public List<WebAuthnCredential> getWebAuthnCredentials() {
+        return webAuthnCredentials;
+    }
+    
+    /**
+     * Get any additional properties not explicitly captured by this class.
+     * 
+     * @return the map of any additional properties not directly captured by this class.
+     */
+    @JsonIgnore
+    @Nonnull @NotLive @Unmodifiable public Map<String, Object> getAdditionalProperties() {
+        return additionalProperties;
+    }
+
+    /**
+     * Get the builder for this class.
+     * 
+     * @return the builder
+     */
+    public static Builder builder() {
+        return new Builder();
+    }
+
+    /** The builder used to create a threadsafe instance of this class.*/
+    @JsonPOJOBuilder(buildMethodName = "build", withPrefix = "with")
+    @JsonIgnoreProperties(ignoreUnknown = true) 
+    public static final class Builder {
+        
+        @Nullable private Integer created;
+        @Nullable private String email;
+        @Nullable private String firstname;
+        @Nullable private Boolean enrolled;
+        @Nullable private Object lastDirectorySync;
+        @Nullable private Integer lastLogin;
+        @Nullable private String lastname;
+        @Nullable private String lockoutReason;
+        @Nullable private String notes;
+        @Nullable private String realname;
+        @Nullable private String status;
+        @Nonnull private List<U2ftoken> u2ftokens;
+        @Nullable private String userId;
+        @Nullable private String username;
+        @Nonnull private List<WebAuthnCredential> webAuthnCredentials;
+        @Nonnull private final Map<String, Object> additionalProperties;
+
+        /** Constructor.*/
+        private Builder() {
+            webAuthnCredentials = CollectionSupport.emptyList();
+            additionalProperties = new HashMap<>();
+            u2ftokens = CollectionSupport.emptyList();
+        }
+
+        @JsonProperty("created")
+        public Builder withCreated(@Nullable final Integer createdAt) {
+            created = createdAt;
+            return this;
+        }
+
+        @JsonProperty("email")
+        public Builder withEmail(@Nullable final String emailIn) {
+            email = emailIn;
+            return this;
+        }
+
+        @JsonProperty("firstname")
+        public Builder withFirstname(@Nullable final String firstnameIn) {
+            firstname = firstnameIn;
+            return this;
+        }
+
+        @JsonProperty("is_enrolled")
+        public Builder withIsEnrolled(@Nullable final Boolean isEnrolled) {
+            enrolled = isEnrolled;
+            return this;
+        }
+
+        @JsonProperty("last_directory_sync")
+        public Builder withLastDirectorySync(@Nullable final Object dirSync) {
+            lastDirectorySync = dirSync;
+            return this;
+        }
+
+        @JsonProperty("last_login")
+        public Builder withLastLogin(@Nullable final Integer lastLoginAt) {
+            lastLogin = lastLoginAt;
+            return this;
+        }
+
+        @JsonProperty("lastname")
+        public Builder withLastname(@Nullable final String lastnameIn) {
+            lastname = lastnameIn;
+            return this;
+        }
+
+        @JsonProperty("lockout_reason")
+        public Builder withLockoutReason(@Nullable final String lockoutReasonIn) {
+            lockoutReason = lockoutReasonIn;
+            return this;
+        }
+
+        @JsonProperty("notes")
+        public Builder withNotes(@Nullable final String notesIn) {
+            notes = notesIn;
+            return this;
+        }
+
+        @JsonProperty("realname")
+        public Builder withRealname(@Nullable final String realnameIn) {
+            realname = realnameIn;
+            return this;
+        }
+
+        @JsonProperty("status")
+        public Builder withStatus(@Nullable final String statusIn) {
+            status = statusIn;
+            return this;
+        }
+
+        @JsonProperty("u2ftokens")
+        public Builder withU2ftokens(@Nullable final List<U2ftoken> tokens) {
+            if (tokens != null) {
+                u2ftokens = CollectionSupport.copyToList(tokens);
+            }
+            return this;
+        }
+
+        @JsonProperty("user_id")
+        public Builder withUserId(@Nullable final String id) {
+            userId = id;
+            return this;
+        }
+
+        @JsonProperty("username")
+        public Builder withUsername(@Nullable final String uname) {
+            username = uname;
+            return this;
+        }
+
+        @JsonProperty("webauthncredentials")
+        public Builder withWebAuthnCredentials(@Nullable final List<WebAuthnCredential> credentials) {
+            if (credentials != null) {
+                webAuthnCredentials = CollectionSupport.copyToList(credentials);
+            }
+            return this;
+        }
+        
+        @JsonAnySetter
+        public Builder withAdditionalProperty(@Nonnull final String name, @Nullable final Object value) {
+            // Only add properties with non-null values.
+            if (value != null) {
+                additionalProperties.put(name, value);
+            }
+            return this;
+        }
+
+        public User build() {
+            return new User(this);
+        }
+    }
+
+}
diff --git a/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/model/WebAuthnCredential.java b/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/model/WebAuthnCredential.java
new file mode 100644
index 00000000..64f08a83
--- /dev/null
+++ b/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/model/WebAuthnCredential.java
@@ -0,0 +1,149 @@
+/*
+ * 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.duo.model;
+
+import javax.annotation.Nullable;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
+import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder;
+
+/**
+ * A WebAuthn credential registered to a User.
+ */
+ at JsonInclude(JsonInclude.Include.NON_NULL)
+ at JsonDeserialize(builder = WebAuthnCredential.Builder.class)
+public final class WebAuthnCredential {
+
+    /** Free-form label for the WebAuthn credential.*/
+    @JsonProperty("credential_name")
+    @Nullable private final String credentialName;
+    
+    /** The date the WebAuthn credential was registered in Duo.*/
+    @JsonProperty("date_added")
+    @Nullable private final Integer dateAdded;
+    
+    /** Indicates the type of WebAuthn credential.*/
+    @JsonProperty("label")
+    @Nullable private final String label;
+    
+    /** The WebAuthn credential's registration identifier.*/
+    @JsonProperty("webauthnkey")
+    @Nullable private final String webauthnkey;
+
+    /**
+     * 
+     * Constructor.
+     *
+     * @param builder the builder
+     */
+    private WebAuthnCredential(final Builder builder) {
+        this.credentialName = builder.credentialName;
+        this.dateAdded = builder.dateAdded;
+        this.label = builder.label;
+        this.webauthnkey = builder.webauthnkey;
+    }
+
+    /**
+     * Get the free-form label for the WebAuthn credential.
+     * 
+     * @return the credential name
+     */
+    @JsonProperty("credential_name")
+    @Nullable public String getCredentialName() {
+        return credentialName;
+    }
+
+    /**
+     * Get the date the WebAuthn credential was registered in Duo.
+     * 
+     * @return the date the credential was added
+     */
+    @JsonProperty("date_added")
+    @Nullable public Integer getDateAdded() {
+        return dateAdded;
+    }   
+
+    /**
+     * Get the type of WebAuthn credential.
+     * 
+     * @return the credential label
+     */
+    @JsonProperty("label")
+    @Nullable public String getLabel() {
+        return label;
+    }
+
+    /**
+     * Get the WebAuthn credential's registration identifier.
+     * 
+     * @return the credentials registration identifier
+     */
+    @JsonProperty("webauthnkey")
+    public String getWebauthnkey() {
+        return webauthnkey;
+    }
+
+    /**
+     * Get the builder for this class.
+     * 
+     * @return the builder for this class
+     */
+    public static Builder builder() {
+        return new Builder();
+    }
+
+    /** Builder for this class.*/
+    @JsonPOJOBuilder(buildMethodName = "build", withPrefix = "with")
+    public static final class Builder {
+        private String credentialName;
+        private Integer dateAdded;
+        private String label;
+        private String webauthnkey;
+
+        /** Constructor.*/
+        private Builder() {
+        }
+
+        @JsonProperty("credential_name")
+        public Builder withCredentialName(@Nullable final String name) {
+            credentialName = name;
+            return this;
+        }
+
+        @JsonProperty("date_added")
+        public Builder withDateAdded(@Nullable final Integer date) {
+            dateAdded = date;
+            return this;
+        }
+
+        @JsonProperty("label")
+        public Builder withLabel(@Nullable final String labelIn) {
+            label = labelIn;
+            return this;
+        }
+
+        @JsonProperty("webauthnkey")
+        public Builder withWebauthnkey(@Nullable final String key) {
+            webauthnkey = key;
+            return this;
+        }
+
+        public WebAuthnCredential build() {
+            return new WebAuthnCredential(this);
+        }
+    }
+}
diff --git a/idp-duo-impl/pom.xml b/idp-duo-impl/pom.xml
index f22bed15..95285957 100644
--- a/idp-duo-impl/pom.xml
+++ b/idp-duo-impl/pom.xml
@@ -221,6 +221,22 @@
            <artifactId>shib-spring</artifactId>
            <scope>test</scope>
         </dependency>
+        <dependency>
+            <groupId>com.squareup.okhttp3</groupId>
+            <artifactId>mockwebserver</artifactId>
+            <scope>test</scope>
+            <exclusions>
+                <exclusion>
+                    <groupId>junit</groupId>
+                    <artifactId>junit</artifactId>
+                </exclusion>
+            </exclusions>
+        </dependency>
+        <dependency>
+            <groupId>com.squareup.okhttp3</groupId>
+            <artifactId>okhttp-tls</artifactId>
+            <scope>test</scope>
+        </dependency>
         <!-- Spring webflow tests require Junit4, runs in TestNG bridge -->
         <dependency>
             <groupId>junit</groupId>
diff --git a/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/impl/DefaultDuoAdminClient.java b/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/impl/DefaultDuoAdminClient.java
new file mode 100644
index 00000000..45663fcb
--- /dev/null
+++ b/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/impl/DefaultDuoAdminClient.java
@@ -0,0 +1,337 @@
+/*
+ * 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.duo.impl;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.security.InvalidKeyException;
+import java.security.NoSuchAlgorithmException;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Function;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.apache.hc.client5.http.classic.HttpClient;
+import org.apache.hc.client5.http.protocol.HttpClientContext;
+import org.apache.hc.core5.http.ClassicHttpRequest;
+import org.apache.hc.core5.http.ClassicHttpResponse;
+import org.apache.hc.core5.http.HttpEntity;
+import org.apache.hc.core5.http.HttpStatus;
+import org.apache.hc.core5.http.io.support.ClassicRequestBuilder;
+import org.apache.hc.core5.net.URIBuilder;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.opensaml.security.httpclient.HttpClientSecurityParameters;
+import org.opensaml.security.httpclient.HttpClientSecuritySupport;
+import org.slf4j.Logger;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import net.shibboleth.idp.authn.duo.DuoAuthAPI;
+import net.shibboleth.idp.authn.duo.DuoIntegration;
+import net.shibboleth.idp.plugin.authn.duo.DuoAdminClient;
+import net.shibboleth.idp.plugin.authn.duo.DuoAdminResponseWrapper;
+import net.shibboleth.idp.plugin.authn.duo.DuoException;
+import net.shibboleth.idp.plugin.authn.duo.model.User;
+import net.shibboleth.shared.annotation.constraint.NonnullAfterInit;
+import net.shibboleth.shared.annotation.constraint.NonnullElements;
+import net.shibboleth.shared.annotation.constraint.NotEmpty;
+import net.shibboleth.shared.annotation.constraint.ThreadSafeAfterInit;
+import net.shibboleth.shared.codec.EncodingException;
+import net.shibboleth.shared.component.AbstractIdentifiableInitializableComponent;
+import net.shibboleth.shared.component.ComponentInitializationException;
+import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.logic.FunctionSupport;
+import net.shibboleth.shared.primitive.LoggerFactory;
+
+/**
+ * The default implementation of the {@link DuoAdminClient} for looking up information from the DuoAdmin API.
+ * 
+ * <p>This class is thread-safe and can be re-used once published.</p>
+ */
+ at ThreadSafeAfterInit
+public class DefaultDuoAdminClient extends AbstractIdentifiableInitializableComponent implements DuoAdminClient {    
+    
+    /** Class logger. */
+    @Nonnull private final Logger log = LoggerFactory.getLogger(DefaultDuoAdminClient.class);
+    
+    /** HttpClient for contacting Duo. */
+    @NonnullAfterInit private HttpClient httpClient;
+
+    /** HTTP client security parameters. */
+    @Nullable private HttpClientSecurityParameters httpClientSecurityParameters;
+    
+    /** JSON object mapper. */
+    @NonnullAfterInit private ObjectMapper objectMapper;
+    
+    /** Lookup strategy for Duo integration for Admin API use. */
+    @Nonnull private Function<ProfileRequestContext, DuoIntegration> adminDuoIntegrationLookupStrategy;
+    
+    /** The location of the users admin endpoint.*/
+    @Nonnull @NotEmpty private String usersAdminEndpoint;
+    
+    /** Constructor.*/
+    public DefaultDuoAdminClient() {
+        adminDuoIntegrationLookupStrategy =  FunctionSupport.constant(null);
+        usersAdminEndpoint = "/admin/v1/users";
+    }
+    
+    /**
+     * Set the {@link HttpClient} to use for contacting Duo.
+     * 
+     * @param client HttpClient
+     */
+    public void setHttpClient(@Nonnull final HttpClient client) {
+        checkSetterPreconditions();
+        httpClient = Constraint.isNotNull(client, "HTTP client cannot be null");
+    }
+
+    /**
+     * Set the optional client security parameters.
+     * 
+     * @param params the new client security parameters
+     */
+    public void setHttpClientSecurityParameters(@Nullable final HttpClientSecurityParameters params) {
+        checkSetterPreconditions();
+        httpClientSecurityParameters = params;
+    }
+    
+    /**
+     * Set the JSON {@link ObjectMapper}.
+     * 
+     * @param mapper object mapper
+     */
+    public void setObjectMapper(@Nonnull final ObjectMapper mapper) {
+        checkSetterPreconditions();
+        objectMapper = Constraint.isNotNull(mapper, "Object mapper cannot be null");
+    }
+    
+    /**
+     * Set the lookup strategy to use to locate the Duo Admin API integration. 
+     * 
+     * @param strategy The lookup strategy to set.
+     */
+    public void setAdminDuoIntegrationLookupStrategy(@Nonnull 
+            final Function<ProfileRequestContext, DuoIntegration> strategy) {
+        checkSetterPreconditions();
+        adminDuoIntegrationLookupStrategy = Constraint.isNotNull(strategy,
+                "AdminDuoIntegrationLookup strategy can not be null");
+    }
+    
+    /**
+     * Set the /users admin endpoint.
+     * 
+     * @param endpoint The users admin endpoint to set.
+     */
+    public void setUsersAdminEndpoint(@Nonnull @NotEmpty final String endpoint) {
+        checkSetterPreconditions();
+        usersAdminEndpoint = Constraint.isNotEmpty(endpoint, "UserAdminEndpoint can not be null");
+    }
+
+    /** {@inheritDoc} */
+    @Override protected void doInitialize() throws ComponentInitializationException {
+        super.doInitialize();
+        
+        
+        if (httpClient == null) {
+            throw new ComponentInitializationException("HttpClient cannot be null");
+        }
+
+        if (objectMapper == null) {
+            throw new ComponentInitializationException("ObjectMapper cannot be null");
+        }
+    }
+
+    /** {@inheritDoc} */
+    @Nonnull public User getUser(@Nonnull final ProfileRequestContext context, @Nonnull final String username)
+            throws DuoException {
+        try {
+            // prepare the request
+            final ClassicHttpRequest request = 
+                    buildRequest(context, usersAdminEndpoint, Map.of(DuoAuthAPI.DUO_USERNAME, username));
+            
+            // execute the request
+            final List<User> response = 
+                    doAPIRequest(request, new TypeReference<DuoAdminResponseWrapper<List<User>>>() {}).getResponse();
+            if (response.size() != 1) {
+                throw new DuoException("User API response did not contain a single user");
+            }
+            final User user = response.get(0);
+            if (user == null) {
+                throw new DuoException("User API response did not contain a user"); 
+            }
+            return user;
+
+        } catch (final Exception ex) {
+            throw new DuoException("Unable to to get User '"+username+"' from Duo's Admin API", ex);
+        }
+    }
+    
+    /** {@inheritDoc} */
+    @Nonnull public <T extends DuoAdminResponseWrapper<?>> T retrieve(@Nonnull final ProfileRequestContext context,
+            @Nonnull @NotEmpty final String path, @Nullable @NonnullElements final Map<String, String> parameters,
+            @Nonnull final TypeReference<T> wrapperTypeRef) 
+                    throws DuoException {
+        try {
+            final ClassicHttpRequest request = buildRequest(context, path, parameters);
+            return doAPIRequest(request, wrapperTypeRef);
+        } catch (final DuoException | IOException e) {
+            //Wrap the exception
+            throw new DuoException(e);
+        }
+    }
+    
+    
+    /**
+     * Check the component is active and if it is get the {@link DuoIntegration} to use.
+     * 
+     * @param prc the profile request context
+     * 
+     * @return the {@link DuoIntegration} to use
+     * 
+     * @throws DuoException
+     */
+    @Nonnull private DuoIntegration getIntegrationAndCheckComponentActive(
+            @Nonnull final ProfileRequestContext prc) throws DuoException {
+        
+        checkComponentActive();   
+        final DuoIntegration integration = adminDuoIntegrationLookupStrategy.apply(prc);
+        if (integration == null) {
+            throw new DuoException("Unable to locate Duo Integration.");
+        }
+        return integration;
+    }
+    
+    /**
+     * Build the HTTP request from the given path and parameters. 
+     * 
+     * <p>The {@link DuoIntegration} is located dynamically. The request is signed using the integration key and secret 
+     * key from the located integration. Similarly the integration's API host is used as the host component of the URI.
+     * </p>
+     * 
+     * @param context the profile request context use to locate the {@link DuoIntegration} to use
+     * @param path the path component of the API endpoint
+     * @param parameters a map of name value pairs to add to the HTTP request parameters
+     * 
+     * @return the built and signed HTTP request
+     * 
+     * @throws DuoException on error creating the request
+     * 
+     */
+    @Nonnull private ClassicHttpRequest buildRequest(@Nonnull final ProfileRequestContext context,
+            @Nonnull @NotEmpty final String path, @Nullable @NonnullElements final Map<String, String> parameters) 
+                    throws DuoException {
+        
+        final DuoIntegration integration = getIntegrationAndCheckComponentActive(context);
+        
+        try {
+            final URI uri = new URIBuilder().setScheme("https").setHost(integration.getAPIHost())
+                    .setPath(path).build();
+            final ClassicRequestBuilder rb =
+                    ClassicRequestBuilder.get().setUri(uri);
+            if (parameters != null) {
+                parameters.forEach(rb::addParameter);
+            }
+            
+            assert rb != null;            
+            DuoSupport.signRequest(rb, integration);
+            final ClassicHttpRequest request = rb.build();
+            assert request != null;            
+            return request;
+        } catch (final URISyntaxException | InvalidKeyException | NoSuchAlgorithmException | EncodingException ex) {
+            throw new DuoException("Unable to to get response from Duo Admin API", ex);
+        }
+        
+    }
+    
+// Checkstyle: CyclomaticComplexity OFF
+    /**
+     * Performs a call to the Duo AdminAPI. Upon a successful call, the JSON response is mapped into the appropriate 
+     * type of {@link YYY}.
+     * 
+     * @param request the prepared HTTP request
+     * @param wrapperTypeRef the type of {@link DuoResponseWrapper} to use
+     * @param <T> the DuoResponse type being wrapped
+     * 
+     * @return a {@link DuoResponseWrapper}
+     * 
+     * @throws IOException on an I/O error
+     * @throws DuoException on a Duo-related error
+     */
+    @Nonnull private <T extends DuoAdminResponseWrapper<?>> T doAPIRequest(@Nonnull final ClassicHttpRequest request,
+            @Nonnull final TypeReference<T> wrapperTypeRef)
+                    throws DuoException, IOException {
+
+        // Make the request.
+        final HttpClientContext clientContext = HttpClientContext.create();
+        assert clientContext != null;
+        HttpClientSecuritySupport.marshalSecurityParameters(clientContext, httpClientSecurityParameters, true);
+        HttpClientSecuritySupport.addDefaultTLSTrustEngineCriteria(clientContext, request);
+        try (final ClassicHttpResponse httpResponse = httpClient.executeOpen(null, request, clientContext)) {
+            final String scheme = request.getScheme();
+            assert scheme != null;
+            HttpClientSecuritySupport.checkTLSCredentialEvaluated(clientContext, scheme);
+    
+            // Check the HTTP response code.
+            final int httpStatusCode = httpResponse.getCode();
+            if (httpStatusCode == HttpStatus.SC_BAD_REQUEST) {
+                try (final HttpEntity entity = httpResponse.getEntity()) {
+                    if (entity == null) {
+                        throw new IOException("Bad request status code (" + httpStatusCode + ") returned from Duo: "
+                                + (httpResponse.getReasonPhrase() != null ? httpResponse.getReasonPhrase() : "none"));
+                    }
+                    try (final InputStream httpContent = entity.getContent()) {
+                        final DuoFailureResponse msg = objectMapper.readValue(httpContent, DuoFailureResponse.class);
+                        final StringBuilder builder = new StringBuilder();
+                        builder.append(msg.getMessage() != null ? msg.getMessage() : "no message")
+                            .append(" (")
+                            .append(msg.getMessageDetail() != null ? msg.getMessageDetail() : "no detail")
+                            .append(")");
+                        throw new DuoException(builder.toString());
+                    }
+                }
+            }
+            if (httpStatusCode != HttpStatus.SC_OK) {
+                throw new IOException("Non-ok status code (" + httpStatusCode + ") returned from Duo: "
+                        + (httpResponse.getReasonPhrase() != null ? httpResponse.getReasonPhrase() : "none"));
+            }
+            
+            try (final HttpEntity entity = httpResponse.getEntity()) {
+                if (entity == null) {
+                    throw new IOException("No response body returned from Duo");
+                }
+        
+                // Parse the JSON response.
+                try (final InputStream content = entity.getContent()) {
+                    final T duoResponse = objectMapper.readValue(content, wrapperTypeRef);
+
+                    if (duoResponse == null) {
+                        throw new DuoException("Unable to parse JSON response");
+                    } else if (!"OK".equals(duoResponse.getStat())) {
+                        throw new DuoException("Unexpected status value in JSON response: " + duoResponse.getStat());
+                    }
+        
+                    return duoResponse;
+                }
+            }
+        }
+    }
+// Checkstyle: CyclomaticComplexity ON
+
+}
diff --git a/idp-duo-impl/src/main/resources/META-INF/net.shibboleth.idp/postconfig.xml b/idp-duo-impl/src/main/resources/META-INF/net.shibboleth.idp/postconfig.xml
index 08bb6656..bed68aa2 100644
--- a/idp-duo-impl/src/main/resources/META-INF/net.shibboleth.idp/postconfig.xml
+++ b/idp-duo-impl/src/main/resources/META-INF/net.shibboleth.idp/postconfig.xml
@@ -52,5 +52,30 @@
     <bean id="shibboleth.DuoOIDCAuthnController"
         class="net.shibboleth.idp.plugin.authn.duo.impl.DuoOIDCAuthnController" />
 
+    <!-- Default Duo Admin API Integration for IdP-wide use. -->
+    <bean id="shibboleth.authn.DuoOIDC.Admin.DuoIntegration" lazy-init="true"
+        class="net.shibboleth.idp.authn.duo.BasicDuoIntegration"
+        p:APIHost="%{idp.duo.oidc.admin.apiHost:%{idp.duo.oidc.apiHost:none}}"
+        p:integrationKey="%{idp.duo.oidc.admin.integrationKey:none}"
+        p:secretKey="%{idp.duo.oidc.admin.secretKey:none}"/>
+    <bean id="shibboleth.authn.DuoOIDC.Admin.DuoIntegrationStrategy" parent="shibboleth.Functions.Constant" lazy-init="true"
+        c:target-ref="shibboleth.authn.DuoOIDC.Admin.DuoIntegration" />
+
+    <bean id="shibboleth.authn.DuoOIDC.DefaultAdminClient" class="net.shibboleth.idp.plugin.authn.duo.impl.DefaultDuoAdminClient"
+        lazy-init="true"
+        p:objectMapper-ref="shibboleth.JSONObjectMapper"
+        p:httpClient="#{getObject('shibboleth.authn.DuoOIDC.Admin.HttpClient') ?: getObject('shibboleth.InternalHttpClient')}"
+        p:httpClientSecurityParameters="#{getObject('shibboleth.authn.DuoOIDC.NonBrowser.HttpClientSecurityParameters')}"
+        p:adminDuoIntegrationLookupStrategy-ref="shibboleth.authn.DuoOIDC.Admin.DuoIntegrationStrategy"/>
+
+    <!-- Default passwordless condition that uses the admin API -->
+    <bean id="shibboleth.authn.DuoOIDC.Passwordless.DefaultCondition" lazy-init="true"
+            class="net.shibboleth.idp.plugin.authn.duo.PasswordlessEnrollmentCondition"
+            p:duoAdminClient="#{getObject('shibboleth.authn.DuoOIDC.AdminClient') ?: getObject('shibboleth.authn.DuoOIDC.DefaultAdminClient')}">
+        <property name="allowedLabels">
+            <bean parent="shibboleth.CommaDelimStringArray"
+                c:_0="#{'%{idp.duo.oidc.passwordless.allowedLabels:}'.trim()}" />
+        </property>
+    </bean>
 
 </beans>
\ No newline at end of file
diff --git a/idp-duo-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/authn/DuoOIDC/duo-oidc-authn-beans.xml b/idp-duo-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/authn/DuoOIDC/duo-oidc-authn-beans.xml
index 00981536..d5fa518a 100644
--- a/idp-duo-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/authn/DuoOIDC/duo-oidc-authn-beans.xml
+++ b/idp-duo-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/authn/DuoOIDC/duo-oidc-authn-beans.xml
@@ -62,16 +62,16 @@
 
     <!-- Default passwordless integration. -->
     <bean id="shibboleth.authn.DuoOIDC.Passwordless.DuoIntegration" lazy-init="false"
-        class="net.shibboleth.idp.plugin.authn.duo.DefaultDuoOIDCIntegration"
-        p:passwordless="true"
-        p:APIHost="%{idp.duo.oidc.passwordless.apiHost:%{idp.duo.oidc.apiHost:none}}"
-        p:clientId="%{idp.duo.oidc.passwordless.clientId:none}"
-        p:secretKey="%{idp.duo.oidc.passwordless.secretKey:none}"
-        p:registeredRedirectURI="%{idp.duo.oidc.passwordless.redirectURL:%{idp.duo.oidc.redirectURL:}}"
-        p:healthCheckEndpoint="%{idp.duo.oidc.passwordless.endpoint.health:%{idp.duo.oidc.endpoint.health:/oauth/v1/health_check}}"
-        p:tokenEndpoint="%{idp.duo.oidc.passwordless.endpoint.token:%{idp.duo.oidc.endpoint.token:/oauth/v1/token}}"
-        p:authorizeEndpoint="%{idp.duo.oidc.passwordless.endpoint.authorize:%{idp.duo.oidc.endpoint.authorize:/oauth/v1/authorize}}"
-        p:allowedOrigins="%{idp.duo.oidc.passwordless.redirecturl.allowedOrigins:%{idp.duo.oidc.redirecturl.allowedOrigins:}}">
+            class="net.shibboleth.idp.plugin.authn.duo.DefaultDuoOIDCIntegration"
+            p:passwordless="true"
+            p:APIHost="%{idp.duo.oidc.passwordless.apiHost:%{idp.duo.oidc.apiHost:none}}"
+            p:clientId="%{idp.duo.oidc.passwordless.clientId:none}"
+            p:secretKey="%{idp.duo.oidc.passwordless.secretKey:none}"
+            p:registeredRedirectURI="%{idp.duo.oidc.passwordless.redirectURL:%{idp.duo.oidc.redirectURL:}}"
+            p:healthCheckEndpoint="%{idp.duo.oidc.passwordless.endpoint.health:%{idp.duo.oidc.endpoint.health:/oauth/v1/health_check}}"
+            p:tokenEndpoint="%{idp.duo.oidc.passwordless.endpoint.token:%{idp.duo.oidc.endpoint.token:/oauth/v1/token}}"
+            p:authorizeEndpoint="%{idp.duo.oidc.passwordless.endpoint.authorize:%{idp.duo.oidc.endpoint.authorize:/oauth/v1/authorize}}"
+            p:allowedOrigins="%{idp.duo.oidc.passwordless.redirecturl.allowedOrigins:%{idp.duo.oidc.redirecturl.allowedOrigins:}}">
         <property name="allowedFactors">
             <bean parent="shibboleth.CommaDelimStringArray"
                 c:_0="#{'%{idp.duo.oidc.passwordless.allowedFactors:Platform authenticator (2fa),Roaming authenticator (2fa)}'.trim()}" />
@@ -79,7 +79,7 @@
     </bean>
     <bean id="shibboleth.authn.DuoOIDC.Passwordless.DuoIntegrationStrategy" parent="shibboleth.Functions.Constant"
         c:target="#{getObject('shibboleth.authn.DuoOIDC.Passwordless.DuoIntegration')}" />
-
+        
     <!-- Default "optional" non-browser integration. -->
     <bean id="shibboleth.authn.DuoOIDC.NonBrowser.DuoIntegration" lazy-init="false"
         class="net.shibboleth.idp.authn.duo.BasicDuoIntegration"
@@ -146,22 +146,20 @@
         p:resultCachingPredicate="#{getObject('shibboleth.authn.DuoOIDC.resultCachingPredicate')}" />
 
     <!-- Passwordless beans  -->
-    <!-- TODO: change to shibboleth.BiConditions.TRUE once API moves to 5.1. -->
-    <bean id="DefaultPasswordlessCondition" parent="shibboleth.BiConditions.Expression" c:_0="true" />
     <bean id="CheckPasswordlessEnrollment"
         class="net.shibboleth.idp.plugin.authn.duo.impl.CheckPasswordlessEnrollment" scope="prototype"
         p:httpServletRequestSupplier-ref="shibboleth.HttpServletRequestSupplier"
-        p:passwordlessCondition="#{getObject('shibboleth.authn.DuoOIDC.Passwordless.Condition') ?: getObject('DefaultPasswordlessCondition')}"
+        p:passwordlessCondition="#{getObject('shibboleth.authn.DuoOIDC.Passwordless.Condition') ?: getObject('shibboleth.authn.DuoOIDC.Passwordless.DefaultCondition')}"
         p:dataSealer="#{'%{idp.authn.usernameCookieName:}'.trim().isEmpty() ? null : getObject('shibboleth.DataSealer')}"
         p:cookieManager="#{'%{idp.authn.usernameCookieName:}'.trim().isEmpty() ? null : getObject('shibboleth.PersistentCookieManager')}"
         p:cookieName="%{idp.authn.usernameCookieName:}"
-        p:usernameFieldName="#{'%{idp.authn.oidc.usernameFieldName:j_username}'.trim()}"
+        p:usernameFieldName="#{'%{idp.duo.oidc.usernameFieldName:j_username}'.trim()}"
         p:checkSession="%{idp.authn.usernameFromSession:false}"
-        p:lowercase="%{idp.authn.oidc.lowercase:false}"
-        p:uppercase="%{idp.authn.oidc.uppercase:false}"
-        p:trim="%{idp.authn.oidc.trim:true}"
+        p:lowercase="%{idp.duo.oidc.lowercase:false}"
+        p:uppercase="%{idp.duo.oidc.uppercase:false}"
+        p:trim="%{idp.duo.oidc.trim:true}"
         p:transforms="#{getObject('shibboleth.authn.DuoOIDC.Transforms')}" />
-        
+    
     <!-- Duo OIDC beans -->
     <bean id="PopulateDuoAuthenticationContext" scope="prototype"
         class="net.shibboleth.idp.plugin.authn.duo.impl.PopulateDuoAuthenticationContext"
diff --git a/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/AbstractAuthnXmlFlowExecutionTests.java b/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/AbstractAuthnXmlFlowExecutionTests.java
index d43d3172..2bddd262 100644
--- a/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/AbstractAuthnXmlFlowExecutionTests.java
+++ b/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/AbstractAuthnXmlFlowExecutionTests.java
@@ -25,7 +25,17 @@ import java.util.function.Consumer;
 
 import javax.annotation.Nonnull;
 import javax.annotation.Nullable;
+import javax.net.ssl.SSLContext;
 
+import org.apache.hc.client5.http.SchemePortResolver;
+import org.apache.hc.client5.http.impl.classic.HttpClients;
+import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder;
+import org.apache.hc.client5.http.io.HttpClientConnectionManager;
+import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactory;
+import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactoryBuilder;
+import org.apache.hc.client5.http.ssl.TrustAllStrategy;
+import org.apache.hc.core5.http.HttpHost;
+import org.apache.hc.core5.ssl.SSLContexts;
 import org.mockito.Mockito;
 import org.opensaml.profile.context.ProfileRequestContext;
 import org.slf4j.Logger;
@@ -72,6 +82,7 @@ import net.shibboleth.idp.ui.context.RelyingPartyUIContext;
 import net.shibboleth.shared.annotation.constraint.NonnullElements;
 import net.shibboleth.shared.annotation.constraint.NotEmpty;
 import net.shibboleth.shared.collection.CollectionSupport;
+import net.shibboleth.shared.logic.Constraint;
 import net.shibboleth.shared.logic.FunctionSupport;
 import net.shibboleth.shared.primitive.LoggerFactory;
 import net.shibboleth.shared.security.impl.SecureRandomIdentifierGenerationStrategy;
@@ -465,10 +476,66 @@ public abstract class AbstractAuthnXmlFlowExecutionTests extends CustomAbstractX
         
         addBeanDefinition(builderContext, "shibboleth.ChildLookup.AuthenticationContext",BeanDefinitionBuilder.
                 genericBeanDefinition(org.opensaml.messaging.context.navigate.ChildContextLookup.class)
-                .addConstructorArgValue(net.shibboleth.idp.authn.context.AuthenticationContext.class).getBeanDefinition());
+                .addConstructorArgValue(net.shibboleth.idp.authn.context.AuthenticationContext.class)
+                .getBeanDefinition());
+        
+        try {
+            // Create a HttpClient which turns off hostname verification and trusts all certificates (for TESTS!)
+            // Would be use either by non-browser or admin API lookups
+            // Switch HTTPS (all) ports to 9191 so we can use a mock server to serve requests.
+            final SSLContext sslcontext = SSLContexts.custom()
+                    .loadTrustMaterial(null, new TrustAllStrategy())
+                    .build();
+            final SSLConnectionSocketFactory sslSocketFactory = SSLConnectionSocketFactoryBuilder.create()
+                    .setSslContext(sslcontext)
+                    .build();
+            final HttpClientConnectionManager cm = PoolingHttpClientConnectionManagerBuilder.create()
+                    .setSSLSocketFactory(sslSocketFactory)
+                    .build();
+            
+            addBeanSingleton(builderContext, "shibboleth.InternalHttpClient",
+                    Constraint.isNotNull(HttpClients.custom()
+                    .setConnectionManager(cm)
+                    .setSchemePortResolver(new SchemePortResolver() {                        
+                        @Override
+                        public int resolve(final HttpHost host) {
+                            return 9191;
+                        }
+                    })
+                    .evictExpiredConnections()
+                    .build(),"HttpClient can not be null"));
+           
+        } catch (final Exception e) {
+            log.error("Could not mock HTTP response",e);
+        }
                          
     }
     
+    /**
+     * Adds a singleton bean to the {@link StaticApplicationContext} contained in the builder context.
+     * 
+     * @param builderContext to add the bean to.
+     * @param beanName the name of the bean.
+     * @param bean the bean.
+     */
+    protected void addBeanSingleton(@Nonnull final MockFlowBuilderContext builderContext, 
+            @Nonnull final String beanName, @Nonnull final Object bean) {
+        
+        assertNotNull(builderContext);
+        assertNotNull(beanName);
+        assertNotNull(bean);
+        assertTrue( builderContext.getApplicationContext() instanceof ConfigurableApplicationContext);
+        
+        final BeanFactory factory = ((ConfigurableApplicationContext) builderContext.
+                getApplicationContext()).getBeanFactory();
+        
+        assertNotNull(factory);
+        assertTrue(factory instanceof DefaultListableBeanFactory);
+        
+        ((DefaultListableBeanFactory)factory).registerSingleton(beanName, bean);
+        
+    }
+    
     
     /**
      * Adds the bean to the {@link StaticApplicationContext} contained in the builder context.
diff --git a/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/DefaultDuoAdminClientTest.java b/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/DefaultDuoAdminClientTest.java
new file mode 100644
index 00000000..867de652
--- /dev/null
+++ b/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/DefaultDuoAdminClientTest.java
@@ -0,0 +1,397 @@
+/*
+ * 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.duo.impl;
+
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertNotNull;
+import static org.testng.Assert.fail;
+
+import java.util.List;
+import java.util.Map;
+
+import org.apache.hc.client5.http.classic.HttpClient;
+import org.apache.hc.core5.http.ClassicHttpRequest;
+import org.apache.hc.core5.http.ClassicHttpResponse;
+import org.apache.hc.core5.http.HttpHost;
+import org.apache.hc.core5.http.io.entity.StringEntity;
+import org.apache.hc.core5.http.protocol.HttpContext;
+import org.mockito.Mockito;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import net.shibboleth.idp.authn.duo.BasicDuoIntegration;
+import net.shibboleth.idp.plugin.authn.duo.DuoAdminResponseWrapper;
+import net.shibboleth.idp.plugin.authn.duo.DuoException;
+import net.shibboleth.idp.plugin.authn.duo.model.User;
+import net.shibboleth.shared.component.ComponentInitializationException;
+
+/**
+ * Tests for {@link DefaultDuoAdminClient}. 
+ */
+public class DefaultDuoAdminClientTest {
+    
+    /** The client to test.*/
+    private DefaultDuoAdminClient client;
+    
+    /** A normal response.*/
+    private static final String GOOD_RESPONSE = 
+            """
+            {
+            "response": [
+                {
+                "alias1": null,
+                "alias2": null,
+                "alias3": null,
+                "alias4": null,
+                "aliases": {},
+                "created": 1592986174,
+                "desktoptokens": [],
+                "email": "",
+                "firstname": null,
+                "groups": [],
+                "is_enrolled": true,
+                "last_directory_sync": null,
+                "last_login": 1704368292,
+                "lastname": null,
+                "lockout_reason": null,
+                "notes": "",
+                "phones": [],
+                "realname": "",
+                "status": "active",
+                "tokens": [],
+                "u2ftokens": [
+                    {
+                        "date_added": 1629107332,
+                        "registration_id": "DDFKJNBVWFRFFD"
+                    }
+                ],
+                "user_id": "FWREGFEROGJER",
+                "username": "jdoe",
+                "webauthncredentials": [
+                    {
+                        "credential_name": "Security Key",
+                        "date_added": 1643124774,
+                        "label": "Passkey",
+                        "webauthnkey": "DDFKJNBVWFRFFDD"
+                    },
+                    {
+                        "credential_name": "Security key",
+                        "date_added": 1701874074,
+                        "label": "Security key",
+                        "webauthnkey": "DDFKJNBVWFRFFDDD"
+                    },
+                    {
+                        "credential_name": "Passkey",
+                        "date_added": 1704280068,
+                        "label": "Passkey",
+                        "webauthnkey": "DDFKJNBVWFRFFDDDD"
+                    }
+                ]
+                }
+            ],
+            "stat": "OK"
+            }          
+            """;
+    
+    /** Two users in response. We require one.*/
+    private static final String TWO_USER_RESPONSE = 
+            """
+            {
+            "response": [
+                {             
+                "user_id": "FWREGFEROGJERFEFE",
+                "username": "jdoe"             
+                },
+                {             
+                "user_id": "FWREGFEROGJER",
+                "username": "jdoe"               
+                }
+            ],
+            "stat": "OK"
+            }          
+            """;
+    
+    /** Correct response, but the status describes a FAIL.*/
+    private static final String UNEXPECTED_RESPONSE_STATUS =
+            """
+            {
+            "response": [
+                {
+                "created": 1592986174,
+                "desktoptokens": [],
+                "email": "",
+                "firstname": null,
+                "groups": [],
+                "is_enrolled": true,
+                "last_directory_sync": null,
+                "last_login": 1704368292,
+                "lastname": null,
+                "lockout_reason": null,
+                "notes": "",
+                "phones": [],
+                "realname": "",
+                "status": "active",
+                "tokens": [],                
+                "user_id": "FWREGFEROGJER",
+                "username": "jdoe"  
+                }              
+            ],
+            "stat": "FAIL"
+            }          
+            """;
+    
+    /** Incorrect return format.*/
+    private static final String INCOMPATIBLE_RESPONSE = 
+            """
+            {
+            "bad_response": [],
+            "stat": "OK"
+            }          
+            """;
+    
+    /** Failed response.*/
+    private static final String FAIL_RESPONSE = 
+            """
+            {
+              "stat": "FAIL",
+              "code": 40002,
+              "message": "Invalid request parameters",
+              "message_detail": "username"
+            }         
+            """;
+    
+    @BeforeMethod
+    public void setup() {
+        client = new DefaultDuoAdminClient();
+        client.setId("test-client");
+        client.setAdminDuoIntegrationLookupStrategy(prc -> {
+            final BasicDuoIntegration integration = new BasicDuoIntegration();
+            integration.setAPIHost("example.com");
+            integration.setIntegrationKey("integrationkey");
+            integration.setSecretKey("secretkey");
+            try {
+                integration.initialize();
+            } catch (final ComponentInitializationException e) {
+                fail(e.getMessage());
+            }
+            return integration;
+        });
+    }
+    
+    
+    @Test(expectedExceptions = ComponentInitializationException.class)
+    public void testClientSetup_NothingSet() throws ComponentInitializationException {
+        client.initialize();
+    }
+    
+    @Test(expectedExceptions = ComponentInitializationException.class)
+    public void testClientSetup_NoObjectMapper() throws ComponentInitializationException {
+        client.setHttpClient(Mockito.mock(HttpClient.class));
+        client.initialize();
+    }
+    
+    @Test(expectedExceptions = ComponentInitializationException.class)
+    public void testClientSetup_NoHttpClient() throws ComponentInitializationException {
+        client.setObjectMapper(new ObjectMapper());
+        client.initialize();
+    }
+    
+    @Test(expectedExceptions = DuoException.class)
+    public void testClientGetUsers_EmptyResponse() throws Exception {
+        final HttpClient httpClient = Mockito.mock(HttpClient.class);
+        final ClassicHttpResponse httpResponse = Mockito.mock(ClassicHttpResponse.class);
+
+        Mockito.when(httpResponse.getCode()).thenReturn(200);
+        Mockito.when(httpResponse.getEntity()).thenReturn(new StringEntity(""));
+        Mockito.when(httpClient.executeOpen((HttpHost) Mockito.any(), (ClassicHttpRequest) Mockito.any(), 
+                (HttpContext) Mockito.any())).thenReturn(httpResponse);
+        
+        client.setHttpClient(httpClient);
+        client.setObjectMapper(new ObjectMapper());
+        client.initialize();
+        
+        client.getUser(new ProfileRequestContext(), "jdoe");
+    }
+    
+    @Test(expectedExceptions = DuoException.class)
+    public void testClientGetUsers_ServerError() throws Exception {
+        final HttpClient httpClient = Mockito.mock(HttpClient.class);
+        final ClassicHttpResponse httpResponse = Mockito.mock(ClassicHttpResponse.class);
+
+        Mockito.when(httpResponse.getCode()).thenReturn(500);
+        Mockito.when(httpResponse.getEntity()).thenReturn(new StringEntity(""));
+        Mockito.when(httpClient.executeOpen((HttpHost) Mockito.any(), (ClassicHttpRequest) Mockito.any(), 
+                (HttpContext) Mockito.any())).thenReturn(httpResponse);
+        
+        client.setHttpClient(httpClient);
+        client.setObjectMapper(new ObjectMapper());
+        client.initialize();
+        
+        client.getUser(new ProfileRequestContext(), "jdoe");
+    }
+    
+    @Test(expectedExceptions = DuoException.class)
+    public void testClientGetUsers_BadRequest() throws Exception {
+        final HttpClient httpClient = Mockito.mock(HttpClient.class);
+        final ClassicHttpResponse httpResponse = Mockito.mock(ClassicHttpResponse.class);
+
+        Mockito.when(httpResponse.getCode()).thenReturn(400);
+        Mockito.when(httpResponse.getEntity()).thenReturn(new StringEntity(FAIL_RESPONSE));
+        Mockito.when(httpClient.executeOpen((HttpHost) Mockito.any(), (ClassicHttpRequest) Mockito.any(), 
+                (HttpContext) Mockito.any())).thenReturn(httpResponse);
+        
+        client.setHttpClient(httpClient);
+        client.setObjectMapper(new ObjectMapper());
+        client.initialize();
+        
+        client.getUser(new ProfileRequestContext(), "jdoe");
+    }
+    
+    @Test
+    public void testClientGetUsers_OK() throws Exception {
+        final HttpClient httpClient = Mockito.mock(HttpClient.class);
+        final ClassicHttpResponse httpResponse = Mockito.mock(ClassicHttpResponse.class);
+
+        Mockito.when(httpResponse.getCode()).thenReturn(200);
+        Mockito.when(httpResponse.getEntity()).thenReturn(new StringEntity(GOOD_RESPONSE));
+        Mockito.when(httpClient.executeOpen((HttpHost) Mockito.any(), (ClassicHttpRequest) Mockito.any(), 
+                (HttpContext) Mockito.any())).thenReturn(httpResponse);
+        
+        client.setHttpClient(httpClient);
+        client.setObjectMapper(new ObjectMapper());
+        client.initialize();
+        
+        final User user = client.getUser(new ProfileRequestContext(), "jdoe");
+        assertNotNull(user);
+        assertEquals(user.getUsername(), "jdoe");
+        assertEquals(user.getWebAuthnCredentials().size(), 3);
+    }
+    
+    @Test(expectedExceptions = DuoException.class)
+    public void testClientGetUsers_IncompatibleResponse() throws Exception {
+        final HttpClient httpClient = Mockito.mock(HttpClient.class);
+        final ClassicHttpResponse httpResponse = Mockito.mock(ClassicHttpResponse.class);
+
+        Mockito.when(httpResponse.getCode()).thenReturn(200);
+        Mockito.when(httpResponse.getEntity()).thenReturn(new StringEntity(INCOMPATIBLE_RESPONSE));
+        Mockito.when(httpClient.executeOpen((HttpHost) Mockito.any(), (ClassicHttpRequest) Mockito.any(), 
+                (HttpContext) Mockito.any())).thenReturn(httpResponse);
+        
+        client.setHttpClient(httpClient);
+        client.setObjectMapper(new ObjectMapper());
+        client.initialize();
+        
+        client.getUser(new ProfileRequestContext(), "jdoe");
+    }
+    
+    @Test(expectedExceptions = DuoException.class)
+    public void testClientGetUsers_NoResponseBody() throws Exception {
+        final HttpClient httpClient = Mockito.mock(HttpClient.class);
+        final ClassicHttpResponse httpResponse = Mockito.mock(ClassicHttpResponse.class);
+
+        Mockito.when(httpResponse.getCode()).thenReturn(200);
+        Mockito.when(httpResponse.getEntity()).thenReturn(null);
+        Mockito.when(httpClient.executeOpen((HttpHost) Mockito.any(), (ClassicHttpRequest) Mockito.any(), 
+                (HttpContext) Mockito.any())).thenReturn(httpResponse);
+        
+        client.setHttpClient(httpClient);
+        client.setObjectMapper(new ObjectMapper());
+        client.initialize();
+        
+        client.getUser(new ProfileRequestContext(), "jdoe");
+    }
+    
+    @Test(expectedExceptions = DuoException.class)
+    public void testClientGetUsers_NoIntegration() throws Exception {
+        final HttpClient httpClient = Mockito.mock(HttpClient.class);
+        final ClassicHttpResponse httpResponse = Mockito.mock(ClassicHttpResponse.class);
+
+        Mockito.when(httpResponse.getCode()).thenReturn(200);
+        Mockito.when(httpResponse.getEntity()).thenReturn(new StringEntity(GOOD_RESPONSE));
+        Mockito.when(httpClient.executeOpen((HttpHost) Mockito.any(), (ClassicHttpRequest) Mockito.any(), 
+                (HttpContext) Mockito.any())).thenReturn(httpResponse);
+        
+        client.setHttpClient(httpClient);
+        client.setAdminDuoIntegrationLookupStrategy(prc->null);
+        client.setObjectMapper(new ObjectMapper());
+        client.initialize();
+        
+        client.getUser(new ProfileRequestContext(), "jdoe");
+    }
+    
+    @Test(expectedExceptions = DuoException.class)
+    public void testClientGetUsers_TwoUsersReturned() throws Exception {
+        final HttpClient httpClient = Mockito.mock(HttpClient.class);
+        final ClassicHttpResponse httpResponse = Mockito.mock(ClassicHttpResponse.class);
+
+        Mockito.when(httpResponse.getCode()).thenReturn(200);
+        Mockito.when(httpResponse.getEntity()).thenReturn(new StringEntity(TWO_USER_RESPONSE));
+        Mockito.when(httpClient.executeOpen((HttpHost) Mockito.any(), (ClassicHttpRequest) Mockito.any(), 
+                (HttpContext) Mockito.any())).thenReturn(httpResponse);
+        
+        client.setHttpClient(httpClient);
+        client.setObjectMapper(new ObjectMapper());
+        client.initialize();
+        
+        client.getUser(new ProfileRequestContext(), "jdoe");
+    }
+    
+    @Test(expectedExceptions = DuoException.class)
+    public void testClientGetUsers_UnexpectedResponseStatus() throws Exception {
+        final HttpClient httpClient = Mockito.mock(HttpClient.class);
+        final ClassicHttpResponse httpResponse = Mockito.mock(ClassicHttpResponse.class);
+
+        Mockito.when(httpResponse.getCode()).thenReturn(200);
+        Mockito.when(httpResponse.getEntity()).thenReturn(new StringEntity(UNEXPECTED_RESPONSE_STATUS));
+        Mockito.when(httpClient.executeOpen((HttpHost) Mockito.any(), (ClassicHttpRequest) Mockito.any(), 
+                (HttpContext) Mockito.any())).thenReturn(httpResponse);
+        
+        client.setHttpClient(httpClient);
+        client.setObjectMapper(new ObjectMapper());
+        client.initialize();
+        
+        client.getUser(new ProfileRequestContext(), "jdoe");
+    }
+    
+    @Test
+    public void testClientRetrieve_OK() throws Exception {
+        final HttpClient httpClient = Mockito.mock(HttpClient.class);
+        final ClassicHttpResponse httpResponse = Mockito.mock(ClassicHttpResponse.class);
+
+        Mockito.when(httpResponse.getCode()).thenReturn(200);
+        Mockito.when(httpResponse.getEntity()).thenReturn(new StringEntity(GOOD_RESPONSE));
+        Mockito.when(httpClient.executeOpen((HttpHost) Mockito.any(), (ClassicHttpRequest) Mockito.any(), 
+                (HttpContext) Mockito.any())).thenReturn(httpResponse);
+        
+        client.setHttpClient(httpClient);
+        client.setObjectMapper(new ObjectMapper());
+        client.initialize();
+        
+        final DuoAdminResponseWrapper<List<User>> userWrapper = 
+                client.retrieve(new ProfileRequestContext(),"/admin/v1/users", Map.of("username","jdoe"), 
+                new TypeReference<DuoAdminResponseWrapper<List<User>>>() {});
+        
+        assertNotNull(userWrapper);
+        assertNotNull(userWrapper.getResponse());
+        assertEquals(userWrapper.getResponse().size(), 1);
+        assertEquals(userWrapper.getResponse().get(0).getUsername(), "jdoe");
+    }
+
+
+}
diff --git a/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/DuoAuthnFlowTest.java b/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/DuoAuthnFlowTest.java
index 5c6a07a3..1f117e4e 100644
--- a/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/DuoAuthnFlowTest.java
+++ b/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/DuoAuthnFlowTest.java
@@ -15,6 +15,8 @@
 package net.shibboleth.idp.plugin.authn.duo.impl;
 
 
+import java.io.IOException;
+import java.net.UnknownHostException;
 import java.security.Principal;
 import java.util.List;
 import java.util.Map;
@@ -44,6 +46,7 @@ import net.shibboleth.idp.plugin.authn.duo.DefaultDuoOIDCIntegration;
 import net.shibboleth.idp.plugin.authn.duo.DuoClientException;
 import net.shibboleth.idp.plugin.authn.duo.DuoOIDCIntegration;
 import net.shibboleth.idp.plugin.authn.duo.context.DuoOIDCAuthenticationContext;
+import net.shibboleth.idp.plugin.authn.duo.context.DuoPasswordlessContext;
 import net.shibboleth.idp.plugin.authn.mock.MockDuoOIDCClientFactory_FAIL_Client;
 import net.shibboleth.idp.plugin.authn.mock.MockDuoOIDCClientFactory_OK_Client;
 import net.shibboleth.idp.plugin.authn.mock.MockDuoOIDCClient_OK;
@@ -54,6 +57,10 @@ import net.shibboleth.profile.context.RelyingPartyContext;
 import net.shibboleth.shared.annotation.constraint.NonnullElements;
 import net.shibboleth.shared.annotation.constraint.Unmodifiable;
 import net.shibboleth.shared.component.ComponentInitializationException;
+import okhttp3.mockwebserver.MockResponse;
+import okhttp3.mockwebserver.MockWebServer;
+import okhttp3.tls.HandshakeCertificates;
+import okhttp3.tls.HeldCertificate;
 
 /**
  * Test the Duo 2FA flow using SWF flow testing.
@@ -92,6 +99,38 @@ public class DuoAuthnFlowTest extends AbstractAuthnXmlFlowExecutionTests {
                     "classpath:/net/shibboleth/idp/flows/authn/authn-abstract-flow.xml","authn.abstract",
                     "classpath:/net/shibboleth/idp/module/conf/authn/authn-events-flow.xml","authn.events");
     
+    /** A normal response.*/
+    private static final String ADMIN_API_USER_RESPONSE = 
+            """
+            {
+            "response": [
+                {
+                "created": 1592986174,               
+                "email": "",
+                "firstname": null,                
+                "is_enrolled": true,
+                "last_directory_sync": null,
+                "last_login": 1704368292,
+                "lastname": null,
+                "lockout_reason": null,
+                "realname": "",
+                "status": "active",
+                "tokens": [],                
+                "user_id": "FWREGFEROGJER",
+                "username": "jdoe",
+                "webauthncredentials": [
+                    {
+                        "credential_name": "Security Key",
+                        "date_added": 1643124774,
+                        "label": "Passkey",
+                        "webauthnkey": "DDFKJNBVWFRFFDD"
+                    }
+                ]
+                }
+            ],
+            "stat": "OK"
+            }          
+            """;    
     
     /** Constructor.*/
     public DuoAuthnFlowTest() {
@@ -105,7 +144,44 @@ public class DuoAuthnFlowTest extends AbstractAuthnXmlFlowExecutionTests {
         
         loadBeanDefinitionsFromXmlFile(builderContext, 
                 new ClassPathResource("META-INF/net.shibboleth.idp/postconfig.xml"),
-                null);               
+                null);        
+    }
+    
+    /**
+     * Create a running server that mimics responses from the Duo Admin or Auth APIs.
+     * 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 Duo APIs.
+     * 
+     * @param mockOPServer the mock server
+     * @param code the response HTTP code
+     * @param body the response body
+     * @param contentType the content type header
+     */
+    private 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));
     }
 
     /** Test the Duo flow when the health check returns unhealthy.*/
@@ -172,6 +248,61 @@ public class DuoAuthnFlowTest extends AbstractAuthnXmlFlowExecutionTests {
 
     }
     
+    /** 
+     * Test the Duo flow up to the external authorization request when using the passwordless flow.
+     * 
+     * @throws IOException on error
+     */
+    @Test
+    public void testDuoAuthnFlowToAuthorizationRequestForPasswordless() throws IOException {
+        
+        
+        // Requires mock server to handle Admin API calls
+        final MockWebServer mockServer = createSimpleServer();
+        // Simulate Admin API user response.
+        queueMockServerResponse(mockServer, 200, ADMIN_API_USER_RESPONSE, "application/json");
+
+        
+        mockServer.start(9191); 
+              
+        setFlowPath(FLOW);
+        setFlowModelResources(flowResources);
+        setSubflows(subflows);
+        setClientFactory(new MockDuoOIDCClientFactory_OK_Client());
+        
+       final Map<String,String> mockProperties = Map.of(
+                "idp.duo.oidc.redirectURL","http://localhost/authorization-callback",
+                "idp.duo.oidc.apiHost","api-c9f24c5a.duosecurity.com",
+                "idp.duo.oidc.clientId","DIU6GEFWG5LIUBVV2M3P",
+                "idp.duo.oidc.secretKey","rFvDfPul27v3Wew2zb6xRPzAJewJ34MP2w8UitPh",
+                "idp.duo.oidc.admin.apiHost","localhost",
+                "idp.duo.oidc.admin.integrationKey","DIU6GEFWG5LIUBVV2M3PPPP",
+                "idp.duo.oidc.admin.secretKey","rFvDfPul27v3Wew2zb6xRPzAJewJ34MP2w8UitPh",
+                "idp.duo.oidc.passwordless.allowedLabels","Passkey",
+                "idp.duo.oidc.user.config","duo-oidc-authn-config.xml",
+                "idp.duo.oidc.clientFactoryBean","shibboleth.authn.DuoOIDC.test.clientFactory");
+        
+        setMockProperties(mockProperties);
+        
+        final LocalAttributeMap<Object> inputMap = new LocalAttributeMap<>();
+        inputMap.put("calledAsSubflow", true);
+       
+        final ProfileRequestContext context = buildProfileRequestContext(false,true);
+        // Add passwordless context to init passwordless flow
+        final DuoPasswordlessContext passwordlessContext = new DuoPasswordlessContext();
+        context.getSubcontext(AuthenticationContext.class).addSubcontext(passwordlessContext);
+        // Add a username into the request for the passwordless flow to use
+        mockRequest.addParameter("j_username", "jdoe");
+        
+        final FlowExecution flowExecution = getFlowExecutionFactory().createFlowExecution(getFlowDefinition());  
+        flowExecution.getConversationScope().put("opensamlProfileRequestContext", context);
+        updateFlowExecution(flowExecution);         
+        flowExecution.start(inputMap, externalContext);         
+        assertFlowExecutionActive();
+        assertCurrentStateEquals("PasswordlessView");
+
+    }
+    
     /** Test the Duo flow up to the external authorization request using the dynamically selected
      * first Duo integration.*/
     @Test
diff --git a/pom.xml b/pom.xml
index 19d18657..88662510 100644
--- a/pom.xml
+++ b/pom.xml
@@ -23,6 +23,8 @@
         <idp.groupId>net.shibboleth.idp</idp.groupId>
         <idp.version>5.0.0</idp.version>
         <duo.client.version>1.1.3</duo.client.version>
+        <okhttp3.mockserver.version>4.9.3</okhttp3.mockserver.version>
+        <okhttp3.tls.version>4.9.3</okhttp3.tls.version>
         <opensaml.groupId>org.opensaml</opensaml.groupId>
         <opensaml.version>5.0.0</opensaml.version>
         <oidc-common.groupId>net.shibboleth.oidc</oidc-common.groupId>
@@ -119,8 +121,19 @@
                 <artifactId>idp-plugin-duo-api</artifactId>
                 <version>${project.version}</version>
             </dependency>
-            <!-- test bom dependencies -->
-           
+            <!-- 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>
 

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


More information about the commits mailing list