[java-idp-plugin-duo] branch dev/JDUO-80 updated: JDUO-80 - More WIP, closer to a working model.

Scott Cantor cantor.2 at osu.edu
Thu Dec 21 20:21:22 UTC 2023


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

scantor pushed a commit to branch dev/JDUO-80
in repository java-idp-plugin-duo.

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

The following commit(s) were added to refs/heads/dev/JDUO-80 by this push:
     new 697810c0 JDUO-80 - More WIP, closer to a working model.
697810c0 is described below

commit 697810c02e48caf3dde44ec15a57df53f586bcba
Author: Scott Cantor <cantor.2 at osu.edu>
AuthorDate: Thu Dec 21 15:21:19 2023 -0500

    JDUO-80 - More WIP, closer to a working model.
---
 .../plugin/authn/duo/DefaultDuoCleanupHook.java    | 152 +++++++++
 .../authn/duo/DefaultDuoOIDCIntegration.java       |  27 +-
 .../idp/plugin/authn/duo/DuoOIDCIntegration.java   |  16 +-
 .../plugin/authn/duo/SimpleDuoOIDCIntegration.java |  33 +-
 .../authn/duo/context/DuoPasswordlessContext.java  |  12 +-
 .../duo/impl/CheckPasswordlessEnrollment.java      | 346 +++++++++++++++++++++
 .../duo/impl/ExtractPasswordlessUsername.java      | 230 --------------
 .../duo/impl/PopulateDuoAuthenticationContext.java | 204 ++++++++----
 .../impl/ValidateDuoTokenAuthenticationResult.java |  28 +-
 .../flows/authn/DuoOIDC/duo-oidc-authn-beans.xml   |  45 ++-
 .../flows/authn/DuoOIDC/duo-oidc-authn-flow.xml    |  41 ++-
 .../nimbus/views/{username.vm => passwordless.vm}  |  11 +-
 12 files changed, 773 insertions(+), 372 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..365147a8
--- /dev/null
+++ b/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/DefaultDuoCleanupHook.java
@@ -0,0 +1,152 @@
+/*
+ * 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;
+    }
+    
+    /** {@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);
+            }
+        }
+    }
+
+}
\ 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..4f189459 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;
     
@@ -84,12 +87,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.
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..9d49885e 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
@@ -78,7 +78,17 @@ 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;
+    }
 
-}
+}
\ No newline at end of file
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..29add449 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,11 +36,16 @@ 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
+ at Deprecated(forRemoval=true, since="2.1.0")
 public final class SimpleDuoOIDCIntegration 
-            extends AbstractInitializableComponent implements DuoOIDCIntegration{
+            extends AbstractInitializableComponent implements DuoOIDCIntegration {
         
     /** API host. */
     @GuardedBy("this") @NonnullAfterInit @NotEmpty private String apiHost;
