[java-idp-plugin-duo] branch main updated: JDUO-80 - Use of Duo as a Passwordless solution

Scott Cantor cantor.2 at osu.edu
Thu Jan 4 15:26:20 UTC 2024


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

scantor 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=88102f0660f661fac502b241371640bd4554f6dd

The following commit(s) were added to refs/heads/main by this push:
     new 88102f06 JDUO-80 - Use of Duo as a Passwordless solution
88102f06 is described below

commit 88102f0660f661fac502b241371640bd4554f6dd
Author: Scott Cantor <cantor.2 at osu.edu>
AuthorDate: Thu Jan 4 10:25:01 2024 -0500

    JDUO-80 - Use of Duo as a Passwordless solution
    
    https://shibboleth.atlassian.net/browse/JDUO-80
---
 .../plugin/authn/duo/DefaultDuoCleanupHook.java    | 156 +++++++++
 .../authn/duo/DefaultDuoOIDCIntegration.java       |  58 +++-
 .../idp/plugin/authn/duo/DuoOIDCIntegration.java   |  33 +-
 .../authn/duo/DynamicDuoOIDCIntegration.java       |   1 -
 .../plugin/authn/duo/SimpleDuoOIDCIntegration.java |  63 ++--
 .../authn/duo/context/DuoPasswordlessContext.java  |  93 ++++++
 .../duo/impl/CheckPasswordlessEnrollment.java      | 362 +++++++++++++++++++++
 .../duo/impl/PopulateDuoAuthenticationContext.java | 227 +++++++++----
 .../impl/ValidateDuoTokenAuthenticationResult.java |  66 ++--
 .../flows/authn/DuoOIDC/duo-oidc-authn-beans.xml   | 103 ++++--
 .../flows/authn/DuoOIDC/duo-oidc-authn-flow.xml    |  50 ++-
 .../idp/plugin/authn/duo/messages.properties       |  18 +
 .../idp/plugin/authn/duo/views/passwordless.vm     | 122 +++++++
 .../authn/duo/impl/AbstractDuoActionTest.java      |  35 +-
 .../duo/impl/CheckPasswordlessEnrollmentTest.java  | 321 ++++++++++++++++++
 .../duo/impl/DefaultDuoOIDCClientRegistryTest.java |   1 +
 .../DefaultRedirectURICreationStrategyTest.java    |   1 +
 .../impl/DuoAudienceClaimLookupStrategyTest.java   |   1 +
 .../duo/impl/DuoIssuerClaimLookupStrategyTest.java |   1 +
 .../duo/impl/DuoNonceClaimLookupStrategyTest.java  |   3 +-
 .../idp/plugin/authn/duo/impl/DuoSupportTest.java  |   1 +
 .../impl/DuoUsernameClaimLookupStrategyTest.java   |   5 +-
 .../duo/impl/ExchangeCodeForDuoTokenTest.java      |   1 +
 .../impl/PopulateDuoAuthenticationContextTest.java | 129 +++++++-
 .../duo/impl/ValidateDuoResponseStateTest.java     |   1 +
 .../ValidateDuoTokenAuthenticationResultTest.java  |  67 +++-
 .../authn/duo/impl/ValidateTokenClaimsTest.java    |   1 +
 .../authn/duo/impl/ValidateTokenSignatureTest.java |   1 +
 .../plugin/authn/mock/MockDuoOIDCClient_FAIL.java  |   7 +
 .../plugin/authn/mock/MockDuoOIDCClient_OK.java    |   7 +
 .../mock/MockDuoOIDCClient_OK_OLD_AUTH_TIME.java   |   7 +
 .../authn/mock/MockDuoOIDCClient_UNKNOWN.java      |   7 +
 .../authn/util/mock/IdPPropertyConfigurer.java     |  12 +-
 .../authn/util/mock/TestResourceConverter.java     | 127 ++++++++
 .../idp/plugin/authn/duo/impl/SealerKeyStore.jks   | Bin 0 -> 984 bytes
 .../idp/plugin/authn/duo/impl/SealerKeyStore.kver  |   1 +
 .../idp/plugin/authn/duo/nimbus/module.properties  |   2 +
 .../idp/plugin/authn/duo/sdk/module.properties     |   2 +
 pom.xml                                            |   2 +-
 39 files changed, 1909 insertions(+), 186 deletions(-)

diff --git a/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/DefaultDuoCleanupHook.java b/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/DefaultDuoCleanupHook.java
new file mode 100644
index 00000000..0e12bbc3
--- /dev/null
+++ b/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/DefaultDuoCleanupHook.java
@@ -0,0 +1,156 @@
+/*
+ * 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.function.Consumer;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.slf4j.Logger;
+
+import com.google.common.net.UrlEscapers;
+
+import net.shibboleth.idp.authn.context.AuthenticationContext;
+import net.shibboleth.idp.plugin.authn.duo.context.DuoOIDCAuthenticationContext;
+import net.shibboleth.idp.plugin.authn.duo.context.DuoPasswordlessContext;
+import net.shibboleth.shared.annotation.constraint.NotEmpty;
+import net.shibboleth.shared.component.AbstractInitializableComponent;
+import net.shibboleth.shared.net.CookieManager;
+import net.shibboleth.shared.primitive.LoggerFactory;
+import net.shibboleth.shared.primitive.StringSupport;
+import net.shibboleth.shared.security.DataSealer;
+import net.shibboleth.shared.security.DataSealerException;
+
+/**
+ * A default cleanup hook for the DuoOIDC flow that handles both standard
+ * and passwordless scenarios, with configuration flexibility for the deployer.
+ * 
+ * @since 2.1.0
+ */
+public class DefaultDuoCleanupHook extends AbstractInitializableComponent implements Consumer<ProfileRequestContext> {
+
+    /** Class logger. */
+    @Nonnull private final Logger log = LoggerFactory.getLogger(DefaultDuoCleanupHook.class);
+    
+    /** Username cookie name. */
+    @Nullable @NotEmpty private String cookieName;
+
+    /** Optional cookie manager to use. */
+    @Nullable private CookieManager cookieManager;
+
+    /** Optional data sealer to use. */
+    @Nullable private DataSealer dataSealer;
+
+    /** Whether to remove a {@link DuoPasswordlessContext} if present. */
+    private boolean removePasswordlessContext;
+    
+    /** Constructor. */
+    public DefaultDuoCleanupHook() {
+        removePasswordlessContext = true;
+    }
+    
+    /**
+     * Set cookie name to use for cached username.
+     * 
+     * @param name cookie name
+     */
+    public void setCookieName(@Nullable final String name) {
+        checkSetterPreconditions();
+
+        cookieName = StringSupport.trimOrNull(name);
+    }
+    
+    /**
+     * Sets optional {@link CookieManager} to use.
+     * 
+     * @param manager cookie manager
+     */
+    public void setCookieManager(@Nullable final CookieManager manager) {
+        checkSetterPreconditions();
+        
+        cookieManager = manager;
+    }
+
+    /**
+     * Sets optional {@link DataSealer} to use.
+     * 
+     * @param sealer data sealer
+     */
+    public void setDataSealer(@Nullable final DataSealer sealer) {
+        checkSetterPreconditions();
+        
+        dataSealer = sealer;
+    }
+    
+    /**
+     * Sets whether to remove a {@link DuoPasswordlessContext} if present.
+     * 
+     * @param flag flag to set
+     */
+    public void setRemovePasswordlessContext(final boolean flag) {
+        checkSetterPreconditions();
+        
+        removePasswordlessContext = flag;
+    }
+    
+// Checkstyle: CyclomaticComplexity OFF
+    /** {@inheritDoc} */
+    public void accept(@Nullable final ProfileRequestContext input) {
+        checkComponentActive();
+        
+        final AuthenticationContext authnCtx = input != null ? input.getSubcontext(AuthenticationContext.class) : null;
+        if (authnCtx == null) {
+            return;
+        }
+        
+        final DuoOIDCAuthenticationContext duoCtx = authnCtx.getSubcontext(DuoOIDCAuthenticationContext.class);
+        if (duoCtx != null) {
+            duoCtx.removeFromParent();
+        }
+        
+        final DuoPasswordlessContext passwordlessCtx = authnCtx.getSubcontext(DuoPasswordlessContext.class);
+        if (passwordlessCtx != null) {
+            if (removePasswordlessContext) {
+                passwordlessCtx.removeFromParent();
+            }
+
+            final String localCookieName = cookieName;
+            if (authnCtx.isResultCacheable()) {
+                if (cookieManager != null && dataSealer != null && localCookieName != null) {
+                    String wrapped = passwordlessCtx.getUsername();
+                    if (wrapped != null) {
+                        try {
+                            assert dataSealer != null;
+                            wrapped = dataSealer.wrap(wrapped);
+                            assert cookieManager != null;
+                            cookieManager.addCookie(localCookieName,
+                                    UrlEscapers.urlFormParameterEscaper().escape(wrapped));
+                        } catch (final DataSealerException e) {
+                            wrapped = null;
+                            log.warn("Error sealing username cookie", e);
+                        }
+                    }
+                }
+            } else if (cookieManager != null && localCookieName != null) {
+                cookieManager.unsetCookie(localCookieName);
+            }
+        }
+    }
+// Checkstyle: CyclomaticComplexity ON
+    
+
+}
\ No newline at end of file
diff --git a/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/DefaultDuoOIDCIntegration.java b/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/DefaultDuoOIDCIntegration.java
index 41b7503d..936df113 100644
--- a/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/DefaultDuoOIDCIntegration.java
+++ b/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/DefaultDuoOIDCIntegration.java
@@ -54,7 +54,10 @@ public final class DefaultDuoOIDCIntegration
     
     /** Class logger. */
     @Nonnull private final Logger log = LoggerFactory.getLogger(DefaultDuoOIDCIntegration.class);
-    
+
+    /** Passwordless indicator. */
+    @GuardedBy("this") private boolean passwordless;
+
     /** API host. */
     @GuardedBy("this") @NonnullAfterInit @NotEmpty private String apiHost;
     
@@ -71,7 +74,10 @@ public final class DefaultDuoOIDCIntegration
     @GuardedBy("this") @Nullable private String registeredRedirectURI;
     
     /** A set of 'allowed' origins that can be used as the scheme, host, and port portion of the redirectURI.*/
-    @GuardedBy("this") @Nonnull @NonnullElements @Unmodifiable private Set<String> allowedOrigins;
+    @GuardedBy("this") @Nonnull @NonnullElements private Set<String> allowedOrigins;
+    
+    /** A set of 'allowed' factors. */
+    @GuardedBy("this") @Nullable @NonnullElements private Set<String> allowedFactors;
     
     /** The URL path to the health endpoint.*/
     @GuardedBy("this") @NonnullAfterInit @NotEmpty private String healthEndpoint;
@@ -84,12 +90,32 @@ public final class DefaultDuoOIDCIntegration
     
     /** Container for supported principals. */
     @GuardedBy("this") @Nonnull private final Subject supportedPrincipals;
-    
+
     /** Constructor. */
     public DefaultDuoOIDCIntegration() {
         supportedPrincipals = new Subject();
         allowedOrigins = CollectionSupport.emptySet();
     }
+
+    /**
+     * Sets whether this integration is suitable for use as a single factor.
+     * 
+     * <p>Defaults to false.</p>
+     * 
+     * @param flag flag to set
+     * 
+     * @since 2.1.0
+     */
+    public synchronized void setPasswordless(final boolean flag) {
+        checkSetterPreconditions();
+        passwordless = flag;
+    }
+    
+    /** {@inheritDoc} */
+    public synchronized boolean isPasswordless() {
+        checkComponentActive();
+        return passwordless;
+    }
     
     /**
      * Set the origins that are allowed to form the scheme, host, and port part of a computed redirect_uri.
@@ -97,15 +123,31 @@ public final class DefaultDuoOIDCIntegration
      * @param hosts the hostnames to allow.
      */
     public synchronized void setAllowedOrigins(@Nullable @NonnullElements final Collection<String> hosts) {
-        checkSetterPreconditions();        
-        allowedOrigins = CollectionSupport.copyToSet(StringSupport.normalizeStringCollection(
-                Constraint.isNotNull(hosts, "Types cannot be null")));
+        checkSetterPreconditions();
+        allowedOrigins = CollectionSupport.copyToSet(StringSupport.normalizeStringCollection(hosts));
     }
     
     /** {@inheritDoc} */
     @Nonnull @NotLive @Unmodifiable public synchronized Set<String> getAllowedOrigins() {
-        //set is unmodifiable and string is immutable - so not live. 
-        return CollectionSupport.copyToSet(allowedOrigins);
+        return allowedOrigins;
+    }
+    
+    /**
+     * Set the allowable factors.
+     * 
+     * @param factors the factors to allow
+     * 
+     * @since 2.1.0
+     */
+    public synchronized void setAllowedFactors(@Nullable @NonnullElements final Collection<String> factors) {
+        checkSetterPreconditions();
+        allowedFactors = factors != null ?
+                CollectionSupport.copyToSet(StringSupport.normalizeStringCollection(factors)) : null;
+    }
+    
+    /** {@inheritDoc} */
+    @Nullable @NotLive @Unmodifiable public synchronized Set<String> getAllowedFactors() {
+        return allowedFactors;
     }
 
     /** {@inheritDoc} */
diff --git a/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/DuoOIDCIntegration.java b/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/DuoOIDCIntegration.java
index 77e4e3b9..4bf6ec95 100644
--- a/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/DuoOIDCIntegration.java
+++ b/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/DuoOIDCIntegration.java
@@ -14,12 +14,16 @@
 
 package net.shibboleth.idp.plugin.authn.duo;
 
+import java.util.Set;
+
 import javax.annotation.Nonnull;
 import javax.annotation.Nullable;
 
 import net.shibboleth.idp.authn.principal.PrincipalSupportingComponent;
+import net.shibboleth.shared.annotation.constraint.NonnullElements;
 import net.shibboleth.shared.annotation.constraint.NotEmpty;
-
+import net.shibboleth.shared.annotation.constraint.NotLive;
+import net.shibboleth.shared.annotation.constraint.Unmodifiable;
 
 /**
  * Interface to a particular Duo OIDC integration point. In part replaces
@@ -78,7 +82,28 @@ public interface DuoOIDCIntegration extends PrincipalSupportingComponent {
      */
     @Nonnull @NotEmpty String getTokenEndpoint();
 
-    
-
+    /**
+     * Gets whether the integration is suitable for use as a passwordless single factor.
+     * 
+     * <p>Defaults to false.</p>
+     * 
+     * @return true iff the integration limits methods to passwordless
+     * 
+     * @since 2.1.0
+     */
+    default boolean isPasswordless() {
+        return false;
+    }
 
