[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