@@ -84,8 +89,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 +107,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 +126,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 +145,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 +162,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 +174,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 +192,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 +223,7 @@ public final class SimpleDuoOIDCIntegration
      */
     public synchronized <T extends Principal> void setSupportedPrincipals(
             @Nullable @NonnullElements final Collection<T> principals) {
-        ifInitializedThrowUnmodifiabledComponentException();
-        ifDestroyedThrowDestroyedComponentException();
+        checkSetterPreconditions();
         
         supportedPrincipals.getPrincipals().clear();
         
diff --git a/idp-duo-api/src/main/java/net/shibboleth/idp/authn/duo/context/DuoPasswordlessContext.java b/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/context/DuoPasswordlessContext.java
similarity index 87%
rename from idp-duo-api/src/main/java/net/shibboleth/idp/authn/duo/context/DuoPasswordlessContext.java
rename to idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/context/DuoPasswordlessContext.java
index 9eab6c28..26627774 100644
--- a/idp-duo-api/src/main/java/net/shibboleth/idp/authn/duo/context/DuoPasswordlessContext.java
+++ b/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/context/DuoPasswordlessContext.java
@@ -12,7 +12,9 @@
  * limitations under the License.
  */
 
-package net.shibboleth.idp.authn.duo.context;
+package net.shibboleth.idp.plugin.authn.duo.context;
+
+import java.util.Objects;
 
 import javax.annotation.Nonnull;
 import javax.annotation.Nullable;
@@ -53,12 +55,18 @@ public final class DuoPasswordlessContext extends BaseContext {
     /**
      * 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) {
-        username = name;
+        if (!Objects.equals(name, username)) {
+            username = name;
+            enrolled = false;
+        }
         return this;
     }
     
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..983f5620
--- /dev/null
+++ b/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/impl/CheckPasswordlessEnrollment.java
@@ -0,0 +1,346 @@
+/*
+ * 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 com.google.common.net.UrlEscapers;
+
+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.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: Real default once implemented.
+        passwordlessCondition = (a,b) -> { return false; };
+            
+        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);
+        return passwordlessContext != null;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext,
+            @Nonnull final AuthenticationContext authenticationContext) {
+        
+        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);
+            }
+        } 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);
+                }
+            } else {
+                username = getUsernameFromSession(profileRequestContext);
+                if (username != null && !username.equals(passwordlessContext.getUsername())) {
+                    log.debug("{} Populating username '{}' from session into Duo passwordless context", getLogPrefix(),
+                            username);
+                    passwordlessContext.setUsername(username);
+                }
+            }
+        }
+
+        if (passwordlessContext.getUsername() == null) {
+            passwordlessContext.setEnrolled(false);
+            ActionSupport.buildEvent(profileRequestContext, AuthnEventIds.UNKNOWN_USERNAME);
+            return;
+        }
+        
+        if (!passwordlessContext.isEnrolled()) {
+            passwordlessContext.setEnrolled(
+                    passwordlessCondition.test(profileRequestContext, passwordlessContext.getUsername()));
+        }
+        
+        log.debug("{} Username '{}' found to be {} of passwordless attempt", getLogPrefix(),
+                passwordlessContext.getUsername(), passwordlessContext.isEnrolled() ? "capable" : "incapable");
+        if (!passwordlessContext.isEnrolled()) {
+            ActionSupport.buildEvent(profileRequestContext, AuthnEventIds.REQUEST_UNSUPPORTED);
+        }
+    }
+    
+    /**
+     * 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 = cookieManager.getCookieValue(cookieName, null);
+            if (cookie != null) {
+                try {
+                    assert dataSealer != null;
+                    return dataSealer.unwrap(UrlEscapers.urlFormParameterEscaper().escape(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
+     * 
+     * @return username from existing session, or null
+     */
+    @Nullable private String getUsernameFromSession(@Nonnull final ProfileRequestContext profileRequestContext) {
+        
+        if (checkSession) {
+            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/ExtractPasswordlessUsername.java b/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/impl/ExtractPasswordlessUsername.java
deleted file mode 100644
index 1fae5dfc..00000000
--- a/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/impl/ExtractPasswordlessUsername.java
+++ /dev/null
@@ -1,230 +0,0 @@
-/*
- * 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.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 com.google.common.net.UrlEscapers;
-
-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.authn.duo.context.DuoPasswordlessContext;
-import net.shibboleth.idp.session.IdPSession;
-import net.shibboleth.idp.session.context.SessionContext;
-import net.shibboleth.shared.logic.Constraint;
-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;
-
-/**
- * An action to populate a username into a {@link DuoPasswordlessContext}, either from a form
- * submission, a cookie, or an existing session.
- * 
- * <p>If no username is found, then {@link AuthnEventIds#UNKNOWN_USERNAME} is signaled.</p>
- * 
- * @event {@link EventIds#PROCEED_EVENT_ID}
- * @event {@link EventIds#INVALID_PROFILE_CTX}
- * @event {@link AuthnEventIds#UNKNOWN_USERNAME}
- * @post {@link DuoPasswordlessContext#setUsername(String)} is called with an existing value if found.
- * 
- * @since 2.1.0
- */
-public class ExtractPasswordlessUsername extends AbstractExtractionAction {
-
-    /** Cookie name to cache username. */
-    @Nonnull public static final String COOKIE_NAME = "_shibidp_duo_username";
-    
-    /** Class logger. */
-    @Nonnull private final Logger log = LoggerFactory.getLogger(ExtractPasswordlessUsername.class);
-    
-    /** Strategy used to locate the {@link DuoPasswordlessContext} to populate. */
-    @Nonnull private Function<ProfileRequestContext,DuoPasswordlessContext> duoPasswordlessContextLookupStrategy;
-
-    /** Form parameter name to carry username. */
-    @Nonnull private String usernameFieldName = "j_username";
-    
-    /** Optional cookie manager to use. */
-    @Nullable private CookieManager cookieManager;
-
-    /** Optional data sealer to use. */
-    @Nullable private DataSealer dataSealer;
-    
-    /** Constructor.*/
-    public ExtractPasswordlessUsername() {
-        duoPasswordlessContextLookupStrategy =
-                new ChildContextLookup<>(DuoPasswordlessContext.class, true).compose(
-                        new ChildContextLookup<>(AuthenticationContext.class));
-    }
-    
-    /**
-     * 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");
-    }
-    
-    /**
-     * Sets the name of the form field to carry the username.
-     * 
-     * @param name field name
-     */
-    public void setUsernameFieldName(@Nonnull final String name) {
-        usernameFieldName = Constraint.isNotNull(StringSupport.trimOrNull(name) ,
-                "Username form field name cannot be null or empty");
-    }
-
-    /**
-     * 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;
-    }
-
-    /** {@inheritDoc} */
-    @Override
-    protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext,
-            @Nonnull final AuthenticationContext authenticationContext) {
-        
-        final DuoPasswordlessContext context = duoPasswordlessContextLookupStrategy.apply(profileRequestContext);
-        if (context == null) {
-            log.error("{} Error locating DuoPasswordlessContext", getLogPrefix());
-            ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_PROFILE_CTX);
-            return;
-        }
-        
-        String username = getUsernameFromForm();
-        if (username != null) {
-            log.debug("{} Populating username '{}' from form submission into Duo passwordless context", getLogPrefix(),
-                    username);
-            context.setUsername(username);
-            return;
-        }
-        
-        username = getUsernameFromCookie(profileRequestContext);
-        if (username != null) {
-            log.debug("{} Populating cached username '{}' from cookie into Duo passwordless context", getLogPrefix(),
-                    username);
-            context.setUsername(username);
-            return;
-        }
-
-        username = getUsernameFromSession(profileRequestContext);
-        if (username != null) {
-            log.debug("{} Populating username '{}' from session into Duo passwordless context", getLogPrefix(),
-                    username);
-            context.setUsername(username);
-            return;
-        }
-
-        if (context.getUsername() == null) {
-            ActionSupport.buildEvent(profileRequestContext, AuthnEventIds.UNKNOWN_USERNAME);
-        }
-    }
-    
-    /**
-     * Gets the username from a form submission.
-     * 
-     * @return submitted username, after applying any configured transforms
-     */
-    @Nullable private String getUsernameFromForm() {
-        
-        final HttpServletRequest request = getHttpServletRequest();
-        if (request != null) {
-            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) {
-            final String cookie = cookieManager.getCookieValue(COOKIE_NAME, null);
-            if (cookie != null) {
-                try {
-                    assert dataSealer != null;
-                    return dataSealer.unwrap(UrlEscapers.urlFormParameterEscaper().escape(cookie));
-                } catch (final DataSealerException e) {
-                    log.warn("{} Unable to unwrap sealed username cookie", getLogPrefix(), e);
-                    assert cookieManager != null;
-                    cookieManager.unsetCookie(COOKIE_NAME);
-                }
-            }
-        }
-        
-        return null;
-    }
-    
-    /**
-     * Gets the username from an existing {@link IdPSession}, if any.
-     * 
-     * @param profileRequestContext profile request context
-     * 
-     * @return username from existing session, or null
-     */
-    @Nullable private String getUsernameFromSession(@Nonnull final ProfileRequestContext profileRequestContext) {
-        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..917e9ef4 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,8 +137,7 @@ 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");
@@ -139,8 +150,7 @@ public class PopulateDuoAuthenticationContext extends AbstractAuthenticationActi
      */
     public void setDuoContextCreationStrategy(
             @Nonnull final Function<ProfileRequestContext,DuoOIDCAuthenticationContext> strategy) {
-        ifInitializedThrowUnmodifiabledComponentException();
-        ifDestroyedThrowDestroyedComponentException();
+        checkSetterPreconditions();
 
         duoAuthContextCreationStrategy = Constraint.isNotNull(strategy, "DuoAuthenticationContext"
                 + " creation strategy cannot be null");
@@ -151,14 +161,26 @@ public class PopulateDuoAuthenticationContext extends AbstractAuthenticationActi
      * 
      * @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 DuoIntegration lookup strategy to use.
+     * 
+     * @param strategy lookup strategy
+     */
+    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 +195,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 +368,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..7e7d7dab 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,6 @@ import java.security.Principal;
 import java.text.ParseException;
 import java.util.Collection;
 import java.util.Map;
-import java.util.function.Consumer;
 import java.util.function.Function;
 import java.util.stream.Collectors;
 
@@ -113,8 +112,7 @@ public class ValidateDuoTokenAuthenticationResult extends AbstractAuditingValida
      */
     public void setContextToPrincipalMappingStrategy(@Nullable final 
             Function<ProfileRequestContext,Collection<Principal>> hook) {
-        ifInitializedThrowUnmodifiabledComponentException();
-        ifDestroyedThrowDestroyedComponentException();
+        checkSetterPreconditions();
         
         contextToPrincipalMappingStrategy = hook;
     }
@@ -183,7 +181,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 {
@@ -310,26 +307,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 62402c59..46023528 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
@@ -17,14 +17,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 +31,12 @@
     <bean id="shibboleth.authn.DuoOIDC.DuoIntegrationStrategy" parent="shibboleth.Functions.Constant"
         c:target-ref="shibboleth.authn.DuoOIDC.DuoIntegration" />
 
+    <!-- Default strategy for passwordless integration. -->
+    <bean id="shibboleth.authn.DuoOIDC.PasswordlessDuoIntegrationStrategy" parent="shibboleth.Functions.Constant"
+        c:target="#{getObject('shibboleth.authn.DuoOIDC.PasswordlessDuoIntegration')}" />
+
     <!-- 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 +49,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
@@ -98,20 +101,27 @@
         p:resultCachingPredicate="#{getObject('shibboleth.authn.DuoOIDC.resultCachingPredicate')}" />
 
     <!-- Passwordless beans  -->
-    <bean id="ExtractPasswordlessUsername"
-        class="net.shibboleth.idp.plugin.authn.duo.impl.ExtractPasswordlessUsername" scope="prototype"
+    <bean id="CheckPasswordlessEnrollment"
+        class="net.shibboleth.idp.plugin.authn.duo.impl.CheckPasswordlessEnrollment" scope="prototype"
         p:httpServletRequestSupplier-ref="shibboleth.HttpServletRequestSupplier"
+        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.DuoOIDC.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')}" />
-
+        
+    <alias alias="PasswordlessEnrollmentCheck" name="shibboleth.authn.DuoOIDC.PasswordlessEnrollmentCheck" />
+        
     <!-- 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.PasswordlessDuoIntegrationStrategy"
         p:redirectURICreationStrategy-ref="shibboleth.authn.DuoOIDC.RedirectURICreationStrategy"
         p:usernameLookupStrategy-ref="shibboleth.authn.DuoOIDC.UsernameLookupStrategy"
         p:clientRegistry-ref="shibboleth.authn.DuoOIDC.clientRegistry" />
@@ -232,7 +242,10 @@
         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"        
@@ -246,10 +259,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 d00ced49..bd57d8e1 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
@@ -24,31 +24,26 @@
     </action-state>
     
     <decision-state id="CheckForPasswordless">
-        <if test="opensamlProfileRequestContext.containsSubcontext(T(net.shibboleth.idp.authn.duo.context.DuoPasswordlessContext))"
-            then="ExtractPasswordlessUsername"
+        <if test="opensamlProfileRequestContext.containsSubcontext(T(net.shibboleth.idp.plugin.authn.duo.context.DuoPasswordlessContext))"
+            then="CheckPasswordlessEnrollment1"
             else="CheckDuoOIDCAuthAPI" />
     </decision-state>
     
-    <action-state id="ExtractPasswordlessUsername">
-        <evaluate expression="ExtractPasswordlessUsername" />
+    <action-state id="CheckPasswordlessEnrollment1">
+        <evaluate expression="CheckPasswordlessEnrollment" />
         <evaluate expression="'proceed'" />
         
-        <transition on="proceed" to="CheckEnrollmentStatus" />
-        <transition on="UnknownUsername" to="UsernameCollectionView" />
+        <transition on="proceed" to="PasswordlessView" />
+        <transition on="UnknownUsername" to="PasswordlessView" />
+        <transition on="RequestUnsupported" to="PasswordlessView" />
     </action-state>
 
-    <action-state id="CheckEnrollmentStatus">
-        <evaluate expression="CheckEnrollmentStatus" />
-        <evaluate expression="'proceed'" />
-        
-        <transition on="proceed" to="UsernameCollectionView" />
-    </action-state>
-    
-    <view-state id="UsernameCollectionView" view="username">
+    <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('shibboleth.CSPDigester')" result="viewScope.cspDigester" />
@@ -58,16 +53,26 @@
             <evaluate expression="flowRequestContext.getActiveFlow().getApplicationContext().containsBean('shibboleth.CustomViewContext') ? flowRequestContext.getActiveFlow().getApplicationContext().getBean('shibboleth.CustomViewContext') : null" result="viewScope.custom" />
         </on-render>
 
-        <transition on="proceed" to="CheckDuoOIDCAuthAPI" />
-        <transition on="CheckEnrollmentStatus" to="CheckEnrollmentStatus" />
+        <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>
     
@@ -89,6 +94,7 @@
         <evaluate expression="PreStateValidationPopulateAuditContext" />
         <evaluate expression="ValidateDuoResponseState"/>  
         <evaluate expression="'proceed'" />
+        
         <transition on="proceed" to="ExchangeCodeForDuoToken" />
     </action-state>
     
@@ -98,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/nimbus/views/username.vm b/idp-duo-impl/src/main/resources/net/shibboleth/idp/plugin/authn/duo/nimbus/views/passwordless.vm
similarity index 89%
rename from idp-duo-impl/src/main/resources/net/shibboleth/idp/plugin/authn/duo/nimbus/views/username.vm
rename to idp-duo-impl/src/main/resources/net/shibboleth/idp/plugin/authn/duo/nimbus/views/passwordless.vm
index 6a7c9845..13c35e05 100644
--- a/idp-duo-impl/src/main/resources/net/shibboleth/idp/plugin/authn/duo/nimbus/views/username.vm
+++ b/idp-duo-impl/src/main/resources/net/shibboleth/idp/plugin/authn/duo/nimbus/views/passwordless.vm
@@ -7,6 +7,7 @@
 ## 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)
@@ -17,7 +18,7 @@
 ## custom - arbitrary object injected by deployer
 ##
 #set ($rpContext = $profileRequestContext.getSubcontext('net.shibboleth.profile.context.RelyingPartyContext'))
-#set ($username = $authenticationContext.getSubcontext('net.shibboleth.idp.authn.duo.context.DuoPasswordlessContext').getUsername())
+#set ($username = $passwordlessContext.getUsername())
 ##
 <!DOCTYPE html>
 <html>
@@ -68,12 +69,16 @@
                         value="#if($username)$encoder.encodeForHTML($username)#end" />
                         
                     <input type="checkbox" name="donotcache" value="1" id="donotcache" />
-                    <label for="donotcache">#springMessageText("idp.duo.donotcache", "Don't Remember Me")</label>
+                    <label for="donotcache">#springMessageText("idp.login.donotcache", "Don't Remember Login")</label>
     
                     <div class="grid">
                         <div class="grid-item">
                             <button type="submit" name="_eventId_proceed"
-                                >#springMessageText("idp.duo.continue", "Continue")</button>
+                                >#springMessageText("idp.duo.passwordless", "Passwordless Login")</button>
+                        </div>
+                        <div class="grid-item">
+                            <button type="submit" name="_eventId_cancel"
+                                >#springMessageText("idp.duo.cancel", "Password Login")</button>
                         </div>
                     </div>
                 </form>

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


More information about the commits mailing list