-}
+    /**
+     * Gets the set of allowable factors to enforce during validation.
+     * 
+     * @return allowable factors, or null for any
+     * 
+     * @since 2.1.0
+     */
+    default @Nullable @NonnullElements @Unmodifiable @NotLive Set<String> getAllowedFactors() {
+        return null;
+    }
+    
+}
\ No newline at end of file
diff --git a/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/DynamicDuoOIDCIntegration.java b/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/DynamicDuoOIDCIntegration.java
index 3b013f5a..44d02758 100644
--- a/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/DynamicDuoOIDCIntegration.java
+++ b/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/DynamicDuoOIDCIntegration.java
@@ -53,7 +53,6 @@ public interface DynamicDuoOIDCIntegration extends DuoOIDCIntegration{
      */
     boolean isRedirectURIPreregistered();
     
-    
     /**
      * <p>Set the redirectURI from the one given in a thread-safe way.</p>
      * 
diff --git a/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/SimpleDuoOIDCIntegration.java b/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/SimpleDuoOIDCIntegration.java
index 29067330..c6d80a8a 100644
--- a/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/SimpleDuoOIDCIntegration.java
+++ b/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/SimpleDuoOIDCIntegration.java
@@ -36,12 +36,19 @@ import net.shibboleth.shared.primitive.StringSupport;
 
 
 /**
- * A data wrapper for use with Duo OIDC integrations which does not support redirectURI generation. 
+ * A data wrapper for use with Duo OIDC integrations which does not support redirectURI generation.
+ * 
+ * <p>This class was replaced by the {@link DefaultDuoOIDCIntegration} subclass.</p>
+ * 
+ * @deprecated
  */
 @ThreadSafe
-public final class SimpleDuoOIDCIntegration 
-            extends AbstractInitializableComponent implements DuoOIDCIntegration{
+ at Deprecated(forRemoval=true, since="2.1.0")
+public final class SimpleDuoOIDCIntegration extends AbstractInitializableComponent implements DuoOIDCIntegration {
         
+    /** Passwordless indicator. */
+    @GuardedBy("this") private boolean passwordless;
+    
     /** API host. */
     @GuardedBy("this") @NonnullAfterInit @NotEmpty private String apiHost;
     
@@ -71,6 +78,26 @@ public final class SimpleDuoOIDCIntegration
         supportedPrincipals = new Subject();
     }
 
+    /**
+     * Sets whether this integration is suitable for use as a single factor.
+     * 
+     * <p>Defaults to false.</p>
+     * 
+     * @param flag flag to set
+     * 
+     * @since 2.1.0
+     */
+    public synchronized void setPasswordless(final boolean flag) {
+        checkSetterPreconditions();
+        passwordless = flag;
+    }
+    
+    /** {@inheritDoc} */
+    public synchronized boolean isPasswordless() {
+        checkComponentActive();
+        return passwordless;
+    }
+    
     /** {@inheritDoc} */
     @Nonnull @NotEmpty public synchronized String getAPIHost() {
         checkComponentActive();
@@ -84,8 +111,7 @@ public final class SimpleDuoOIDCIntegration
      * @param host API host
      */
     public synchronized void setAPIHost(@Nonnull @NotEmpty final String host) {
-        ifInitializedThrowUnmodifiabledComponentException();
-        ifDestroyedThrowDestroyedComponentException();
+        checkSetterPreconditions();
         
         apiHost = Constraint.isNotNull(StringSupport.trimOrNull(host), "API host cannot be null or empty");
     }
@@ -103,8 +129,7 @@ public final class SimpleDuoOIDCIntegration
      * @param endpoint the endpoint.
      */
     public synchronized void setHealthCheckEndpoint(@Nonnull @NotEmpty final String endpoint) {
-        ifInitializedThrowUnmodifiabledComponentException();
-        ifDestroyedThrowDestroyedComponentException();
+        checkSetterPreconditions();
         
         healthEndpoint = Constraint.isNotNull(StringSupport.trimOrNull(endpoint), 
                 "Health check endpoint cannot be null or empty");
@@ -123,8 +148,7 @@ public final class SimpleDuoOIDCIntegration
      * @param endpoint the endpoint.
      */
     public synchronized void setAuthorizeEndpoint(@Nonnull @NotEmpty final String endpoint) {
-        ifInitializedThrowUnmodifiabledComponentException();
-        ifDestroyedThrowDestroyedComponentException();
+        checkSetterPreconditions();
         
         authorizeEndpoint = Constraint.isNotNull(StringSupport.trimOrNull(endpoint), 
                 "Authorize endpoint cannot be null or empty");
@@ -143,8 +167,7 @@ public final class SimpleDuoOIDCIntegration
      * @param endpoint the endpoint.
      */
     public synchronized void setTokenEndpoint(@Nonnull @NotEmpty final String endpoint) {
-        ifInitializedThrowUnmodifiabledComponentException();
-        ifDestroyedThrowDestroyedComponentException();
+        checkSetterPreconditions();
         
         tokenEndpoint = Constraint.isNotNull(StringSupport.trimOrNull(endpoint), 
                 "Token endpoint cannot be null or empty");
@@ -161,8 +184,7 @@ public final class SimpleDuoOIDCIntegration
      * @param url the url.
      */
     public synchronized void setRedirectURI(@Nonnull final String url) {
-        ifInitializedThrowUnmodifiabledComponentException();
-        ifDestroyedThrowDestroyedComponentException();
+        checkSetterPreconditions();
         
         redirectURI = Constraint.isNotNull(StringSupport.trimOrNull(url), "Redirect URI cannot be null or empty");;
     }
@@ -174,8 +196,7 @@ public final class SimpleDuoOIDCIntegration
      * @param id the client identifier.
      */
     public synchronized void setClientId(@Nonnull @NotEmpty final String id) {
-        ifInitializedThrowUnmodifiabledComponentException();
-        ifDestroyedThrowDestroyedComponentException();
+        checkSetterPreconditions();
         
         clientId = Constraint.isNotNull(StringSupport.trimOrNull(id), "ClientID cannot be null or empty");
     }
@@ -193,8 +214,7 @@ public final class SimpleDuoOIDCIntegration
      * @param key secret key
      */
     public synchronized void setSecretKey(@Nonnull @NotEmpty final String key) {
-        ifInitializedThrowUnmodifiabledComponentException();
-        ifDestroyedThrowDestroyedComponentException();
+        checkSetterPreconditions();
         
         secretKey = Constraint.isNotNull(StringSupport.trimOrNull(key), "Secret key cannot be null or empty");
     }
@@ -225,8 +245,7 @@ public final class SimpleDuoOIDCIntegration
      */
     public synchronized <T extends Principal> void setSupportedPrincipals(
             @Nullable @NonnullElements final Collection<T> principals) {
-        ifInitializedThrowUnmodifiabledComponentException();
-        ifDestroyedThrowDestroyedComponentException();
+        checkSetterPreconditions();
         
         supportedPrincipals.getPrincipals().clear();
         
@@ -238,9 +257,9 @@ public final class SimpleDuoOIDCIntegration
     /** {@inheritDoc} */
     @Override
     protected void doInitialize() throws ComponentInitializationException {
-        if (getAPIHost() == null || getClientId() == null || getSecretKey() == null 
-                ||  getHealthCheckEndpoint() == null || getAuthorizeEndpoint() == null
-                || getTokenEndpoint() == null || getRedirectURI() == null) {
+        if (apiHost == null || clientId == null || secretKey == null 
+                ||  healthEndpoint == null || authorizeEndpoint == null
+                || tokenEndpoint == null || redirectURI == null) {
             throw new ComponentInitializationException("API host, clientId, secret key,"
                     + "token endpoint, health check endpoint, authorization endpoint, and "
                     + "redirectURI must be set");
diff --git a/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/context/DuoPasswordlessContext.java b/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/context/DuoPasswordlessContext.java
new file mode 100644
index 00000000..26627774
--- /dev/null
+++ b/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/context/DuoPasswordlessContext.java
@@ -0,0 +1,93 @@
+/*
+ * 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.context;
+
+import java.util.Objects;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.opensaml.messaging.context.BaseContext;
+
+import net.shibboleth.idp.authn.context.AuthenticationContext;
+
+/**
+ * Context that tracks username and signals usage of Duo in a passwordless mode.
+ * 
+ * <p>This is used for a more specialized use of the Duo service as a single factor.
+ * The presence of the context acts as a signal of this behavior, and the username
+ * is tracked here since it is typicall set by calling code or collected from a view.</p>
+ * 
+ * @parent {@link AuthenticationContext}
+ * @added By configuration to signal username collection and enrollment checking
+ * 
+ * @since 2.1.0
+ */
+public final class DuoPasswordlessContext extends BaseContext {
+
+    /** Username. */
+    @Nullable private String username;
+    
+    /** Whether user has appropriate devices enrolled. */
+    boolean enrolled;
+    
+    /**
+     * Get the username.
+     * 
+     * @return username
+     */
+    @Nullable public String getUsername() {
+        return username;
+    }
+    
+    /**
+     * Set the username.
+     * 
+     * <p>When changing the context's existing value, the context will clear the
+     * {@link #isEnrolled()} setting to false.</p>
+     * 
+     * @param name username
+     * 
+     * @return this context
+     */
+    @Nonnull public DuoPasswordlessContext setUsername(@Nullable final String name) {
+        if (!Objects.equals(name, username)) {
+            username = name;
+            enrolled = false;
+        }
+        return this;
+    }
+    
+    /**
+     * Gets whether the user is determined to have appropriate devices enrolled.
+     * 
+     * @return whether the user is determined to have appropriate devices enrolled
+     */
+    public boolean isEnrolled() {
+        return enrolled;
+    }
+    
+    /**
+     * Sets whether the user is determined to have appropriate devices enrolled.
+     * 
+     * @param flag flag to set
+     * 
+     * @return this context
+     */
+    @Nonnull public DuoPasswordlessContext setEnrolled(final boolean flag) {
+        enrolled = flag;
+        return this;
+    }
+}
\ No newline at end of file
diff --git a/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/impl/CheckPasswordlessEnrollment.java b/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/impl/CheckPasswordlessEnrollment.java
new file mode 100644
index 00000000..84ebb0b4
--- /dev/null
+++ b/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/impl/CheckPasswordlessEnrollment.java
@@ -0,0 +1,362 @@
+/*
+ * 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.util.function.BiPredicate;
+import java.util.function.Function;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.opensaml.messaging.context.navigate.ChildContextLookup;
+import org.opensaml.profile.action.ActionSupport;
+import org.opensaml.profile.action.EventIds;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.slf4j.Logger;
+
+import jakarta.servlet.http.HttpServletRequest;
+import net.shibboleth.idp.authn.AbstractExtractionAction;
+import net.shibboleth.idp.authn.AuthnEventIds;
+import net.shibboleth.idp.authn.context.AuthenticationContext;
+import net.shibboleth.idp.plugin.authn.duo.context.DuoPasswordlessContext;
+import net.shibboleth.idp.session.IdPSession;
+import net.shibboleth.idp.session.context.SessionContext;
+import net.shibboleth.shared.annotation.constraint.NonnullBeforeExec;
+import net.shibboleth.shared.annotation.constraint.NotEmpty;
+import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.net.CookieManager;
+import net.shibboleth.shared.net.URISupport;
+import net.shibboleth.shared.primitive.LoggerFactory;
+import net.shibboleth.shared.primitive.StringSupport;
+import net.shibboleth.shared.security.DataSealer;
+import net.shibboleth.shared.security.DataSealerException;
+
+/**
+ * An action to populate a username into a {@link DuoPasswordlessContext}, either from a form
+ * submission, a cookie, or an existing session, and perform a check for suitability for
+ * that user to conduct passwordless use of Duo, typically based on device enrollments.
+ * 
+ * <p>If no username is found, then {@link AuthnEventIds#UNKNOWN_USERNAME} is signaled.</p>
+ * 
+ * <p>If the username in the context is "changed" from its existing state, then the condition
+ * for passwordless usage is executed and the result stored into the context. A failed check
+ * results in the @event {@link AuthnEventIds#REQUEST_UNSUPPORTED} event.</p>
+ * 
+ * <p>The action also processes a signal to avoid caching the result of authentication (and also
+ * avoid caching the username entered) for shared machines.</p>
+ * 
+ * @event {@link EventIds#PROCEED_EVENT_ID}
+ * @event {@link EventIds#INVALID_PROFILE_CTX}
+ * @event {@link AuthnEventIds#UNKNOWN_USERNAME}
+ * @event {@link AuthnEventIds#REQUEST_UNSUPPORTED}
+ * @post {@link DuoPasswordlessContext#setUsername(String)} is called with an existing value if found
+ *  and {@link DuoPasswordlessContext#setEnrolled(boolean)} is called with the relevant value 
+ * 
+ * @since 2.1.0
+ */
+public class CheckPasswordlessEnrollment extends AbstractExtractionAction {
+    
+    /** Class logger. */
+    @Nonnull private final Logger log = LoggerFactory.getLogger(CheckPasswordlessEnrollment.class);
+    
+    /** Strategy used to locate the {@link DuoPasswordlessContext} to operate on. */
+    @Nonnull private Function<ProfileRequestContext,DuoPasswordlessContext> duoPasswordlessContextLookupStrategy;
+    
+    /** Condition indicating passwordless use is valid. */
+    @Nonnull private BiPredicate<ProfileRequestContext,String> passwordlessCondition;
+
+    /** Form parameter name to carry username. */
+    @Nonnull @NotEmpty private String usernameFieldName;
+
+    /** Parameter name for SSO bypass. */
+    @Nonnull @NotEmpty private String ssoBypassFieldName;
+
+    /** Username cookie name. */
+    @Nullable @NotEmpty private String cookieName;
+    
+    /** Optional cookie manager to use. */
+    @Nullable private CookieManager cookieManager;
+
+    /** Optional data sealer to use. */
+    @Nullable private DataSealer dataSealer;
+    
+    /** Whether to pull username from existing session or not. */
+    private boolean checkSession;
+    
+    /** Context to operate on. */
+    @NonnullBeforeExec private DuoPasswordlessContext passwordlessContext;
+    
+    /** Constructor.*/
+    public CheckPasswordlessEnrollment() {
+        duoPasswordlessContextLookupStrategy =
+                new ChildContextLookup<>(DuoPasswordlessContext.class).compose(
+                        new ChildContextLookup<>(AuthenticationContext.class));
+        
+        // TODO: BiPredicateSupport.alwaysTrue once API is bumped.
+        passwordlessCondition = (a,b) -> {
+            return true;
+            };
+            
+        usernameFieldName = "j_username";
+        ssoBypassFieldName = "donotcache";
+    }
+    
+    /**
+     * Set the strategy used to locate the {@link DuoPasswordlessContext} to operate on.
+     * 
+     * @param strategy lookup strategy
+     */
+    public void setDuoContextCreationStrategy(
+            @Nonnull final Function<ProfileRequestContext,DuoPasswordlessContext> strategy) {
+        checkSetterPreconditions();
+
+        duoPasswordlessContextLookupStrategy =
+                Constraint.isNotNull(strategy, "DuoPasswordlessContext lookup strategy cannot be null");
+    }
+    
+    /**
+     * Set a {@link BiPredicate} to run to determine whether to proceed with passwordless Duo.
+     * 
+     * @param condition condition to set
+     */
+    public void setPasswordlessCondition(@Nonnull final BiPredicate<ProfileRequestContext,String> condition) {
+        checkSetterPreconditions();
+        
+        passwordlessCondition = Constraint.isNotNull(condition, "Condition cannot be null");
+    }
+    
+    /**
+     * Sets the name of the form field to carry the username.
+     * 
+     * @param name field name
+     */
+    public void setUsernameFieldName(@Nonnull final String name) {
+        checkSetterPreconditions();
+        
+        usernameFieldName = Constraint.isNotNull(StringSupport.trimOrNull(name) ,
+                "Username form field name cannot be null or empty");
+    }
+
+    /**
+     * Set the SSO bypass parameter name.
+     * 
+     * @param fieldName the SSO bypass parameter name
+     */
+    public void setSSOBypassFieldName(@Nonnull @NotEmpty final String fieldName) {
+        checkSetterPreconditions();
+        
+        ssoBypassFieldName = Constraint.isNotNull(
+                StringSupport.trimOrNull(fieldName), "SSO Bypass field name cannot be null or empty.");
+    }
+    
+    /**
+     * Set cookie name to use for cached username.
+     * 
+     * @param name cookie name
+     */
+    public void setCookieName(@Nullable final String name) {
+        checkSetterPreconditions();
+
+        cookieName = StringSupport.trimOrNull(name);
+    }
+
+    /**
+     * Sets optional {@link CookieManager} to use.
+     * 
+     * @param manager cookie manager
+     */
+    public void setCookieManager(@Nullable final CookieManager manager) {
+        checkSetterPreconditions();
+        
+        cookieManager = manager;
+    }
+
+    /**
+     * Sets optional {@link DataSealer} to use.
+     * 
+     * @param sealer data sealer
+     */
+    public void setDataSealer(@Nullable final DataSealer sealer) {
+        checkSetterPreconditions();
+        
+        dataSealer = sealer;
+    }
+    
+    /**
+     * Sets whether tp pull username from existing session as a fallback.
+     * 
+     * <p>Defaults to false.</p>
+     * 
+     * @param flag flag to set
+     */
+    public void setCheckSession(final boolean flag) {
+        checkSetterPreconditions();
+
+        checkSession = flag;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected boolean doPreExecute(@Nonnull final ProfileRequestContext profileRequestContext,
+            @Nonnull final AuthenticationContext authenticationContext) {
+        
+        if (!super.doPreExecute(profileRequestContext, authenticationContext)) {
+            return false;
+        }
+        
+        passwordlessContext = duoPasswordlessContextLookupStrategy.apply(profileRequestContext);
+        if (passwordlessContext == null) {
+            log.debug("{} No DuoPasswordlessContext found, nothing to do", getLogPrefix());
+            return false;
+        }
+        return true;
+    }
+
+// Checkstyle: CyclomaticComplexity OFF
+    /** {@inheritDoc} */
+    @Override
+    protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext,
+            @Nonnull final AuthenticationContext authenticationContext) {
+        
+        boolean usernameChanged = false;
+        
+        String username = getUsernameFromForm(authenticationContext);
+        if (username != null) {
+            if (!username.equals(passwordlessContext.getUsername()) ) {
+                log.debug("{} Populating username '{}' from form submission into Duo passwordless context",
+                        getLogPrefix(), username);
+                passwordlessContext.setUsername(username);
+                usernameChanged = true;
+            }
+        } else {
+            username = getUsernameFromCookie(profileRequestContext);
+            if (username != null) {
+                if (!username.equals(passwordlessContext.getUsername()) ) {
+                    log.debug("{} Populating cached username '{}' from cookie into Duo passwordless context",
+                            getLogPrefix(), username);
+                    passwordlessContext.setUsername(username);
+                    usernameChanged = true;
+                }
+            } else {
+                username = getUsernameFromSession(profileRequestContext, authenticationContext);
+                if (username != null && !username.equals(passwordlessContext.getUsername())) {
+                    log.debug("{} Populating username '{}' from session into Duo passwordless context", getLogPrefix(),
+                            username);
+                    passwordlessContext.setUsername(username);
+                    usernameChanged = true;
+                }
+            }
+        }
+
+        if (passwordlessContext.getUsername() == null) {
+            passwordlessContext.setEnrolled(false);
+            ActionSupport.buildEvent(profileRequestContext, AuthnEventIds.UNKNOWN_USERNAME);
+            return;
+        }
+        
+        if (usernameChanged) {
+            passwordlessContext.setEnrolled(
+                    passwordlessCondition.test(profileRequestContext, passwordlessContext.getUsername()));
+            log.debug("{} Username '{}' found to be {} of passwordless attempt", getLogPrefix(),
+                    passwordlessContext.getUsername(), passwordlessContext.isEnrolled() ? "capable" : "incapable");
+        } else {
+            log.debug("{} Username not available, leaving DuoPasswordlessContext unchanged", getLogPrefix());
+        }
+        
+        if (!passwordlessContext.isEnrolled()) {
+            ActionSupport.buildEvent(profileRequestContext, AuthnEventIds.REQUEST_UNSUPPORTED);
+        }
+    }
+// Checkstyle: CyclomaticComplexity ON
+    
+    /**
+     * Gets the username from a form submission.
+     * 
+     * <p>Also processes do-not-cache instruction.</p>
+     * 
+     * @param authenticationContext authentication context
+     * 
+     * @return submitted username, after applying any configured transforms
+     */
+    @Nullable private String getUsernameFromForm(@Nonnull final AuthenticationContext authenticationContext) {
+        
+        final HttpServletRequest request = getHttpServletRequest();
+        if (request != null) {
+            final String donotcache = request.getParameter(ssoBypassFieldName);
+            if (donotcache != null && "1".equals(donotcache)) {
+                log.debug("{} Recording do-not-cache instruction in authentication context", getLogPrefix());
+                authenticationContext.setResultCacheable(false);
+            } else {
+                authenticationContext.setResultCacheable(true);
+            }
+
+            return applyTransforms(request.getParameter(usernameFieldName));
+        }
+        
+        return null;
+    }
+    
+    /**
+     * Gets the username from an existing sealed cookie, if any.
+     * 
+     * @param profileRequestContext profile request context
+     * 
+     * @return username from existing sealed cookie, or null
+     */
+    @Nullable private String getUsernameFromCookie(@Nonnull final ProfileRequestContext profileRequestContext) {
+        
+        if (cookieManager != null && dataSealer != null && cookieName != null) {
+            final String cookie = URISupport.doURLDecode(cookieManager.getCookieValue(cookieName, null));
+            if (cookie != null) {
+                try {
+                    assert dataSealer != null;
+                    return dataSealer.unwrap(cookie);
+                } catch (final DataSealerException e) {
+                    log.warn("{} Unable to unwrap sealed username cookie", getLogPrefix(), e);
+                    assert cookieName != null;
+                    assert cookieManager != null;
+                    cookieManager.unsetCookie(cookieName);
+                }
+            }
+        }
+        
+        return null;
+    }
+    
+    /**
+     * Gets the username from an existing {@link IdPSession}, if any.
+     * 
+     * @param profileRequestContext profile request context
+     * @param authenticationContext authentication context
+     * 
+     * @return username from existing session, or null
+     */
+    @Nullable private String getUsernameFromSession(@Nonnull final ProfileRequestContext profileRequestContext,
+            @Nonnull final AuthenticationContext authenticationContext) {
+        
+        if (checkSession && !authenticationContext.getActiveResults().isEmpty()) {
+            final SessionContext sessionContext = profileRequestContext.getSubcontext(SessionContext.class);
+            if (sessionContext != null) {
+                final IdPSession idpSession = sessionContext.getIdPSession();
+                if (idpSession != null) {
+                    return idpSession.getPrincipalName();
+                }
+            }
+        }
+        
+        return null;
+    }
+
+}
\ No newline at end of file
diff --git a/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/impl/PopulateDuoAuthenticationContext.java b/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/impl/PopulateDuoAuthenticationContext.java
index 4447da1f..e7651698 100644
--- a/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/impl/PopulateDuoAuthenticationContext.java
+++ b/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/impl/PopulateDuoAuthenticationContext.java
@@ -36,6 +36,7 @@ import net.shibboleth.idp.plugin.authn.duo.DuoOIDCClientRegistry;
 import net.shibboleth.idp.plugin.authn.duo.DuoOIDCIntegration;
 import net.shibboleth.idp.plugin.authn.duo.DynamicDuoOIDCIntegration;
 import net.shibboleth.idp.plugin.authn.duo.context.DuoOIDCAuthenticationContext;
+import net.shibboleth.idp.plugin.authn.duo.context.DuoPasswordlessContext;
 import net.shibboleth.idp.session.context.navigate.CanonicalUsernameLookupStrategy;
 import net.shibboleth.shared.annotation.constraint.NonnullAfterInit;
 import net.shibboleth.shared.component.ComponentInitializationException;
@@ -46,7 +47,10 @@ import net.shibboleth.shared.primitive.LoggerFactory;
 
 /**
  * An action to create (or lookup) and populate the {@link DuoOIDCAuthenticationContext} 
- * with the username, chosen {@link DuoOIDCIntegration}, and {@link DuoOIDCClient} appropriate for this request. 
+ * with the username, chosen {@link DuoOIDCIntegration}, and {@link DuoOIDCClient} appropriate for this request.
+ * 
+ * <p>Operates in 2 modes, one for passwordless (indicated by presence of a {@link DuoPasswordlessContext}, or
+ * a standard mode. The difference is in how the username and integration to use are derived.</p>
  * 
  * <p>Determines the usable redirect_uri, either from one registered, or computed from the
  * HTTP request. Is set once, before the client is constructed, for every client. If however, the client supports 
@@ -54,10 +58,10 @@ import net.shibboleth.shared.primitive.LoggerFactory;
  * 
  * <p>Adds the nonce part of the state parameter for matching on callback from the 2FA check.</p>
  * 
- * @event {@link org.opensaml.profile.action.EventIds#PROCEED_EVENT_ID}
- * @event {@link org.opensaml.profile.action.EventIds#INVALID_PROFILE_CTX}
- * @event {@link net.shibboleth.idp.authn.AuthnEventIds#NO_CREDENTIALS}
- * @event {@link net.shibboleth.idp.authn.AuthnEventIds#AUTHN_EXCEPTION}
+ * @event {@link EventIds#PROCEED_EVENT_ID}
+ * @event {@link EventIds#INVALID_PROFILE_CTX}
+ * @event {@link AuthnEventIds#NO_CREDENTIALS}
+ * @event {@link AuthnEventIds#AUTHN_EXCEPTION}
  * @post See above.
  */
 public class PopulateDuoAuthenticationContext extends AbstractAuthenticationAction {
@@ -68,28 +72,38 @@ public class PopulateDuoAuthenticationContext extends AbstractAuthenticationActi
     /** Strategy used to locate or create the {@link DuoOIDCAuthenticationContext} to populate. */
     @Nonnull private Function<ProfileRequestContext,DuoOIDCAuthenticationContext> duoAuthContextCreationStrategy;
 
+    /** Strategy used to locate a {@link DuoPasswordlessContext} if present. */
+    @Nonnull private Function<ProfileRequestContext,DuoPasswordlessContext> passwordlessContextLookupStrategy;
+
     /** Lookup strategy for username to match against Duo identity. */
     @Nonnull private Function<ProfileRequestContext, String> usernameLookupStrategy;
 
     /** Lookup strategy for Duo integration. */
-    @Nonnull private Function<ProfileRequestContext, DuoOIDCIntegration> duoIntegrationLookupStrategy;
+    @Nonnull private Function<ProfileRequestContext, DuoOIDCIntegration> standardDuoIntegrationLookupStrategy;
+
+    /** Lookup strategy for Duo integration for passwordless use. */
+    @Nonnull private Function<ProfileRequestContext, DuoOIDCIntegration> passwordlessDuoIntegrationLookupStrategy;
     
     /** Strategy used to compute the redirectURI from the given Duo integration if supported.*/
-    @Nullable
-    private BiFunction<HttpServletRequest, DynamicDuoOIDCIntegration, String> redirectURICreationStrategy;
+    @Nullable private BiFunction<HttpServletRequest, DynamicDuoOIDCIntegration, String> redirectURICreationStrategy;
     
     /** The registry for locating the DuoClient for the established integration.*/
     @NonnullAfterInit private DuoOIDCClientRegistry clientRegistry;
-
+    
     /** Constructor.*/
     public PopulateDuoAuthenticationContext() {
         //default creates duo authentication context under authentication context.
         duoAuthContextCreationStrategy =
-                new ChildContextLookup<>(DuoOIDCAuthenticationContext.class, true).
-                compose(new ChildContextLookup<>(AuthenticationContext.class));
+                new ChildContextLookup<>(DuoOIDCAuthenticationContext.class, true).compose(
+                        new ChildContextLookup<>(AuthenticationContext.class));
+        
+        passwordlessContextLookupStrategy =
+                new ChildContextLookup<>(DuoPasswordlessContext.class).compose(
+                        new ChildContextLookup<>(AuthenticationContext.class));
         
         usernameLookupStrategy = new CanonicalUsernameLookupStrategy();
-        duoIntegrationLookupStrategy = FunctionSupport.constant(null);
+        standardDuoIntegrationLookupStrategy = FunctionSupport.constant(null);
+        passwordlessDuoIntegrationLookupStrategy = FunctionSupport.constant(null);
     }
     
     /**
@@ -98,8 +112,7 @@ public class PopulateDuoAuthenticationContext extends AbstractAuthenticationActi
      * @param duoRegistry the registry
      */
     public void setClientRegistry(@Nonnull final DuoOIDCClientRegistry duoRegistry) { 
-        ifInitializedThrowUnmodifiabledComponentException();
-        ifDestroyedThrowDestroyedComponentException();
+        checkSetterPreconditions();
         
         clientRegistry = Constraint.isNotNull(duoRegistry,"DuoClient registry can not be null");
     }
@@ -111,8 +124,7 @@ public class PopulateDuoAuthenticationContext extends AbstractAuthenticationActi
      */
     public void setUsernameLookupStrategy(
             @Nonnull final Function<ProfileRequestContext, String> strategy) {
-        ifInitializedThrowUnmodifiabledComponentException();
-        ifDestroyedThrowDestroyedComponentException();
+        checkSetterPreconditions();
 
         usernameLookupStrategy = Constraint.isNotNull(strategy, "Username lookup strategy cannot be null");
     }
@@ -125,40 +137,67 @@ public class PopulateDuoAuthenticationContext extends AbstractAuthenticationActi
      */
     public void setRedirectURICreationStrategy(
             @Nonnull final BiFunction<HttpServletRequest, DynamicDuoOIDCIntegration, String> strategy) {
-        ifInitializedThrowUnmodifiabledComponentException();
-        ifDestroyedThrowDestroyedComponentException();
+        checkSetterPreconditions();
         
         redirectURICreationStrategy =  Constraint.isNotNull(strategy, "RedirectURI"
                 + " creation strategy cannot be null");
     }
     
     /**
-     * Set the strategy used to locate the {@link DuoOIDCAuthenticationContext} to operate on.
+     * Set the strategy used to create/locate the {@link DuoOIDCAuthenticationContext} to operate on.
      * 
-     * @param strategy lookup strategy
+     * @param strategy creation strategy
      */
     public void setDuoContextCreationStrategy(
             @Nonnull final Function<ProfileRequestContext,DuoOIDCAuthenticationContext> strategy) {
-        ifInitializedThrowUnmodifiabledComponentException();
-        ifDestroyedThrowDestroyedComponentException();
+        checkSetterPreconditions();
 
         duoAuthContextCreationStrategy = Constraint.isNotNull(strategy, "DuoAuthenticationContext"
                 + " creation strategy cannot be null");
     }
+    
+    /**
+     * Set the strategy used to locate the {@link DuoPasswordlessContext} to operate on.
+     * 
+     * @param strategy lookup strategy
+     * 
+     * @since 2.1.0
+     */
+    public void setPasswordlessContextLookupStrategy(
+            @Nonnull final Function<ProfileRequestContext,DuoPasswordlessContext> strategy) {
+        checkSetterPreconditions();
 
+        passwordlessContextLookupStrategy = Constraint.isNotNull(strategy, "DuoPasswordlessContext"
+                + " lookup strategy cannot be null");
+    }
+    
     /**
-     * Set DuoIntegration lookup strategy to use.
+     * Set standard DuoIntegration lookup strategy to use.
      * 
      * @param strategy lookup strategy
      */
-    public void setDuoIntegrationLookupStrategy(
+    public void setStandardDuoIntegrationLookupStrategy(
             @Nonnull final Function<ProfileRequestContext, DuoOIDCIntegration> strategy) {
-        ifInitializedThrowUnmodifiabledComponentException();
-        ifDestroyedThrowDestroyedComponentException();
+        checkSetterPreconditions();
 
-        duoIntegrationLookupStrategy = Constraint.isNotNull(strategy, "DuoIntegration lookup strategy cannot be null");
+        standardDuoIntegrationLookupStrategy =
+                Constraint.isNotNull(strategy, "Standard DuoIntegration lookup strategy cannot be null");
     }
-    
+
+    /**
+     * Set passwordless DuoIntegration lookup strategy to use.
+     * 
+     * @param strategy lookup strategy
+     * 
+     * @since 2.1.0
+     */
+    public void setPasswordlessDuoIntegrationLookupStrategy(
+            @Nonnull final Function<ProfileRequestContext, DuoOIDCIntegration> strategy) {
+        checkSetterPreconditions();
+        passwordlessDuoIntegrationLookupStrategy =
+                Constraint.isNotNull(strategy, "Passwordless DuoIntegration lookup strategy cannot be null");
+    }
+
     /** {@inheritDoc} */
     @Override protected void doInitialize() throws ComponentInitializationException {
         super.doInitialize();
@@ -173,62 +212,134 @@ public class PopulateDuoAuthenticationContext extends AbstractAuthenticationActi
     @Override protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext,
             @Nonnull final AuthenticationContext authenticationContext) {
 
-        
-        final DuoOIDCAuthenticationContext context = duoAuthContextCreationStrategy.apply(profileRequestContext);
-        if (context == null) {
-            log.error("{} Error creating DuoAuthenticationContext", getLogPrefix());
+        final HttpServletRequest request = getHttpServletRequest();
+        if (request == null) {
+            log.warn("{} Profile action does not contain an HttpServletRequest", getLogPrefix());
             ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_PROFILE_CTX);
             return;
         }
-        
-        final DuoOIDCIntegration duoIntegration = duoIntegrationLookupStrategy.apply(profileRequestContext);
-        if (duoIntegration == null) {
-            log.warn("{} No DuoIntegration returned by lookup strategy", getLogPrefix());
+
+        final DuoOIDCAuthenticationContext duoContext = duoAuthContextCreationStrategy.apply(profileRequestContext);
+        if (duoContext == null) {
+            log.error("{} Error creating DuoAuthenticationContext", getLogPrefix());
             ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_PROFILE_CTX);
             return;
         }
-        context.setIntegration(duoIntegration);
         
-        final HttpServletRequest request = getHttpServletRequest();
-        if (request == null) {
-            log.warn("{} Profile action does not contain an HttpServletRequest", getLogPrefix());
-            ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_PROFILE_CTX);
-            return;
-        }   
-        
-        final String username = usernameLookupStrategy.apply(profileRequestContext);
-        if (username == null) {
-            log.warn("{} No principal name available to initiate a Duo 2FA request", getLogPrefix());
-            ActionSupport.buildEvent(profileRequestContext, AuthnEventIds.NO_CREDENTIALS);
-            return;
+        final DuoPasswordlessContext passwordlessContext =
+                passwordlessContextLookupStrategy.apply(profileRequestContext);
+        if (passwordlessContext != null) {
+            if (!doPasswordless(profileRequestContext, duoContext, passwordlessContext)) {
+                duoContext.removeFromParent();
+                return;
+            }
+        } else {
+            if (!doStandard(profileRequestContext, duoContext)) {
+                duoContext.removeFromParent();
+                return;
+            }
         }
-        context.setUsername(username);
         
+        final DuoOIDCIntegration duoIntegration = duoContext.getIntegration();
+        assert duoIntegration != null;
+
         // Generate state, stash in the context for checking on return.
         final String nonce = DuoSupport.generateNonce(32);
         // Store only the nonce component as the request state. The SWF key is added by the controller
         // And included in the authorization request to Duo.
-        context.setRequestState(nonce);
-        
+        duoContext.setRequestState(nonce);
+
         try {
-            computeAndStoreRedirectURIIfSupported(duoIntegration, request, context);     
+            computeAndStoreRedirectURIIfSupported(duoIntegration, request, duoContext);     
             
             //Configure the Duo client for the established integration           
             final DuoOIDCClient client = clientRegistry.getClientOrCreate(duoIntegration);
-            context.setClient(client);
+            duoContext.setClient(client);
             
         } catch (final DuoException e) {
             log.warn("{} Unable to establish a Duo Client for the given integration", getLogPrefix(),e);
             ActionSupport.buildEvent(profileRequestContext, AuthnEventIds.AUTHN_EXCEPTION);
+            duoContext.removeFromParent();
             return;
         }
         
-        log.debug("Created Duo authentication context for '{}'",username);
+        log.debug("Created Duo authentication context for '{}'", duoContext.getUsername());
+    }
+    
+    /**
+     * Perform standard context creation and lookups.
+     * 
+     * @param profileRequestContext profile request context
+     * @param duoContext newly created Duo context
+     * @param passwordlessContext Duo passwordless context
+     * 
+     * @return true iff processing should continue
+     */
+    private boolean doPasswordless(@Nonnull final ProfileRequestContext profileRequestContext,
+            @Nonnull final DuoOIDCAuthenticationContext duoContext,
+            @Nonnull final DuoPasswordlessContext passwordlessContext) {
+
+        if (passwordlessContext.getUsername() == null) {
+            log.warn("{} No principal name available to initiate a Duo 2FA request", getLogPrefix());
+            ActionSupport.buildEvent(profileRequestContext, AuthnEventIds.NO_CREDENTIALS);
+            return false;
+        }
+        duoContext.setUsername(passwordlessContext.getUsername());
+
+        if (!passwordlessContext.isEnrolled()) {
+            log.warn("{} Context indicates user '{}' is not eligible for passwordless, proceeding anyway",
+                    getLogPrefix(), duoContext.getUsername());
+        }
+        
+        final DuoOIDCIntegration duoIntegration = passwordlessDuoIntegrationLookupStrategy.apply(profileRequestContext);
+        if (duoIntegration == null) {
+            log.warn("{} No DuoIntegration returned by lookup strategy", getLogPrefix());
+            ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_PROFILE_CTX);
+            return false;
+        } else if (!duoIntegration.isPasswordless()) {
+            log.warn("{} DuoIntegration returned by lookup strategy was not passwordless", getLogPrefix());
+            ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_PROFILE_CTX);
+            return false;
+        }
+        
+        duoContext.setIntegration(duoIntegration);
+        
+        return true;
+    }
+    
+    /**
+     * Perform standard context creation and lookups.
+     * 
+     * @param profileRequestContext profile request context
+     * @param duoContext newly created Duo context
+     * 
+     * @return true iff processing should continue
+     */
+    private boolean doStandard(@Nonnull final ProfileRequestContext profileRequestContext,
+            @Nonnull final DuoOIDCAuthenticationContext duoContext) {
+        
+        final DuoOIDCIntegration duoIntegration = standardDuoIntegrationLookupStrategy.apply(profileRequestContext);
+        if (duoIntegration == null) {
+            log.warn("{} No DuoIntegration returned by lookup strategy", getLogPrefix());
+            ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_PROFILE_CTX);
+            return false;
+        }
+        duoContext.setIntegration(duoIntegration);
+        
+        final String username = usernameLookupStrategy.apply(profileRequestContext);
+        if (username == null) {
+            log.warn("{} No principal name available to initiate a Duo 2FA request", getLogPrefix());
+            ActionSupport.buildEvent(profileRequestContext, AuthnEventIds.NO_CREDENTIALS);
+            return false;
+        }
+        duoContext.setUsername(username);
+        
+        return true;
     }
     
     /**
-     * <p>For {@link DynamicDuoOIDCIntegration DynamicDuoOIDCIntegrations}, apply the redirect_uri creation
-     * strategy to compute a redirect_uri to use.</p>
+     * For {@link DynamicDuoOIDCIntegration DynamicDuoOIDCIntegrations}, apply the redirect_uri creation
+     * strategy to compute a redirect_uri to use.
      * 
      *  <p>The redirect_uri is computed for each request, but is only set once as the usable redirect_uri
      *  on the integration itself i.e. for the client to read using {@link DuoOIDCIntegration#getRedirectURI()}.
@@ -274,4 +385,4 @@ public class PopulateDuoAuthenticationContext extends AbstractAuthenticationActi
         }
     }
     
-}
+}
\ No newline at end of file
diff --git a/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/impl/ValidateDuoTokenAuthenticationResult.java b/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/impl/ValidateDuoTokenAuthenticationResult.java
index 900c7414..e64d8826 100644
--- a/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/impl/ValidateDuoTokenAuthenticationResult.java
+++ b/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/impl/ValidateDuoTokenAuthenticationResult.java
@@ -18,7 +18,7 @@ import java.security.Principal;
 import java.text.ParseException;
 import java.util.Collection;
 import java.util.Map;
-import java.util.function.Consumer;
+import java.util.Set;
 import java.util.function.Function;
 import java.util.stream.Collectors;
 
@@ -40,6 +40,7 @@ import net.shibboleth.idp.authn.context.SubjectCanonicalizationContext;
 import net.shibboleth.idp.authn.duo.DuoFactorPrincipal;
 import net.shibboleth.idp.authn.duo.DuoPrincipal;
 import net.shibboleth.idp.authn.impl.AbstractAuditingValidationAction;
+import net.shibboleth.idp.authn.principal.UsernamePrincipal;
 import net.shibboleth.idp.plugin.authn.duo.DuoException;
 import net.shibboleth.idp.plugin.authn.duo.DuoOIDCAuthAPI;
 import net.shibboleth.idp.plugin.authn.duo.DuoOIDCIntegration;
@@ -86,6 +87,9 @@ public class ValidateDuoTokenAuthenticationResult extends AbstractAuditingValida
     /** Attempted username. */
     @NonnullBeforeExec @NotEmpty private String username;
     
+    /** Factor used. */
+    @Nullable private String factorUsed;
+    
     /** Hook to map context information (often Duo factors in the Duo token) to principal collections.*/
     @Nullable private Function<ProfileRequestContext,Collection<Principal>> contextToPrincipalMappingStrategy;
     
@@ -113,8 +117,7 @@ public class ValidateDuoTokenAuthenticationResult extends AbstractAuditingValida
      */
     public void setContextToPrincipalMappingStrategy(@Nullable final 
             Function<ProfileRequestContext,Collection<Principal>> hook) {
-        ifInitializedThrowUnmodifiabledComponentException();
-        ifDestroyedThrowDestroyedComponentException();
+        checkSetterPreconditions();
         
         contextToPrincipalMappingStrategy = hook;
     }
@@ -183,7 +186,6 @@ public class ValidateDuoTokenAuthenticationResult extends AbstractAuditingValida
     /** {@inheritDoc} */
     @Override protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext,
             @Nonnull final AuthenticationContext authenticationContext) {    
-        
                  
         Map<String, Object> authStatusObject = null;
         try {
@@ -207,11 +209,21 @@ public class ValidateDuoTokenAuthenticationResult extends AbstractAuditingValida
                 statusMsgObj instanceof final String authResultStatusMsg) {
             
             if (DuoOIDCAuthAPI.DUO_AUTH_RESULT_ALLOW.equalsIgnoreCase(authResultStatus)){
-                if (log.isInfoEnabled()) {
-                    final String factorUsed = extractFactor();
-                    log.info("{} Duo 2FA authentication succeeded for '{}', using second-factor '{}'",
-                            getLogPrefix(),duoContext.getUsername(), factorUsed != null ? factorUsed : "unspecified");
+                factorUsed = extractFactor();
+                
+                // Check if factor is allowed.
+                final Set<String> allowedFactors = duoIntegration.getAllowedFactors();
+                if (allowedFactors != null && !allowedFactors.contains(factorUsed)) {
+                    log.error("{} Duo 2FA authentication failed for '{}', factor '{}' disallowed",getLogPrefix(),
+                            username, factorUsed);
+                    handleError(profileRequestContext, authenticationContext, AuthnEventIds.INVALID_CREDENTIALS,
+                            AuthnEventIds.INVALID_CREDENTIALS);
+                    recordFailure(profileRequestContext);
+                    return;
                 }
+                
+                log.info("{} Duo 2FA authentication succeeded for '{}', using second-factor '{}'",
+                        getLogPrefix(),duoContext.getUsername(), factorUsed != null ? factorUsed : "unspecified");
                 //must build authentication before recording success. recordSuccess runs
                 //the cleanup hook which removes the Duo context and prevents useful operation
                 //of the contextToPrincipalMappingStrategy.
@@ -273,13 +285,20 @@ public class ValidateDuoTokenAuthenticationResult extends AbstractAuditingValida
     @Nonnull protected Subject populateSubject(@Nonnull final Subject subject) {
         
         // Always add the DuoPrincipal and the DuoFactorPrincipal if present
-        final String factor = extractFactor();
         final String localUsername = username;
         assert localUsername != null;
         subject.getPrincipals().add(new DuoPrincipal(localUsername));
-        if (factor != null) {
-            subject.getPrincipals().add(new DuoFactorPrincipal(factor));
-            log.trace("{} Added DuoFactorPrincipal '{}' to DuoPrincipal '{}'", getLogPrefix(), factor, localUsername);
+        
+        // Add UsernamePrincipal if passwordless.
+        if (duoIntegration.isPasswordless()) {
+            subject.getPrincipals().add(new UsernamePrincipal(localUsername));
+        }
+        
+        final String localFactor = factorUsed;
+        if (localFactor != null) {
+            subject.getPrincipals().add(new DuoFactorPrincipal(localFactor));
+            log.trace("{} Added DuoFactorPrincipal '{}' to DuoPrincipal '{}'", getLogPrefix(), localFactor,
+                    localUsername);
         }
 
         // Always add any principals specified on the integration
@@ -310,26 +329,5 @@ public class ValidateDuoTokenAuthenticationResult extends AbstractAuditingValida
          // Bypass c14n. We already operate on a canonical name, so just re-confirm it.
         profileRequestContext.ensureSubcontext(SubjectCanonicalizationContext.class).setPrincipalName(username);
     }
-    
-    /**
-     * A default cleanup hook that removes the {@link DuoOIDCAuthenticationContext} from the tree.
-     */
-    public static class DuoOIDCCleanupHook implements Consumer<ProfileRequestContext> {
-
-        /** {@inheritDoc} */
-        @Override
-        public void accept(@Nullable final ProfileRequestContext input) {
-            if (input != null) {
-                final AuthenticationContext authnCtx = input.getSubcontext(AuthenticationContext.class);
-                if (authnCtx != null) {
-                    final DuoOIDCAuthenticationContext duoCtx = 
-                            authnCtx.getSubcontext(DuoOIDCAuthenticationContext.class);
-                    if (duoCtx != null) {
-                        authnCtx.removeSubcontext(duoCtx);
-                    }
-                }
-            }
-        }
-    }
 
-}
+}
\ 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 e19fca86..c673bd5e 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
@@ -9,6 +9,35 @@
 
     default-init-method="initialize" default-destroy-method="destroy">
     
+    <bean id="messageSource" class="org.springframework.context.support.ReloadableResourceBundleMessageSource"
+        p:cacheSeconds="%{idp.message.cacheSeconds:300}"
+        p:basenames="classpath:/net/shibboleth/idp/plugin/authn/duo/messages"
+        p:defaultEncoding="UTF-8" />
+    
+    <!-- Used in views to calculate CSP hashes and nonces, remove and adjust beans in flow when compatibility bumped past 5.0 -->
+    
+    <bean id="DuoCSPDigester" class="net.shibboleth.shared.codec.StringDigester"
+        c:algorithm="SHA256" c:format="BASE64" />
+
+    <bean id="DuoCSPNonce" destroy-method=""
+            class="net.shibboleth.shared.security.IdentifierGenerationStrategy" factory-method="getInstance">
+        <constructor-arg>
+            <util:constant
+                static-field="net.shibboleth.shared.security.IdentifierGenerationStrategy.ProviderType.SECURE" />
+        </constructor-arg>
+        <constructor-arg>
+            <bean class="net.shibboleth.shared.security.RandomIdentifierParameterSpec"
+                    c:identifierSize="16">
+                <constructor-arg name="source">
+                    <null/>
+                </constructor-arg>
+                <constructor-arg name="identifierEncoder">
+                    <null/>
+                </constructor-arg>
+            </bean>
+        </constructor-arg>
+    </bean>
+    
     <!-- location of the Duo OIDC IdP servlet -->
     <bean id="shibboleth.authn.DuoOIDC.externalServletPath" class="java.lang.String"
         c:_0="%{idp.duo.oidc.externalAuthnPath:/Authn/Duo/2FA}" />
@@ -17,14 +46,13 @@
     <bean id="shibboleth.authn.DuoOIDC.externalAuthorizationPath" class="java.lang.String"
         c:_0="servletRelative:#{getObject('shibboleth.authn.DuoOIDC.externalServletPath')}#{T(net.shibboleth.idp.plugin.authn.duo.DuoOIDCAuthAPI).AUTHORIZE_PATH_SEGMENT}" />
 
-
     <!-- Default Duo Integration -->
-    <bean id="shibboleth.authn.DuoOIDC.DuoIntegration"
+    <bean id="shibboleth.authn.DuoOIDC.DuoIntegration" lazy-init="false"
         class="net.shibboleth.idp.plugin.authn.duo.DefaultDuoOIDCIntegration"
         p:APIHost="%{idp.duo.oidc.apiHost:none}"
         p:clientId="%{idp.duo.oidc.clientId:none}"
         p:secretKey="%{idp.duo.oidc.secretKey:none}"
-        p:registeredRedirectURI="%{idp.duo.oidc.redirectURL:}" 
+        p:registeredRedirectURI="%{idp.duo.oidc.redirectURL:}"
         p:healthCheckEndpoint="%{idp.duo.oidc.endpoint.health:/oauth/v1/health_check}"
         p:tokenEndpoint="%{idp.duo.oidc.endpoint.token:/oauth/v1/token}"
         p:authorizeEndpoint="%{idp.duo.oidc.endpoint.authorize:/oauth/v1/authorize}"
@@ -32,8 +60,24 @@
     <bean id="shibboleth.authn.DuoOIDC.DuoIntegrationStrategy" parent="shibboleth.Functions.Constant"
         c:target-ref="shibboleth.authn.DuoOIDC.DuoIntegration" />
 
+    <!-- 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: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:}}"
+        p:allowedFactors="#{{ 'Platform authenticator (2fa)', 'Roaming authenticator (2fa)' }}" />
+    <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" 
+    <bean id="shibboleth.authn.DuoOIDC.NonBrowser.DuoIntegration" lazy-init="false"
         class="net.shibboleth.idp.authn.duo.BasicDuoIntegration"
         p:APIHost="%{idp.duo.oidc.nonbrowser.apiHost:%{idp.duo.oidc.apiHost:none}}"
         p:integrationKey="%{idp.duo.oidc.nonbrowser.integrationKey:none}"
@@ -46,9 +90,9 @@
         class="net.shibboleth.idp.session.context.navigate.CanonicalUsernameLookupStrategy" />
 
     <!-- Duo Client factory and bean registry -->
-    <bean id="shibboleth.authn.DuoOIDC.clientRegistry" scope="singleton" 
-        class="net.shibboleth.idp.plugin.authn.duo.impl.DefaultDuoOIDCClientRegistry"        
-         p:clientFactory-ref="%{idp.duo.oidc.clientFactoryBean:shibboleth.authn.DuoOIDC.clientFactory}"/>
+    <bean id="shibboleth.authn.DuoOIDC.clientRegistry" scope="singleton"
+        class="net.shibboleth.idp.plugin.authn.duo.impl.DefaultDuoOIDCClientRegistry"
+        p:clientFactory-ref="%{idp.duo.oidc.clientFactoryBean:shibboleth.authn.DuoOIDC.clientFactory}"/>
 
     <!-- 
         Load all (or none) factory bean definitions from the classpath. The defaulted factory bean must be called
@@ -64,9 +108,7 @@
     to change the location of the user config file. -->
     <import resource="conditional:%{idp.home}/conf/authn/%{idp.duo.oidc.user.config:duo-oidc-authn-config.xml}" />
 
-    <!--
-    Non-Browser actions and beans. Code lives in idp-authn-impl for now, may migrate here later.
-    -->
+    <!-- Non-Browser actions and beans -->
     <bean id="ExtractDuoAuthenticationFromHeaders" scope="prototype"
         class="net.shibboleth.idp.plugin.authn.duo.impl.ExtractDuoAuthenticationFromHeaders"
         p:httpServletRequestSupplier-ref="shibboleth.HttpServletRequestSupplier"
@@ -99,11 +141,29 @@
         p:classifiedMessages="#{getObject('shibboleth.authn.DuoOIDC.ClassifiedMessageMap')}"
         p:resultCachingPredicate="#{getObject('shibboleth.authn.DuoOIDC.resultCachingPredicate')}" />
 
-    <!-- Duo OIDC AuthAPI beans -->
+    <!-- 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: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.DuoOIDC.usernameFieldName:j_username}'.trim()}"
+        p:checkSession="%{idp.authn.usernameFromSession:false}"
+        p:lowercase="%{idp.authn.DuoOIDC.lowercase:false}"
+        p:uppercase="%{idp.authn.DuoOIDC.uppercase:false}"
+        p:trim="%{idp.authn.Password.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"
         p:httpServletRequestSupplier-ref="shibboleth.HttpServletRequestSupplier"
-        p:duoIntegrationLookupStrategy-ref="shibboleth.authn.DuoOIDC.DuoIntegrationStrategy"
+        p:standardDuoIntegrationLookupStrategy-ref="shibboleth.authn.DuoOIDC.DuoIntegrationStrategy"
+        p:passwordlessDuoIntegrationLookupStrategy-ref="shibboleth.authn.DuoOIDC.Passwordless.DuoIntegrationStrategy"
         p:redirectURICreationStrategy-ref="shibboleth.authn.DuoOIDC.RedirectURICreationStrategy"
         p:usernameLookupStrategy-ref="shibboleth.authn.DuoOIDC.UsernameLookupStrategy"
         p:clientRegistry-ref="shibboleth.authn.DuoOIDC.clientRegistry" />
@@ -124,6 +184,9 @@
     <bean id="ValidateDuoResponseState" scope="prototype"
         class="net.shibboleth.idp.plugin.authn.duo.impl.ValidateDuoResponseState" />
         
+    <bean id="ExchangeCodeForDuoToken" scope="prototype"
+        class="net.shibboleth.idp.plugin.authn.duo.impl.ExchangeCodeForDuoToken" />
+        
     <bean id="ValidateTokenSignature" scope="prototype"
         class="net.shibboleth.idp.plugin.authn.duo.impl.ValidateTokenSignature" 
         p:signatureAlgorithm="HS512"/>
@@ -220,11 +283,11 @@
         <value>iat</value>
     </util:set>
 
-    <bean id="ExchangeCodeForDuoToken" scope="prototype"
-        class="net.shibboleth.idp.plugin.authn.duo.impl.ExchangeCodeForDuoToken" />
-        
     <bean id="shibboleth.authn.DuoOIDC.DefaultCleanupHook" 
-        class="net.shibboleth.idp.plugin.authn.duo.impl.ValidateDuoTokenAuthenticationResult.DuoOIDCCleanupHook" />
+        class="net.shibboleth.idp.plugin.authn.duo.DefaultDuoCleanupHook"
+        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:}" />
           
     <bean id="ValidateDuoTokenAuthenticationResult" scope="prototype"
         class="net.shibboleth.idp.plugin.authn.duo.impl.ValidateDuoTokenAuthenticationResult"        
@@ -238,10 +301,10 @@
 
     <!-- Audit logging beans -->
     
-        <!-- 
-        The first context logger clears any previous audit information from the first authentication factor. The
-        others are accumulative.
-         -->
+    <!-- 
+    The first context logger clears any previous audit information from the first authentication factor. The
+    others are accumulative.
+     -->
     <bean id="PreDuoPopulateAuditContext" parent="shibboleth.authn.AbstractPopulateAuditContext"
         p:fieldExtractors="#{getObject('shibboleth.authn.DuoOIDC.PreDuoPopulateAuditExtractors') ?: getObject('shibboleth.authn.DuoOIDC.DefaultPreDuoPopulateAuditExtractors')}"
         p:clearAuditContext="true"/>
diff --git a/idp-duo-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/authn/DuoOIDC/duo-oidc-authn-flow.xml b/idp-duo-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/authn/DuoOIDC/duo-oidc-authn-flow.xml
index 6956f4d1..e01c7ed2 100644
--- a/idp-duo-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/authn/DuoOIDC/duo-oidc-authn-flow.xml
+++ b/idp-duo-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/authn/DuoOIDC/duo-oidc-authn-flow.xml
@@ -13,7 +13,7 @@
         <!-- Fall through to a different flow if header extract fails on a passive or non-browser request. -->
         <transition on="#{ opensamlProfileRequestContext.getSubcontext(T(net.shibboleth.idp.authn.context.AuthenticationContext)).isPassive() || !opensamlProfileRequestContext.isBrowserProfile() }" to="ReselectFlow" />
         
-        <transition on="NoCredentials" to="CheckDuoOIDCAuthAPI" />
+        <transition on="NoCredentials" to="CheckForPasswordless" />
     </action-state>
 
     <action-state id="ValidateDuoAuthAPI">
@@ -23,12 +23,56 @@
         <transition on="proceed" to="proceed" />
     </action-state>
     
+    <decision-state id="CheckForPasswordless">
+        <if test="opensamlProfileRequestContext.ensureSubcontext(T(net.shibboleth.idp.authn.context.AuthenticationContext)).containsSubcontext(T(net.shibboleth.idp.plugin.authn.duo.context.DuoPasswordlessContext))"
+            then="CheckPasswordlessEnrollment1"
+            else="CheckDuoOIDCAuthAPI" />
+    </decision-state>
+    
+    <action-state id="CheckPasswordlessEnrollment1">
+        <evaluate expression="CheckPasswordlessEnrollment" />
+        <evaluate expression="'proceed'" />
+        
+        <transition on="proceed" to="PasswordlessView" />
+        <transition on="UnknownUsername" to="PasswordlessView" />
+        <transition on="RequestUnsupported" to="PasswordlessView" />
+    </action-state>
+
+    <view-state id="PasswordlessView" view="passwordless">
+        <on-render>
+            <evaluate expression="environment" result="viewScope.environment" />
+            <evaluate expression="opensamlProfileRequestContext" result="viewScope.profileRequestContext" />
+            <evaluate expression="opensamlProfileRequestContext.getSubcontext(T(net.shibboleth.idp.authn.context.AuthenticationContext))" result="viewScope.authenticationContext" />
+            <evaluate expression="authenticationContext.getSubcontext(T(net.shibboleth.idp.plugin.authn.duo.context.DuoPasswordlessContext))" result="viewScope.passwordlessContext" />
+            <evaluate expression="authenticationContext.getSubcontext(T(net.shibboleth.idp.ui.context.RelyingPartyUIContext))" result="viewScope.rpUIContext" />
+            <evaluate expression="T(net.shibboleth.shared.codec.HTMLEncoder)" result="viewScope.encoder" />
+            <evaluate expression="flowRequestContext.getActiveFlow().getApplicationContext().getBean('DuoCSPDigester')" result="viewScope.cspDigester" />
+            <evaluate expression="flowRequestContext.getActiveFlow().getApplicationContext().getBean('DuoCSPNonce')" result="viewScope.cspNonce" />
+            <evaluate expression="flowRequestContext.getExternalContext().getNativeRequest()" result="viewScope.request" />
+            <evaluate expression="flowRequestContext.getExternalContext().getNativeResponse()" result="viewScope.response" />
+            <evaluate expression="flowRequestContext.getActiveFlow().getApplicationContext().containsBean('shibboleth.CustomViewContext') ? flowRequestContext.getActiveFlow().getApplicationContext().getBean('shibboleth.CustomViewContext') : null" result="viewScope.custom" />
+        </on-render>
+
+        <transition on="proceed" to="CheckPasswordlessEnrollment2" />
+        <transition on="cancel" to="RequestUnsupported" />
+    </view-state>
+    
+    <action-state id="CheckPasswordlessEnrollment2">
+        <evaluate expression="CheckPasswordlessEnrollment" />
+        <evaluate expression="'proceed'" />
+        
+        <transition on="proceed" to="CheckDuoOIDCAuthAPI" />
+        <transition on="UnknownUsername" to="PasswordlessView" />
+        <transition on="RequestUnsupported" to="PasswordlessView" />
+    </action-state>
+    
     <action-state id="CheckDuoOIDCAuthAPI">
         <evaluate expression="PopulateDuoAuthenticationContext" />
         <evaluate expression="HealthCheckDuoOIDCAuthAPI" />
         <evaluate expression="PreDuoPopulateAuditContext" />
         <evaluate expression="WritePreDuoAuthnAuditLog"/>
         <evaluate expression="'proceed'" />
+        
         <transition on="proceed" to="Duo2FAAuthorizationRequest" />
     </action-state>
     
@@ -50,6 +94,7 @@
         <evaluate expression="PreStateValidationPopulateAuditContext" />
         <evaluate expression="ValidateDuoResponseState"/>  
         <evaluate expression="'proceed'" />
+        
         <transition on="proceed" to="ExchangeCodeForDuoToken" />
     </action-state>
     
@@ -59,8 +104,9 @@
         <evaluate expression="ValidateTokenSignature"/>
         <evaluate expression="ValidateTokenClaims"/>
         <!-- final validation of the response status to build an authn result -->
-        <evaluate expression="ValidateDuoTokenAuthenticationResult"/>        
+        <evaluate expression="ValidateDuoTokenAuthenticationResult"/>
         <evaluate expression="'proceed'" />
+        
         <transition on="proceed" to="proceed" />
     </action-state>    
 
diff --git a/idp-duo-impl/src/main/resources/net/shibboleth/idp/plugin/authn/duo/messages.properties b/idp-duo-impl/src/main/resources/net/shibboleth/idp/plugin/authn/duo/messages.properties
new file mode 100644
index 00000000..ebb51830
--- /dev/null
+++ b/idp-duo-impl/src/main/resources/net/shibboleth/idp/plugin/authn/duo/messages.properties
@@ -0,0 +1,18 @@
+# In addition to the Apache 2.0 license, this content is also licensed
+# under the Creative Commons Attribution-ShareAlike 3.0 Unported license
+# (see http://creativecommons.org/licenses/by-sa/3.0/). 
+
+# This is the as-delivered set of messages used in various views
+# so that internationalized translations can be made available and
+# so administrators can locally override or supplement the values
+# in their own message files.
+
+
+idp.duo.passwordless.explain = If you've enrolled a passkey or device/token for passwordless login, please enter your \
+    username below and press the corresponding button. To bypass this option, just press the alternate button to perform a \
+    traditional login.
+
+idp.duo.passwordless.proceed = Login with Passkey or Device
+idp.duo.passwordless.cancel = Login with Password
+
+idp.duo.passwordless.unsupported = You have not enrolled a qualifying device for Passwordless use.
diff --git a/idp-duo-impl/src/main/resources/net/shibboleth/idp/plugin/authn/duo/views/passwordless.vm b/idp-duo-impl/src/main/resources/net/shibboleth/idp/plugin/authn/duo/views/passwordless.vm
new file mode 100644
index 00000000..07ba6a28
--- /dev/null
+++ b/idp-duo-impl/src/main/resources/net/shibboleth/idp/plugin/authn/duo/views/passwordless.vm
@@ -0,0 +1,122 @@
+##
+## Velocity Template for collection of username for Duo Passwordless use
+##
+## Velocity context will contain the following properties
+## flowExecutionUrl - the form action location
+## flowRequestContext - the Spring Web Flow RequestContext
+## flowExecutionKey - the SWF execution key (this is built into the flowExecutionUrl)
+## profileRequestContext - root of context tree
+## authenticationContext - context with authentication request information
+## passwordlessContext - context with Duo username and enrollment status
+## rpUIContext - the context with SP UI information from the metadata
+## encoder - HTMLEncoder class
+## cspDigester - Calculates base64-encoded SHA-2 hashes (call apply)
+## cspNonce - Calculates secure nonces (call generateIdentifier)
+## request - HttpServletRequest
+## response - HttpServletResponse
+## environment - Spring Environment object for property resolution
+## custom - arbitrary object injected by deployer
+##
+#set ($rpContext = $profileRequestContext.getSubcontext('net.shibboleth.profile.context.RelyingPartyContext'))
+#set ($username = $passwordlessContext.getUsername())
+#set ($eventCtx = $profileRequestContext.getSubcontext('org.opensaml.profile.context.EventContext'))
+#if ($eventCtx)
+#set ($eventId = $eventCtx.getEvent())
+#end
+#set ($onClick = "document.password.j_username.value = document.passwordless.j_username.value")
+$response.addHeader("Content-Security-Policy", "script-src-attr 'unsafe-hashes' 'sha256-$cspDigester.apply($onClick)'")
+##
+<!DOCTYPE html>
+<html>
+    <head>
+        <title>#springMessageText("idp.title", "Web Login Service")</title>
+        <meta charset="UTF-8" />
+        <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
+        <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0">
+        <link rel="stylesheet" type="text/css" href="$request.getContextPath()#springMessageText("idp.css", "/css/placeholder.css")">
+    </head>
+    <body>
+        <main class="main">
+            <header>
+                <img class="main-logo" src="$request.getContextPath()#springMessageText("idp.logo", "/images/placeholder-logo.png")" alt="#springMessageText("idp.logo.alt-text", "logo")" />
+                
+                #set ($serviceName = $rpUIContext.serviceName)
+                #if ($serviceName && !$rpContext.getRelyingPartyId().contains($serviceName))
+                    <h1>#springMessageText("idp.login.loginTo", "Login to") $encoder.encodeForHTML($serviceName)</h1>
+                #end
+            </header>
+            
+            <section>
+                #*
+                //
+                //    SP Description & Logo (optional)
+                //    These idpui lines will display added information (if available
+                //    in the metadata) about the Service Provider (SP) that requested
+                //    authentication. These idpui lines are "active" in this example
+                //    (not commented out) - this extra SP info will be displayed.
+                //    Remove or comment out these lines to stop the display of the
+                //    added SP information.
+                //
+                *#
+                #set ($logo = $rpUIContext.getLogo())
+                #if ($logo)
+                    <img class="service-logo" src= "$encoder.encodeForHTMLAttribute($logo)" alt="$encoder.encodeForHTMLAttribute($serviceName)">
+                #end
+                #set ($desc = $rpUIContext.getServiceDescription())
+                #if ($desc)
+                    <p>$encoder.encodeForHTML($desc)</p>
+                #end
+
+                <!-- Two forms are used below to allow the return key to trigger the Passwordless option. -->
+
+                <blockquote>#springMessageText("idp.duo.passwordless.explain", "If you've enrolled a passkey or device/token for passwordless login,
+                please enter your username below and press the corresponding button. To bypass this option, just press the alternate button
+                to perform a traditional login.")</blockquote>
+
+                <form name="password" action="$flowExecutionUrl" method="post">
+                    #parse("csrf/csrf.vm")
+                    <input name="j_username" type="hidden" value="" />
+                    <div class="grid">
+                        <div class="grid-item">
+                            <button type="submit" name="_eventId_cancel" onClick="$onClick"
+                                >#springMessageText("idp.duo.passwordless.cancel", "Login with Password")</button>
+                        </div>
+                    </div>
+                </form>
+                    
+                #if ($eventId == "RequestUnsupported")
+                    <p class="output-message output--error">$encoder.encodeForHTML("#springMessageText('idp.duo.passwordless.unsupported', 'You have not enrolled a qualifying device for Passwordless use.')")</p>
+                #end
+                        
+                <form name="passwordless" action="$flowExecutionUrl" method="post">
+                    #parse("csrf/csrf.vm")
+                    <label for="username">#springMessageText("idp.login.username", "Username")</label>
+                    <input name="j_username" type="text"
+                        value="#if($username)$encoder.encodeForHTML($username)#end" />
+                        
+                    <input type="checkbox" name="donotcache" value="1" id="donotcache" />
+                    <label for="donotcache">#springMessageText("idp.login.donotcache", "Don't Remember Login")</label>
+
+                    <input id="_shib_idp_revokeConsent" type="checkbox" name="_shib_idp_revokeConsent" value="true" />
+                    <label for="_shib_idp_revokeConsent">#springMessageText("idp.attribute-release.revoke", "Clear prior granting of permission for release of your information to this service.")</label>
+    
+                    <div class="grid">
+                        <div class="grid-item">
+                            <button type="submit" name="_eventId_proceed"
+                                >#springMessageText("idp.duo.passwordless.proceed", "Login with Passkey or Device")</button>
+                        </div>
+                    </div>
+                </form>
+    
+                <ul>
+                    <li><a href="#springMessageText("idp.url.helpdesk", '#')">#springMessageText("idp.login.needHelp", "Need Help?")</a></li>
+                </ul>
+            </section>
+        </main>
+        <footer class="footer">
+            <div class="cc">
+                <p>#springMessageText("idp.footer", "Insert your footer text here.")</p>
+            </div>
+        </footer>
+     </body>
+</html>
\ No newline at end of file
diff --git a/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/AbstractDuoActionTest.java b/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/AbstractDuoActionTest.java
index 40f82746..d3184a0a 100644
--- a/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/AbstractDuoActionTest.java
+++ b/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/AbstractDuoActionTest.java
@@ -21,6 +21,7 @@ import static org.testng.Assert.fail;
 
 import java.text.ParseException;
 import java.time.Instant;
+import java.util.Set;
 
 import javax.annotation.Nonnull;
 import javax.annotation.Nullable;
@@ -49,6 +50,7 @@ import net.shibboleth.idp.profile.context.navigate.WebflowRequestContextProfileR
 import net.shibboleth.idp.profile.testing.RequestContextBuilder;
 import net.shibboleth.shared.codec.Base64Support;
 import net.shibboleth.shared.codec.EncodingException;
+import net.shibboleth.shared.collection.CollectionSupport;
 import net.shibboleth.shared.component.ComponentInitializationException;
 
 
@@ -57,7 +59,6 @@ import net.shibboleth.shared.component.ComponentInitializationException;
  */
 public abstract class AbstractDuoActionTest {
     
-    
     protected static final String CLIENT_ID = "DIU6GEFWG5LIUBVV2M3P";
     
     protected static final String REDIRECT_URI = "http://localhost/";
@@ -876,10 +877,33 @@ public abstract class AbstractDuoActionTest {
         ac.addSubcontext(dc);
     }
     
-    /** Add fabricated duo integration to the duo context. */
-    protected void addDuoIntegrationToContext() {    
+    /**
+     * Add fabricated duo integration to the duo context.
+     *  
+     * @throws ComponentInitializationException on error
+     */
+    protected void addDuoIntegrationToContext() throws ComponentInitializationException {    
+        assertNotNull(dc,"try addDuoContext() before adding the duo integration");
+        final var integ = createDummyDuoIntegration();
+        integ.initialize();
+        dc.setIntegration(integ);
+    }
+
+    /**
+     * Add fabricated duo integration to the duo context.
+     * 
+     *  @param factors allowed factors
+     * 
+     * @throws ComponentInitializationException on error
+     */
+    protected void addPasswordlessDuoIntegrationToContext(@Nullable final Set<String> factors)
+            throws ComponentInitializationException {    
         assertNotNull(dc,"try addDuoContext() before adding the duo integration");
-        dc.setIntegration(createDummyDuoIntegration());
+        final DefaultDuoOIDCIntegration integ = createDummyDuoIntegration();
+        integ.setPasswordless(true);
+        integ.setAllowedFactors(factors);
+        integ.initialize();
+        dc.setIntegration(integ);
     }
     
     /**
@@ -896,9 +920,10 @@ public abstract class AbstractDuoActionTest {
         integ.setAuthorizeEndpoint(AUTHORIZE_URI);
         integ.setTokenEndpoint(TOKEN_URI);
         integ.setHealthCheckEndpoint(HEALTH_URI);
+        integ.setAllowedOrigins(CollectionSupport.singletonList("https://example.com"));
         //do not initialize so the tests can mutate and initialize if required
         //integ.initialize();
         return integ;
     }
 
-}
+}
\ No newline at end of file
diff --git a/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/CheckPasswordlessEnrollmentTest.java b/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/CheckPasswordlessEnrollmentTest.java
new file mode 100644
index 00000000..d2fffac3
--- /dev/null
+++ b/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/CheckPasswordlessEnrollmentTest.java
@@ -0,0 +1,321 @@
+/*
+ * 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.time.Instant;
+import java.util.Set;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.springframework.core.io.ClassPathResource;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+import org.springframework.webflow.execution.Event;
+import org.testng.Assert;
+import org.testng.annotations.AfterMethod;
+import org.testng.annotations.BeforeClass;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+import com.google.common.net.UrlEscapers;
+
+import jakarta.servlet.http.Cookie;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import net.shibboleth.idp.authn.AuthenticationResult;
+import net.shibboleth.idp.authn.AuthnEventIds;
+import net.shibboleth.idp.authn.context.AuthenticationContext;
+import net.shibboleth.idp.plugin.authn.duo.context.DuoPasswordlessContext;
+import net.shibboleth.idp.plugin.authn.util.mock.TestResourceConverter;
+import net.shibboleth.idp.profile.testing.ActionTestingSupport;
+import net.shibboleth.idp.session.IdPSession;
+import net.shibboleth.idp.session.SPSession;
+import net.shibboleth.idp.session.SessionException;
+import net.shibboleth.idp.session.context.SessionContext;
+import net.shibboleth.shared.annotation.constraint.NotEmpty;
+import net.shibboleth.shared.collection.CollectionSupport;
+import net.shibboleth.shared.component.ComponentInitializationException;
+import net.shibboleth.shared.net.CookieManager;
+import net.shibboleth.shared.security.DataSealer;
+import net.shibboleth.shared.security.DataSealerException;
+import net.shibboleth.shared.security.impl.BasicKeystoreKeyStrategy;
+import net.shibboleth.shared.servlet.impl.HttpServletRequestResponseContext;
+import net.shibboleth.shared.servlet.impl.ThreadLocalHttpServletRequestSupplier;
+import net.shibboleth.shared.servlet.impl.ThreadLocalHttpServletResponseSupplier;
+
+/** {@link CheckPasswordlessEnrollment} unit test. */
+ at SuppressWarnings("javadoc")
+public class CheckPasswordlessEnrollmentTest extends AbstractDuoActionTest {
+    
+    @Nonnull @NotEmpty public static final String COOKIE_NAME = "_shib_idp_username"; 
+    
+    private DataSealer dataSealer;
+    
+    private CookieManager cookieManager;
+    
+    private CheckPasswordlessEnrollment action;
+    
+    @BeforeClass public void init() throws ComponentInitializationException {
+
+        final BasicKeystoreKeyStrategy strategy = new BasicKeystoreKeyStrategy();
+        
+        strategy.setKeyAlias("secret");
+        strategy.setKeyPassword("kpassword");
+        strategy.setKeystorePassword("password");
+        strategy.setKeystoreResource(TestResourceConverter.of(
+                new ClassPathResource("net/shibboleth/idp/plugin/authn/duo/impl/SealerKeyStore.jks")));
+        strategy.setKeyVersionResource(TestResourceConverter.of(
+                new ClassPathResource("net/shibboleth/idp/plugin/authn/duo/impl/SealerKeyStore.kver")));
+        strategy.initialize();
+        
+        dataSealer = new DataSealer();
+        dataSealer.setKeyStrategy(strategy);
+        dataSealer.initialize();
+        
+        cookieManager = new CookieManager();
+        cookieManager.setHttpServletRequestSupplier(new ThreadLocalHttpServletRequestSupplier());
+        cookieManager.setHttpServletResponseSupplier(new ThreadLocalHttpServletResponseSupplier());
+        cookieManager.setCookiePath("/");
+        cookieManager.setMaxAge(300);
+        cookieManager.initialize();
+    }
+    
+    @BeforeMethod public void setUp() throws ComponentInitializationException {
+        super.setup();
+
+        ac.ensureSubcontext(DuoPasswordlessContext.class);
+        
+        HttpServletRequestResponseContext.loadCurrent(new MockHttpServletRequest(), new MockHttpServletResponse());
+        
+        action = new CheckPasswordlessEnrollment();
+        action.setPasswordlessCondition((a,b) -> {return true;});
+        action.setDataSealer(dataSealer);
+        action.setCookieManager(cookieManager);
+        action.setCookieName(COOKIE_NAME);
+        action.setHttpServletRequestSupplier(new ThreadLocalHttpServletRequestSupplier());
+        action.setHttpServletResponseSupplier(new ThreadLocalHttpServletResponseSupplier());
+        action.setCheckSession(true);
+        action.initialize();
+    }
+    
+    @AfterMethod public void tearDown() {
+        HttpServletRequestResponseContext.clearCurrent();
+    }
+    
+    @Test public void testNoServlet() throws ComponentInitializationException {
+        action = new CheckPasswordlessEnrollment();
+        action.initialize();
+        final Event event = action.execute(src);
+        
+        ActionTestingSupport.assertEvent(event, AuthnEventIds.UNKNOWN_USERNAME);
+    }
+
+    @Test public void testMissingIdentity() {
+        final Event event = action.execute(src);
+        ActionTestingSupport.assertEvent(event, AuthnEventIds.UNKNOWN_USERNAME);
+    }
+
+    @Test public void testUnchangedIdentity() {
+        ac.ensureSubcontext(DuoPasswordlessContext.class).setUsername("bar");
+        
+        final Event event = action.execute(src);
+        ActionTestingSupport.assertEvent(event, AuthnEventIds.REQUEST_UNSUPPORTED);
+    }
+
+    @Test public void testUnchangedIdentityEnrolled() {
+        ac.ensureSubcontext(DuoPasswordlessContext.class).setUsername("bar").setEnrolled(true);
+        
+        final Event event = action.execute(src);
+        ActionTestingSupport.assertProceedEvent(event);
+    }
+    
+    @Test public void testFromForm() {
+        ensureMockRequest().addParameter("j_username", "foo");
+        
+        final Event event = action.execute(src);
+        ActionTestingSupport.assertProceedEvent(event);
+        final AuthenticationContext authCtx = prc.ensureSubcontext(AuthenticationContext.class);
+        Assert.assertTrue(authCtx.isResultCacheable());
+        final DuoPasswordlessContext duoCtx = authCtx.ensureSubcontext(DuoPasswordlessContext.class);
+        Assert.assertEquals(duoCtx.getUsername(), "foo");
+        Assert.assertTrue(duoCtx.isEnrolled());
+    }
+
+    @Test public void testSSOBypass() {
+        ensureMockRequest().addParameter("j_username", "foo");
+        ensureMockRequest().addParameter("donotcache", "1");
+        
+        final Event event = action.execute(src);
+        ActionTestingSupport.assertProceedEvent(event);
+        final AuthenticationContext authCtx = prc.ensureSubcontext(AuthenticationContext.class);
+        Assert.assertFalse(authCtx.isResultCacheable());
+        final DuoPasswordlessContext duoCtx = authCtx.ensureSubcontext(DuoPasswordlessContext.class);
+        Assert.assertEquals(duoCtx.getUsername(), "foo");
+        Assert.assertTrue(duoCtx.isEnrolled());
+    }
+    
+    @Test public void testFromCookie() throws DataSealerException {
+        // Wrong field name.
+        ensureMockRequest().addParameter("username", "foo");
+        
+        final String wrapped = dataSealer.wrap("foo");
+        final Cookie cookie = new Cookie(COOKIE_NAME, UrlEscapers.urlFormParameterEscaper().escape(wrapped));
+        ensureMockRequest().setCookies(cookie);
+        
+        final Event event = action.execute(src);
+        ActionTestingSupport.assertProceedEvent(event);
+        final AuthenticationContext authCtx = prc.ensureSubcontext(AuthenticationContext.class);
+        Assert.assertTrue(authCtx.isResultCacheable());
+        final DuoPasswordlessContext duoCtx = authCtx.ensureSubcontext(DuoPasswordlessContext.class);
+        Assert.assertEquals(duoCtx.getUsername(), "foo");
+        Assert.assertTrue(duoCtx.isEnrolled());
+    }
+
+    @Test public void testFromSession() throws DataSealerException {
+        // Wrong field name.
+        ensureMockRequest().addParameter("username", "foo");
+        
+        // Wrong cookie name.
+        final String wrapped = dataSealer.wrap("foo");
+        final Cookie cookie = new Cookie(COOKIE_NAME + "1", UrlEscapers.urlFormParameterEscaper().escape(wrapped));
+        ensureMockRequest().setCookies(cookie);
+        
+        prc.ensureSubcontext(SessionContext.class).setIdPSession(new MockIdPSession());
+        
+        final Event event = action.execute(src);
+        ActionTestingSupport.assertProceedEvent(event);
+        final AuthenticationContext authCtx = prc.ensureSubcontext(AuthenticationContext.class);
+        Assert.assertTrue(authCtx.isResultCacheable());
+        final DuoPasswordlessContext duoCtx = authCtx.ensureSubcontext(DuoPasswordlessContext.class);
+        Assert.assertEquals(duoCtx.getUsername(), "foo");
+        Assert.assertTrue(duoCtx.isEnrolled());
+    }
+    
+    @Nonnull private MockHttpServletRequest ensureMockRequest() {
+        final HttpServletRequest request = HttpServletRequestResponseContext.getRequest();
+        return MockHttpServletRequest.class.cast(request);
+    }
+
+    @Nonnull private MockHttpServletResponse ensureMockResponse() {
+        final HttpServletResponse request = HttpServletRequestResponseContext.getResponse();
+        return MockHttpServletResponse.class.cast(request);
+    }
+
+    private class MockIdPSession implements IdPSession {
+
+        /** {@inheritDoc} */
+        @Override
+        @Nullable
+        public String getId() {
+            return "id";
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        @Nonnull
+        public String getPrincipalName() {
+            return "foo";
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        @Nonnull
+        public Instant getCreationInstant() {
+            return Instant.now();
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        @Nonnull
+        public Instant getLastActivityInstant() {
+            return Instant.now();
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public boolean checkAddress(@Nonnull String address) throws SessionException {
+            return false;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public boolean checkTimeout() throws SessionException {
+            return false;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        @Nonnull
+        public Set<AuthenticationResult> getAuthenticationResults() {
+            return CollectionSupport.emptySet();
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        @Nullable
+        public AuthenticationResult getAuthenticationResult(@Nonnull String flowId) {
+            return null;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        @Nullable
+        public AuthenticationResult addAuthenticationResult(@Nonnull AuthenticationResult result)
+                throws SessionException {
+            return null;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public void updateAuthenticationResultActivity(@Nonnull AuthenticationResult result) throws SessionException {
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public boolean removeAuthenticationResult(@Nonnull AuthenticationResult result) throws SessionException {
+            return false;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        @Nonnull
+        public Set<SPSession> getSPSessions() {
+            return CollectionSupport.emptySet();
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        @Nullable
+        public SPSession getSPSession(@Nonnull String serviceId) {
+            return null;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        @Nullable
+        public SPSession addSPSession(@Nonnull SPSession spSession) throws SessionException {
+            return null;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public boolean removeSPSession(@Nonnull SPSession spSession) throws SessionException {
+            return false;
+        }
+    }
+
+}
\ No newline at end of file
diff --git a/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/DefaultDuoOIDCClientRegistryTest.java b/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/DefaultDuoOIDCClientRegistryTest.java
index 3ce30685..91a8caa6 100644
--- a/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/DefaultDuoOIDCClientRegistryTest.java
+++ b/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/DefaultDuoOIDCClientRegistryTest.java
@@ -42,6 +42,7 @@ import net.shibboleth.shared.component.ComponentInitializationException;
 /**
  * Tests for the {@link DefaultDuoOIDCClientRegistry}.
  */
+ at SuppressWarnings("javadoc")
 public class DefaultDuoOIDCClientRegistryTest {
 
     /** The registry to test. */
diff --git a/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/DefaultRedirectURICreationStrategyTest.java b/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/DefaultRedirectURICreationStrategyTest.java
index c4473cc3..db988cb7 100644
--- a/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/DefaultRedirectURICreationStrategyTest.java
+++ b/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/DefaultRedirectURICreationStrategyTest.java
@@ -44,6 +44,7 @@ import net.shibboleth.idp.plugin.authn.duo.DynamicDuoOIDCIntegration;
 
 
 /** Tests for the DefaultDuoOIDCIntegration class.*/
+ at SuppressWarnings("javadoc")
 public class DefaultRedirectURICreationStrategyTest {
     
     /** Static callback path from servlet request.*/
diff --git a/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/DuoAudienceClaimLookupStrategyTest.java b/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/DuoAudienceClaimLookupStrategyTest.java
index 10c8138d..39c0ff62 100644
--- a/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/DuoAudienceClaimLookupStrategyTest.java
+++ b/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/DuoAudienceClaimLookupStrategyTest.java
@@ -14,6 +14,7 @@ import net.shibboleth.shared.component.ComponentInitializationException;
 /**
  * Test for the {@link DuoAudienceClaimLookupStrategy}.
  */
+ at SuppressWarnings("javadoc")
 public class DuoAudienceClaimLookupStrategyTest extends AbstractDuoActionTest{
 
     /** The strategy.*/
diff --git a/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/DuoIssuerClaimLookupStrategyTest.java b/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/DuoIssuerClaimLookupStrategyTest.java
index 97eaca12..7f89f7db 100644
--- a/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/DuoIssuerClaimLookupStrategyTest.java
+++ b/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/DuoIssuerClaimLookupStrategyTest.java
@@ -27,6 +27,7 @@ import net.shibboleth.shared.component.ComponentInitializationException;
 /**
  * Test for the {@link DuoIssuerClaimLookupStrategy}.
  */
+ at SuppressWarnings("javadoc")
 public class DuoIssuerClaimLookupStrategyTest extends AbstractDuoActionTest{
 
     /** The strategy.*/
diff --git a/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/DuoNonceClaimLookupStrategyTest.java b/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/DuoNonceClaimLookupStrategyTest.java
index 86766e6e..3fd592de 100644
--- a/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/DuoNonceClaimLookupStrategyTest.java
+++ b/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/DuoNonceClaimLookupStrategyTest.java
@@ -26,6 +26,7 @@ import net.shibboleth.shared.component.ComponentInitializationException;
 /**
  * Test for the {@link DuoNonceClaimLookupStrategy}.
  */
+ at SuppressWarnings("javadoc")
 public class DuoNonceClaimLookupStrategyTest extends AbstractDuoActionTest{
 
     /** The strategy.*/
@@ -42,7 +43,7 @@ public class DuoNonceClaimLookupStrategyTest extends AbstractDuoActionTest{
     }
 
     @Test
-    public void applySuccess() {
+    public void applySuccess() throws ComponentInitializationException {
         addDuoContext();
         addDuoIntegrationToContext();
         dc.setNonce("testnonce");
diff --git a/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/DuoSupportTest.java b/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/DuoSupportTest.java
index 0ab88a02..6424628a 100644
--- a/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/DuoSupportTest.java
+++ b/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/DuoSupportTest.java
@@ -23,6 +23,7 @@ import net.shibboleth.idp.plugin.authn.duo.DuoException;
 /** 
  * Tests for the DuoSupport class.
  */
+ at SuppressWarnings("javadoc")
 public class DuoSupportTest {
     
     @Test public void testCreateAndExtractKeyAndNonceFromState() throws DuoException {
diff --git a/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/DuoUsernameClaimLookupStrategyTest.java b/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/DuoUsernameClaimLookupStrategyTest.java
index 76bba890..1f04204d 100644
--- a/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/DuoUsernameClaimLookupStrategyTest.java
+++ b/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/DuoUsernameClaimLookupStrategyTest.java
@@ -27,6 +27,7 @@ import net.shibboleth.shared.component.ComponentInitializationException;
 /**
  * Test for the {@link DuoUsernameClaimLookupStrategy}.
  */
+ at SuppressWarnings("javadoc")
 public class DuoUsernameClaimLookupStrategyTest extends AbstractDuoActionTest{
 
     /** The strategy.*/
@@ -43,7 +44,7 @@ public class DuoUsernameClaimLookupStrategyTest extends AbstractDuoActionTest{
     }
 
     @Test
-    public void applySuccess() {
+    public void applySuccess() throws ComponentInitializationException {
         addDuoContext();
         addDuoIntegrationToContext();
         dc.setUsername("username");
@@ -52,7 +53,7 @@ public class DuoUsernameClaimLookupStrategyTest extends AbstractDuoActionTest{
     }
     
     @Test
-    public void applyNoUsername() {
+    public void applyNoUsername() throws ComponentInitializationException {
         addDuoContext();
         addDuoIntegrationToContext();
         dc.setUsername(null);
diff --git a/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/ExchangeCodeForDuoTokenTest.java b/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/ExchangeCodeForDuoTokenTest.java
index 90d1f03c..2edfb70a 100644
--- a/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/ExchangeCodeForDuoTokenTest.java
+++ b/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/ExchangeCodeForDuoTokenTest.java
@@ -30,6 +30,7 @@ import net.shibboleth.shared.component.ComponentInitializationException;
 /**
  * Tests for {@link ExchangeCodeForDuoToken}.
  */
+ at SuppressWarnings("javadoc")
 public class ExchangeCodeForDuoTokenTest extends AbstractDuoActionTest {
 
     /** The action to test. */
diff --git a/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/PopulateDuoAuthenticationContextTest.java b/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/PopulateDuoAuthenticationContextTest.java
index 7e8f9ca2..6b37df43 100644
--- a/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/PopulateDuoAuthenticationContextTest.java
+++ b/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/PopulateDuoAuthenticationContextTest.java
@@ -27,6 +27,7 @@ import org.testng.annotations.BeforeMethod;
 import org.testng.annotations.Test;
 
 import net.shibboleth.idp.authn.AuthnEventIds;
+import net.shibboleth.idp.authn.context.AuthenticationContext;
 import net.shibboleth.idp.plugin.authn.duo.DefaultDuoOIDCIntegration;
 import net.shibboleth.idp.plugin.authn.duo.DuoClientException;
 import net.shibboleth.idp.plugin.authn.duo.DuoOIDCClient;
@@ -35,13 +36,14 @@ import net.shibboleth.idp.plugin.authn.duo.DuoOIDCIntegration;
 import net.shibboleth.idp.plugin.authn.duo.DuoRegistryException;
 import net.shibboleth.idp.plugin.authn.duo.SimpleDuoOIDCIntegration;
 import net.shibboleth.idp.plugin.authn.duo.context.DuoOIDCAuthenticationContext;
+import net.shibboleth.idp.plugin.authn.duo.context.DuoPasswordlessContext;
 import net.shibboleth.shared.component.ComponentInitializationException;
 import net.shibboleth.shared.testing.ConstantSupplier;
 
 /**
  * Tests for the {@link PopulateDuoAuthenticationContext} strategy.
  */
-public class PopulateDuoAuthenticationContextTest extends AbstractDuoActionTest{
+public class PopulateDuoAuthenticationContextTest extends AbstractDuoActionTest {
 
     /** The action to test. */
     private PopulateDuoAuthenticationContext action;
@@ -80,10 +82,10 @@ public class PopulateDuoAuthenticationContextTest extends AbstractDuoActionTest{
         integ.setAuthorizeEndpoint(AUTHORIZE_URI);
         integ.setTokenEndpoint(TOKEN_URI);
         integ.setHealthCheckEndpoint(HEALTH_URI);
-        
+        integ.initialize();
         
         //set the duo integration strategy to lookup 
-        action.setDuoIntegrationLookupStrategy(prc -> integ);
+        action.setStandardDuoIntegrationLookupStrategy(prc -> integ);
         //lookup a username
         action.setUsernameLookupStrategy(prc -> "jdoe");
         
@@ -105,6 +107,53 @@ public class PopulateDuoAuthenticationContextTest extends AbstractDuoActionTest{
         assertEquals(duoContext.getRedirectURIOverride(), null);
     }
     
+    /**
+     * Test successful passwordless execution assuming a client that does not support dynamic redirect URIs.
+     * 
+     * @throws ComponentInitializationException on error.
+     * @throws DuoClientException  on error.
+     * @throws DuoRegistryException one error.
+     */
+    @Test
+    public void testPasswordlessExecuteSuccessWithStaticClient() 
+            throws ComponentInitializationException, DuoRegistryException, DuoClientException {
+        
+        final SimpleDuoOIDCIntegration integ = new SimpleDuoOIDCIntegration();
+        integ.setPasswordless(true);
+        integ.setAPIHost(API_HOST);       
+        integ.setClientId(CLIENT_ID);
+        integ.setRedirectURI("https://simple-static.redirect/");
+        integ.setSecretKey(SECRET);
+        integ.setAuthorizeEndpoint(AUTHORIZE_URI);
+        integ.setTokenEndpoint(TOKEN_URI);
+        integ.setHealthCheckEndpoint(HEALTH_URI);
+        integ.initialize();
+        
+        //set the duo integration strategy to lookup 
+        action.setPasswordlessDuoIntegrationLookupStrategy(prc -> integ);
+
+        action.setHttpServletRequestSupplier(new ConstantSupplier<>(request));
+        final DuoOIDCClientRegistry mockClientRegistry = Mockito.mock(DuoOIDCClientRegistry.class);
+        final DuoOIDCClient mockClient = Mockito.mock(DuoOIDCClient.class);
+        Mockito.when(mockClientRegistry.getClientOrCreate(any(DuoOIDCIntegration.class))).thenReturn(mockClient);
+        
+        action.setClientRegistry(mockClientRegistry);
+        action.initialize();
+
+        final DuoPasswordlessContext passwordlessCtx =
+                prc.ensureSubcontext(AuthenticationContext.class).ensureSubcontext(DuoPasswordlessContext.class);
+        passwordlessCtx.setUsername("jdoe");
+        
+        final Event event = action.execute(src);
+        //success here is a null event
+        assertNull(event);
+        //simple client, so no override set
+        final var duoContext = ac.getSubcontext(DuoOIDCAuthenticationContext.class);
+        assertNotNull(duoContext);
+        assert duoContext != null;
+        assertEquals(duoContext.getRedirectURIOverride(), null);
+    }
+    
     /**
      * Test that should return an authentication exception if the integration is dynamic
      * but a redirectURI strategy has not been set.
@@ -117,7 +166,7 @@ public class PopulateDuoAuthenticationContextTest extends AbstractDuoActionTest{
             throws ComponentInitializationException, DuoRegistryException {
         
         final DefaultDuoOIDCIntegration integ = createDummyDuoIntegration();
-        action.setDuoIntegrationLookupStrategy(prc -> integ);
+        action.setStandardDuoIntegrationLookupStrategy(prc -> integ);
         //lookup a username
         action.setUsernameLookupStrategy(prc -> "jdoe");
         
@@ -151,7 +200,7 @@ public class PopulateDuoAuthenticationContextTest extends AbstractDuoActionTest{
         //cleanout pre-registered redirect
         integ.setRegisteredRedirectURI(null);
         //set the duo integration strategy to lookup this 
-        action.setDuoIntegrationLookupStrategy(prc -> integ);
+        action.setStandardDuoIntegrationLookupStrategy(prc -> integ);
         //lookup a username
         action.setUsernameLookupStrategy(prc -> "jdoe");
         action.setRedirectURICreationStrategy((http,duoInteg) 
@@ -181,9 +230,53 @@ public class PopulateDuoAuthenticationContextTest extends AbstractDuoActionTest{
                 .getRedirectURIOverride(), "https://example.com/idp/profile/Authn/Duo/2FA/callback");
     }
     
-    
-    
-    
+    /**
+     * Test successful execution assuming a client that does not support dynamic redirect URIs.
+     * 
+     * @throws ComponentInitializationException on error.
+     * @throws DuoClientException  on error.
+     * @throws DuoRegistryException one error.
+     */
+    @Test
+    public void testPasswordlessExecuteSuccessWithDynamicClient() 
+            throws ComponentInitializationException, DuoRegistryException, DuoClientException {
+        final DefaultDuoOIDCIntegration integ = createDummyDuoIntegration();
+        integ.setPasswordless(true);
+        //cleanout pre-registered redirect
+        integ.setRegisteredRedirectURI(null);
+        integ.initialize();
+        
+        //set the duo integration strategy to lookup this 
+        action.setPasswordlessDuoIntegrationLookupStrategy(prc -> integ);
+        action.setRedirectURICreationStrategy((http,duoInteg) 
+                -> "https://example.com/idp/profile/Authn/Duo/2FA/callback");
+        action.setHttpServletRequestSupplier(new ConstantSupplier<>(request));
+        //set http params
+        request.addHeader("Host", "example.com");
+        request.setServerPort(443);
+        request.setScheme("https");
+        //mock
+        final DuoOIDCClientRegistry mockClientRegistry = Mockito.mock(DuoOIDCClientRegistry.class);
+        final DuoOIDCClient mockClient = Mockito.mock(DuoOIDCClient.class);
+        Mockito.when(mockClientRegistry.getClientOrCreate(any(DuoOIDCIntegration.class))).thenReturn(mockClient);
+      
+        action.setClientRegistry(mockClientRegistry);
+        action.initialize();
+        
+        final DuoPasswordlessContext passwordlessCtx =
+                prc.ensureSubcontext(AuthenticationContext.class).ensureSubcontext(DuoPasswordlessContext.class);
+        passwordlessCtx.setUsername("jdoe");
+        
+        final Event event = action.execute(src);
+        //success here is a null event
+        assertNull(event);
+        assertEquals(integ.getRedirectURI(), "https://example.com/idp/profile/Authn/Duo/2FA/callback");
+        final var duoContext = ac.getSubcontext(DuoOIDCAuthenticationContext.class);
+        assertNotNull(duoContext);
+        assert duoContext != null;
+        assertEquals(duoContext
+                .getRedirectURIOverride(), "https://example.com/idp/profile/Authn/Duo/2FA/callback");
+    }
     
     /**
      * Test unsuccessful execution if there is no duo integration specified.
@@ -233,8 +326,10 @@ public class PopulateDuoAuthenticationContextTest extends AbstractDuoActionTest{
     public void testExecuteNullUsername() throws ComponentInitializationException, 
             DuoRegistryException, DuoClientException {
         final DefaultDuoOIDCIntegration integ = createDummyDuoIntegration();
+        integ.initialize();
+        
         //set the duo integration strategy to lookup this 
-        action.setDuoIntegrationLookupStrategy(prc -> integ);
+        action.setStandardDuoIntegrationLookupStrategy(prc -> integ);
         //lookup a username
         action.setUsernameLookupStrategy(prc -> null);
         action.setRedirectURICreationStrategy((http,duoInteg) 
@@ -259,8 +354,10 @@ public class PopulateDuoAuthenticationContextTest extends AbstractDuoActionTest{
     public void testExecuteNullRedirectURI() throws ComponentInitializationException, 
             DuoRegistryException, DuoClientException {
         final DefaultDuoOIDCIntegration integ = createDummyDuoIntegration();
+        integ.initialize();
+        
         //set the duo integration strategy to lookup this 
-        action.setDuoIntegrationLookupStrategy(prc -> integ);
+        action.setStandardDuoIntegrationLookupStrategy(prc -> integ);
         //lookup a username
         action.setUsernameLookupStrategy(prc -> "jdoe");
         action.setRedirectURICreationStrategy((http,duoInteg) -> null);
@@ -284,8 +381,10 @@ public class PopulateDuoAuthenticationContextTest extends AbstractDuoActionTest{
     public void testExecuteClientException() throws ComponentInitializationException, 
             DuoRegistryException, DuoClientException {
         final DefaultDuoOIDCIntegration integ = createDummyDuoIntegration();
+        integ.initialize();
+        
         //set the duo integration strategy to lookup this 
-        action.setDuoIntegrationLookupStrategy(prc -> integ);
+        action.setStandardDuoIntegrationLookupStrategy(prc -> integ);
         //lookup a username
         action.setUsernameLookupStrategy(prc -> "jdoe");
         action.setRedirectURICreationStrategy((http,duoInteg) 
@@ -313,8 +412,10 @@ public class PopulateDuoAuthenticationContextTest extends AbstractDuoActionTest{
     public void testExecuteNoHttpRequest() throws ComponentInitializationException, 
             DuoRegistryException, DuoClientException {
         final DefaultDuoOIDCIntegration integ = createDummyDuoIntegration();
+        integ.initialize();
+
         //set the duo integration strategy to lookup this 
-        action.setDuoIntegrationLookupStrategy(prc -> integ);
+        action.setStandardDuoIntegrationLookupStrategy(prc -> integ);
         //lookup a username
         action.setUsernameLookupStrategy(prc -> "jdoe");
         action.setRedirectURICreationStrategy((http,duoInteg) 
@@ -343,8 +444,10 @@ public class PopulateDuoAuthenticationContextTest extends AbstractDuoActionTest{
     public void testExecuteNoDuoContext() throws ComponentInitializationException, 
             DuoRegistryException, DuoClientException {
         final DefaultDuoOIDCIntegration integ = createDummyDuoIntegration();
+        integ.initialize();
+
         //set the duo integration strategy to lookup this 
-        action.setDuoIntegrationLookupStrategy(prc -> integ);
+        action.setStandardDuoIntegrationLookupStrategy(prc -> integ);
         //lookup a username
         action.setUsernameLookupStrategy(prc -> "jdoe");
         action.setRedirectURICreationStrategy((http,duoInteg) 
diff --git a/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/ValidateDuoResponseStateTest.java b/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/ValidateDuoResponseStateTest.java
index b5f5709b..563a5436 100644
--- a/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/ValidateDuoResponseStateTest.java
+++ b/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/ValidateDuoResponseStateTest.java
@@ -26,6 +26,7 @@ import net.shibboleth.shared.component.ComponentInitializationException;
 /**
  * Tests for the {@link ValidateDuoResponseState} action.
  */
+ at SuppressWarnings("javadoc")
 public class ValidateDuoResponseStateTest extends AbstractDuoActionTest{
 
     /** The action to test. */
diff --git a/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/ValidateDuoTokenAuthenticationResultTest.java b/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/ValidateDuoTokenAuthenticationResultTest.java
index 9916c1e4..d584436c 100644
--- a/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/ValidateDuoTokenAuthenticationResultTest.java
+++ b/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/ValidateDuoTokenAuthenticationResultTest.java
@@ -17,7 +17,6 @@ package net.shibboleth.idp.plugin.authn.duo.impl;
 
 import static org.testng.Assert.assertFalse;
 import static org.testng.Assert.assertNotNull;
-import static org.testng.Assert.assertNull;
 import static org.testng.Assert.assertTrue;
 
 import java.security.Principal;
@@ -39,14 +38,18 @@ import org.testng.annotations.Test;
 
 import net.shibboleth.idp.authn.AuthnEventIds;
 import net.shibboleth.idp.authn.context.AuthenticationContext;
+import net.shibboleth.idp.authn.principal.UsernamePrincipal;
 import net.shibboleth.idp.plugin.authn.duo.DuoOIDCAuthAPI;
 import net.shibboleth.idp.plugin.authn.duo.context.DuoOIDCAuthenticationContext;
+import net.shibboleth.idp.profile.testing.ActionTestingSupport;
 import net.shibboleth.idp.saml.authn.principal.AuthnContextClassRefPrincipal;
+import net.shibboleth.shared.collection.CollectionSupport;
 import net.shibboleth.shared.component.ComponentInitializationException;
 
 /**
  * Tests for the {@link ValidateDuoTokenAuthenticationResult} action.
  */
+ at SuppressWarnings("javadoc")
 public class ValidateDuoTokenAuthenticationResultTest extends AbstractDuoActionTest {
 
     /** The action to test. */
@@ -56,10 +59,8 @@ public class ValidateDuoTokenAuthenticationResultTest extends AbstractDuoActionT
     public void setUp() throws Exception {
         super.setup();
         action = new ValidateDuoTokenAuthenticationResult();
-
     }
   
-
     /**
      * Test successful execution.
      * 
@@ -76,8 +77,51 @@ public class ValidateDuoTokenAuthenticationResultTest extends AbstractDuoActionT
         action.initialize();
         
         final Event event = action.execute(src);
-        // success here is a null event
-        assertNull(event);
+        ActionTestingSupport.assertProceedEvent(event);
+    }
+    
+    /**
+     * Test successful execution with factor enforcement.
+     * 
+     * @throws ComponentInitializationException on error.
+     */
+    @Test
+    public void testExecuteSuccessWithFactorCheck() throws ComponentInitializationException {
+        addDuoContext();
+        addPasswordlessDuoIntegrationToContext(CollectionSupport.singleton("duo_push"));
+        addAttemptedFlow("authn/DuoOIDC");
+        dc.setAuthToken(createPlainDummyToken(DuoOIDCAuthAPI.DUO_AUTH_RESULT_ALLOW,"Login Succesful",CLIENT_ID,
+                Instant.now().plus(1,ChronoUnit.MINUTES),Instant.now(), Instant.now(), "api.duosecurity.com", "duo_push"));
+        dc.setUsername("jdoe");
+        action.initialize();
+        
+        final Event event = action.execute(src);
+        ActionTestingSupport.assertProceedEvent(event);
+
+        //check the correct subject has been populated.
+        final var authnResult = ac.getAuthenticationResult();
+        assert authnResult != null;
+        final Subject sbj = authnResult.getSubject();
+        assertTrue(sbj.getPrincipals().contains(new UsernamePrincipal("jdoe")));
+    }
+
+    /**
+     * Test failed execution with factor enforcement.
+     * 
+     * @throws ComponentInitializationException on error.
+     */
+    @Test
+    public void testExecuteFailedWithFactorCheck() throws ComponentInitializationException {
+        addDuoContext();
+        addPasswordlessDuoIntegrationToContext(CollectionSupport.singleton("sms"));
+        addAttemptedFlow("authn/DuoOIDC");
+        dc.setAuthToken(createPlainDummyToken(DuoOIDCAuthAPI.DUO_AUTH_RESULT_ALLOW,"Login Succesful",CLIENT_ID,
+                Instant.now().plus(1,ChronoUnit.MINUTES),Instant.now(), Instant.now(), "api.duosecurity.com", "duo_push"));
+        dc.setUsername("jdoe");
+        action.initialize();
+        
+        final Event event = action.execute(src);
+        assertEventId(event, AuthnEventIds.INVALID_CREDENTIALS);
     }
     
     /**
@@ -135,8 +179,7 @@ public class ValidateDuoTokenAuthenticationResultTest extends AbstractDuoActionT
         action.initialize();
         
         final Event event = action.execute(src);
-        // success here is a null event
-        assertNull(event);
+        ActionTestingSupport.assertProceedEvent(event);
     }
     
     /**
@@ -154,7 +197,7 @@ public class ValidateDuoTokenAuthenticationResultTest extends AbstractDuoActionT
     }
     
     /**
-     * Test validation of a token who's 2FA request was denied.
+     * Test validation of a token whose 2FA request was denied.
      * 
      * @throws ComponentInitializationException on error.
      */
@@ -192,7 +235,6 @@ public class ValidateDuoTokenAuthenticationResultTest extends AbstractDuoActionT
         action.initialize();
         
         final Event event = action.execute(src);
-     
         assertEventId(event, AuthnEventIds.INVALID_AUTHN_CTX);
     }
     
@@ -216,7 +258,6 @@ public class ValidateDuoTokenAuthenticationResultTest extends AbstractDuoActionT
         action.initialize();
         
         final Event event = action.execute(src);
-     
         assertEventId(event, AuthnEventIds.INVALID_CREDENTIALS);
     }
     
@@ -261,8 +302,7 @@ public class ValidateDuoTokenAuthenticationResultTest extends AbstractDuoActionT
         action.initialize();
         
         final Event event = action.execute(src);
-        // success here is a null event
-        assertNull(event);
+        ActionTestingSupport.assertProceedEvent(event);
         
         //check the correct subject has been populated.
         final var ac = prc.getSubcontext(AuthenticationContext.class);
@@ -318,8 +358,7 @@ public class ValidateDuoTokenAuthenticationResultTest extends AbstractDuoActionT
         action.initialize();
         
         final Event event = action.execute(src);
-        // success here is a null event
-        assertNull(event);
+        ActionTestingSupport.assertProceedEvent(event);
         
         //check the correct subject has been populated.
         final var ac = prc.getSubcontext(AuthenticationContext.class);
diff --git a/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/ValidateTokenClaimsTest.java b/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/ValidateTokenClaimsTest.java
index 541c4e12..590b05c7 100644
--- a/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/ValidateTokenClaimsTest.java
+++ b/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/ValidateTokenClaimsTest.java
@@ -49,6 +49,7 @@ import net.shibboleth.shared.component.ComponentInitializationException;
 /**
  * Tests for the {@link ValidateTokenClaims} class.
  */
+ at SuppressWarnings("javadoc")
 public class ValidateTokenClaimsTest extends AbstractDuoActionTest {
 
     /** The action to test. */
diff --git a/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/ValidateTokenSignatureTest.java b/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/ValidateTokenSignatureTest.java
index f7965214..e2f0e916 100644
--- a/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/ValidateTokenSignatureTest.java
+++ b/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/ValidateTokenSignatureTest.java
@@ -49,6 +49,7 @@ import net.shibboleth.shared.logic.ConstraintViolationException;
 /**
  * Tests for the {@link ValidateTokenSignature} class.
  */
+ at SuppressWarnings("javadoc")
 public class ValidateTokenSignatureTest extends AbstractDuoActionTest {
 
     /** The action to test. */
diff --git a/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/mock/MockDuoOIDCClient_FAIL.java b/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/mock/MockDuoOIDCClient_FAIL.java
index 8c3fb268..13ec970d 100644
--- a/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/mock/MockDuoOIDCClient_FAIL.java
+++ b/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/mock/MockDuoOIDCClient_FAIL.java
@@ -52,6 +52,13 @@ public class MockDuoOIDCClient_FAIL implements DuoOIDCClient{
     
     @NonnullAfterInit private final DuoOIDCIntegration integration;
 
+    /**
+     * Constructor.
+     *
+     * @param integ integration
+     * 
+     * @throws DuoClientException on error
+     */
     public MockDuoOIDCClient_FAIL(final @Nonnull DuoOIDCIntegration integ) throws DuoClientException {
         integration = integ;        
     }
diff --git a/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/mock/MockDuoOIDCClient_OK.java b/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/mock/MockDuoOIDCClient_OK.java
index 08b0cfb0..50ce174d 100644
--- a/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/mock/MockDuoOIDCClient_OK.java
+++ b/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/mock/MockDuoOIDCClient_OK.java
@@ -49,6 +49,13 @@ public class MockDuoOIDCClient_OK implements DuoOIDCClient{
     
     @NonnullAfterInit private final DuoOIDCIntegration integration;
     
+    /**
+     * Constructor.
+     *
+     * @param integ integration
+     * 
+     * @throws DuoClientException on error
+     */
     public MockDuoOIDCClient_OK(final @Nonnull DuoOIDCIntegration integ) throws DuoClientException {       
         integration = integ;        
     }
diff --git a/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/mock/MockDuoOIDCClient_OK_OLD_AUTH_TIME.java b/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/mock/MockDuoOIDCClient_OK_OLD_AUTH_TIME.java
index e20c2c9e..f41c1077 100644
--- a/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/mock/MockDuoOIDCClient_OK_OLD_AUTH_TIME.java
+++ b/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/mock/MockDuoOIDCClient_OK_OLD_AUTH_TIME.java
@@ -50,6 +50,13 @@ public class MockDuoOIDCClient_OK_OLD_AUTH_TIME implements DuoOIDCClient{
     
     @NonnullAfterInit private final DuoOIDCIntegration integration;
     
+    /**
+     * Constructor.
+     *
+     * @param integ integration
+     * 
+     * @throws DuoClientException on error
+     */
     public MockDuoOIDCClient_OK_OLD_AUTH_TIME(
             final @Nonnull DuoOIDCIntegration integ) throws DuoClientException {       
         integration = integ;        
diff --git a/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/mock/MockDuoOIDCClient_UNKNOWN.java b/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/mock/MockDuoOIDCClient_UNKNOWN.java
index e8353c7a..f82c95b1 100644
--- a/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/mock/MockDuoOIDCClient_UNKNOWN.java
+++ b/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/mock/MockDuoOIDCClient_UNKNOWN.java
@@ -49,6 +49,13 @@ public class MockDuoOIDCClient_UNKNOWN implements DuoOIDCClient{
     
     @NonnullAfterInit private final DuoOIDCIntegration integration;
 
+    /**
+     * Constructor.
+     *
+     * @param integ integration
+     * 
+     * @throws DuoClientException on error
+     */
     public MockDuoOIDCClient_UNKNOWN(final @Nonnull DuoOIDCIntegration integ) throws DuoClientException {
         integration = integ;        
     }
diff --git a/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/util/mock/IdPPropertyConfigurer.java b/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/util/mock/IdPPropertyConfigurer.java
index 003b64cb..ceff4c77 100644
--- a/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/util/mock/IdPPropertyConfigurer.java
+++ b/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/util/mock/IdPPropertyConfigurer.java
@@ -14,6 +14,8 @@
 
 package net.shibboleth.idp.plugin.authn.util.mock;
 
+import javax.annotation.Nonnull;
+
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.context.support.PropertySourcesPlaceholderConfigurer;
@@ -25,10 +27,18 @@ import org.springframework.context.support.PropertySourcesPlaceholderConfigurer;
 @Configuration
 public class IdPPropertyConfigurer {
     
+    /**
+     * Install a property placeholder configurer.
+     * 
+     * @return the object
+     * 
+     * @throws Exception on error
+     */
     @Bean
-    public static PropertySourcesPlaceholderConfigurer properties() throws Exception {
+    @Nonnull public static PropertySourcesPlaceholderConfigurer properties() throws Exception {
         final PropertySourcesPlaceholderConfigurer pspc = new PropertySourcesPlaceholderConfigurer();
         pspc.setPlaceholderPrefix("%{");
+        pspc.setPlaceholderSuffix("}");
         return pspc;
     }
 
diff --git a/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/util/mock/TestResourceConverter.java b/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/util/mock/TestResourceConverter.java
new file mode 100644
index 00000000..8863c0b4
--- /dev/null
+++ b/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/util/mock/TestResourceConverter.java
@@ -0,0 +1,127 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.shibboleth.idp.plugin.authn.util.mock;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URI;
+import java.net.URL;
+
+import javax.annotation.Nonnull;
+
+import org.springframework.core.io.Resource;
+
+import net.shibboleth.shared.logic.Constraint;
+
+/**
+ * Bridging class between {@link Resource} and {@link net.shibboleth.shared.resource.Resource}.
+ */
+public final class TestResourceConverter implements net.shibboleth.shared.resource.Resource {
+
+    /** The cached Spring {@link Resource}. */
+    private Resource springResource;
+
+    /**
+     * A private for shimming the provided input.
+     * 
+     * @param theResource the spring resource;
+     */
+    private TestResourceConverter(@Nonnull Resource theResource) {
+
+        springResource = Constraint.isNotNull(theResource, "provided Spring Resource should not be null");
+    }
+
+    /**
+     * Return a {@link Resource} that does all the work of the provided {@link Resource}.
+     * 
+     * <p>
+     * If the input implements {@link Resource} then it is cast to the output, other a shim class is
+     * generated.
+     * </p>
+     * 
+     * @param springResource the input
+     * @return a {@link Resource} which reflects what the Spring one does
+     */
+    @Nonnull public static net.shibboleth.shared.resource.Resource of(@Nonnull Resource springResource) {
+        if (springResource instanceof net.shibboleth.shared.resource.Resource) {
+            return (net.shibboleth.shared.resource.Resource) springResource;
+        }
+        return new TestResourceConverter(springResource);
+    }
+
+    /** {@inheritDoc} */
+    @Override @Nonnull public InputStream getInputStream() throws IOException {
+        return springResource.getInputStream();
+    }
+
+    /** {@inheritDoc} */
+    @Override public boolean exists() {
+        return springResource.exists();
+    }
+
+    /** {@inheritDoc} */
+    @Override public boolean isReadable() {
+        return springResource.isReadable();
+    }
+
+    /** {@inheritDoc} */
+    @Override public boolean isOpen() {
+        return springResource.isOpen();
+    }
+
+    /** {@inheritDoc} */
+    @Override @Nonnull public URL getURL() throws IOException {
+        return springResource.getURL();
+    }
+
+    /** {@inheritDoc} */
+    @Override @Nonnull public URI getURI() throws IOException {
+        return springResource.getURI();
+    }
+
+    /** {@inheritDoc} */
+    @Override @Nonnull public File getFile() throws IOException {
+        return springResource.getFile();
+    }
+
+    /** {@inheritDoc} */
+    @Override public long contentLength() throws IOException {
+        return springResource.contentLength();
+    }
+
+    /** {@inheritDoc} */
+    @Override public long lastModified() throws IOException {
+        return springResource.lastModified();
+    }
+
+    /** {@inheritDoc} */
+    @Override @Nonnull public net.shibboleth.shared.resource.Resource createRelativeResource(
+            @Nonnull String relativePath) throws IOException {
+
+        return of(springResource.createRelative(relativePath));
+    }
+
+    /** {@inheritDoc} */
+    @Override public String getFilename() {
+        return springResource.getFilename();
+    }
+
+    /** {@inheritDoc} */
+    @Override @Nonnull public String getDescription() {
+        return springResource.getDescription();
+    }
+
+}
diff --git a/idp-duo-impl/src/test/resources/net/shibboleth/idp/plugin/authn/duo/impl/SealerKeyStore.jks b/idp-duo-impl/src/test/resources/net/shibboleth/idp/plugin/authn/duo/impl/SealerKeyStore.jks
new file mode 100644
index 00000000..147d92bb
Binary files /dev/null and b/idp-duo-impl/src/test/resources/net/shibboleth/idp/plugin/authn/duo/impl/SealerKeyStore.jks differ
diff --git a/idp-duo-impl/src/test/resources/net/shibboleth/idp/plugin/authn/duo/impl/SealerKeyStore.kver b/idp-duo-impl/src/test/resources/net/shibboleth/idp/plugin/authn/duo/impl/SealerKeyStore.kver
new file mode 100644
index 00000000..2cd48df3
--- /dev/null
+++ b/idp-duo-impl/src/test/resources/net/shibboleth/idp/plugin/authn/duo/impl/SealerKeyStore.kver
@@ -0,0 +1 @@
+CurrentVersion = 1
diff --git a/idp-duo-nimbus-client-impl/src/main/resources/net/shibboleth/idp/plugin/authn/duo/nimbus/module.properties b/idp-duo-nimbus-client-impl/src/main/resources/net/shibboleth/idp/plugin/authn/duo/nimbus/module.properties
index 7865f52f..4c98e4f2 100644
--- a/idp-duo-nimbus-client-impl/src/main/resources/net/shibboleth/idp/plugin/authn/duo/nimbus/module.properties
+++ b/idp-duo-nimbus-client-impl/src/main/resources/net/shibboleth/idp/plugin/authn/duo/nimbus/module.properties
@@ -13,3 +13,5 @@ idp.authn.DuoOIDC.1.src = /net/shibboleth/idp/plugin/authn/duo/nimbus/conf/authn
 idp.authn.DuoOIDC.1.dest = conf/authn/duo-oidc-authn-config.xml
 idp.authn.DuoOIDC.2.src = /net/shibboleth/idp/plugin/authn/duo/nimbus/conf/authn/duo-oidc.properties
 idp.authn.DuoOIDC.2.dest = conf/authn/duo-oidc.properties
+idp.authn.DuoOIDC.3.src = /net/shibboleth/idp/plugin/authn/duo/views/passwordless.vm
+idp.authn.DuoOIDC.3.dest = views/passwordless.vm
diff --git a/idp-duo-sdk-client-impl/src/main/resources/net/shibboleth/idp/plugin/authn/duo/sdk/module.properties b/idp-duo-sdk-client-impl/src/main/resources/net/shibboleth/idp/plugin/authn/duo/sdk/module.properties
index b0d73671..e2ff44b0 100644
--- a/idp-duo-sdk-client-impl/src/main/resources/net/shibboleth/idp/plugin/authn/duo/sdk/module.properties
+++ b/idp-duo-sdk-client-impl/src/main/resources/net/shibboleth/idp/plugin/authn/duo/sdk/module.properties
@@ -13,3 +13,5 @@ idp.authn.DuoOIDC.1.src = /net/shibboleth/idp/plugin/authn/duo/sdk/conf/authn/du
 idp.authn.DuoOIDC.1.dest = conf/authn/duo-oidc-authn-config.xml
 idp.authn.DuoOIDC.2.src = /net/shibboleth/idp/plugin/authn/duo/sdk/conf/authn/duo-oidc.properties
 idp.authn.DuoOIDC.2.dest = conf/authn/duo-oidc.properties
+idp.authn.DuoOIDC.3.src = /net/shibboleth/idp/plugin/authn/duo/views/passwordless.vm
+idp.authn.DuoOIDC.3.dest = views/passwordless.vm
diff --git a/pom.xml b/pom.xml
index d3ce4ef9..19d18657 100644
--- a/pom.xml
+++ b/pom.xml
@@ -26,7 +26,7 @@
         <opensaml.groupId>org.opensaml</opensaml.groupId>
         <opensaml.version>5.0.0</opensaml.version>
         <oidc-common.groupId>net.shibboleth.oidc</oidc-common.groupId>
-        <oidc-common.version>3.0.1-SNAPSHOT</oidc-common.version>
+        <oidc-common.version>3.1.0-SNAPSHOT</oidc-common.version>
         <shib-shared.groupId>net.shibboleth</shib-shared.groupId>
         <shib-shared.version>9.0.0</shib-shared.version>
         <shib-profile.groupId>net.shibboleth</shib-profile.groupId>

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


More information about the commits mailing list