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

Scott Cantor cantor.2 at osu.edu
Fri Apr 5 16:55:21 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=df1145c71415cdf8e963312aa61022756dd891df

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

commit df1145c71415cdf8e963312aa61022756dd891df
Author: Scott Cantor <cantor.2 at osu.edu>
AuthorDate: Fri Apr 5 12:55:18 2024 -0400

    JDUO-80 - Use of Duo as a Passwordless solution
    
    https://shibboleth.atlassian.net/browse/JDUO-80
    
    Revamped design using an opt-in view governed by a pluggable condition.
    Username now comes from cookie only and is read-only.
    Corrected issues around the shared machine and revoke consent options.
    Revamped properties and cleaned up some naming issues.
---
 .../authn/duo/DefaultPasswordlessCondition.java    |  86 ++++
 .../DefaultPasswordlessEnrollmentCondition.java    | 227 ----------
 .../duo/context/DuoOIDCAuthenticationContext.java  |  30 +-
 .../authn/duo/context/DuoPasswordlessContext.java  |  39 +-
 .../duo/impl/CheckPasswordlessEnrollment.java      | 493 ---------------------
 .../authn/duo/impl/CreatePasswordlessCookie.java   | 125 ++++++
 .../duo/impl/PopulateDuoAuthenticationContext.java |  40 +-
 .../duo/impl/PopulatePasswordlessContext.java      | 179 ++++++++
 .../impl/PostValidatePasswordlessEvaluation.java   | 269 +++++++++++
 .../impl/ValidateDuoTokenAuthenticationResult.java |  23 +-
 .../META-INF/net.shibboleth.idp/postconfig.xml     |  43 --
 .../flows/authn/DuoOIDC/duo-oidc-authn-beans.xml   |  59 +--
 .../flows/authn/DuoOIDC/duo-oidc-authn-flow.xml    |  59 ++-
 .../idp/plugin/authn/duo/messages.properties       |  12 +-
 .../plugin/authn/duo/views/passwordless-optin.vm   |  62 +++
 .../idp/plugin/authn/duo/views/passwordless.vm     |  32 +-
 .../impl/AbstractAuthnXmlFlowExecutionTests.java   |   6 -
 .../duo/impl/CheckPasswordlessEnrollmentTest.java  | 348 ---------------
 .../duo/nimbus/conf/authn/duo-oidc.properties      |  12 +-
 .../idp/plugin/authn/duo/nimbus/module.properties  |   2 +
 .../authn/duo/sdk/conf/authn/duo-oidc.properties   |  12 +-
 .../idp/plugin/authn/duo/sdk/module.properties     |   2 +
 22 files changed, 905 insertions(+), 1255 deletions(-)

diff --git a/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/DefaultPasswordlessCondition.java b/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/DefaultPasswordlessCondition.java
new file mode 100644
index 00000000..2290b44d
--- /dev/null
+++ b/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/DefaultPasswordlessCondition.java
@@ -0,0 +1,86 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.shibboleth.idp.plugin.authn.duo;
+
+import java.util.Collection;
+import java.util.Set;
+import java.util.function.Predicate;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.opensaml.profile.context.ProfileRequestContext;
+
+import net.shibboleth.idp.authn.context.AuthenticationContext;
+import net.shibboleth.idp.plugin.authn.duo.context.DuoOIDCAuthenticationContext;
+import net.shibboleth.shared.annotation.constraint.NonnullElements;
+import net.shibboleth.shared.collection.CollectionSupport;
+import net.shibboleth.shared.component.AbstractInitializableComponent;
+import net.shibboleth.shared.primitive.StringSupport;
+
+/**
+ * A {@link Predicate} which determines whether a user/device should be allowed,
+ * on subsequent requests, to leverage passwordless authentication.
+ * 
+ * <p>This is a hook to nmanage the adoption of passwordless by
+ * controlling under what conditions a device will receive a cookie that
+ * authorizes the option when invoked in that mode. The default condition
+ * is driven solely by examining the factor used by the user, but this
+ * may be combined by deployers with other custom conditions.</p>
+ * 
+ * @since 2.1.0
+ */
+public class DefaultPasswordlessCondition extends AbstractInitializableComponent
+        implements Predicate<ProfileRequestContext> {
+
+    /** Allowed factors that qualify. */
+    @Nonnull @NonnullElements private Set<String> allowedFactors;
+    
+    /** Constructor. */
+    public DefaultPasswordlessCondition() {
+        allowedFactors = CollectionSupport.emptySet();
+    }
+    
+    /**
+     * Sets the allowable Duo factors that qualify for the condition.
+     * 
+     * @param factors allowed factors
+     */
+    public void setAllowedFactors(@Nonnull @NonnullElements final Collection<String> factors) {
+        checkSetterPreconditions();
+        allowedFactors = CollectionSupport.copyToSet(StringSupport.normalizeStringCollection(factors));
+    }
+    
+    /** {@inheritDoc} */
+    public boolean test(@Nullable final ProfileRequestContext input) {
+        checkComponentActive();
+
+        final AuthenticationContext authenticationContext = input != null ?
+                input.getSubcontext(AuthenticationContext.class) : null;
+
+        if (authenticationContext == null) {
+            return false;
+        }
+        
+        final DuoOIDCAuthenticationContext duoContext =
+                authenticationContext.getSubcontext(DuoOIDCAuthenticationContext.class);
+        if (duoContext == null || duoContext.getFactorUsed() == null) {
+            return false;
+        }
+        
+        return allowedFactors.contains(duoContext.getFactorUsed());
+    }
+
+}
\ No newline at end of file
diff --git a/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/DefaultPasswordlessEnrollmentCondition.java b/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/DefaultPasswordlessEnrollmentCondition.java
deleted file mode 100644
index 84138249..00000000
--- a/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/DefaultPasswordlessEnrollmentCondition.java
+++ /dev/null
@@ -1,227 +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;
-
-import java.util.Collection;
-import java.util.List;
-import java.util.Set;
-import java.util.function.BiPredicate;
-import java.util.function.Function;
-import java.util.stream.Collectors;
-
-import javax.annotation.Nonnull;
-import javax.annotation.Nullable;
-
-import org.opensaml.profile.context.ProfileRequestContext;
-import org.slf4j.Logger;
-
-import jakarta.servlet.http.HttpServletRequest;
-import net.shibboleth.idp.authn.AccountLockoutManager;
-import net.shibboleth.idp.plugin.authn.duo.model.User;
-import net.shibboleth.idp.plugin.authn.duo.model.WebAuthnCredential;
-import net.shibboleth.shared.annotation.constraint.NonnullAfterInit;
-import net.shibboleth.shared.annotation.constraint.NonnullElements;
-import net.shibboleth.shared.collection.CollectionSupport;
-import net.shibboleth.shared.component.AbstractInitializableComponent;
-import net.shibboleth.shared.component.ComponentInitializationException;
-import net.shibboleth.shared.logic.Constraint;
-import net.shibboleth.shared.primitive.LoggerFactory;
-import net.shibboleth.shared.primitive.NonnullSupplier;
-import net.shibboleth.shared.primitive.StringSupport;
-import net.shibboleth.shared.servlet.HttpServletSupport;
-
-/**
- * A BiPredicate which checks the enrollment status of a user against the Duo Admin APIs.
- * 
- * <p>The second parameter is the username to evaluate.</p>
- * 
- * @since 2.1.0
- */
-public class DefaultPasswordlessEnrollmentCondition extends AbstractInitializableComponent
-        implements BiPredicate<ProfileRequestContext,String> {
-    
-    /** Class logger. */
-    @Nonnull private final Logger log = LoggerFactory.getLogger(DefaultPasswordlessEnrollmentCondition.class);
-    
-    /** The admin client used to access the DuoAdmin API.*/
-    @NonnullAfterInit private DuoAdminClient adminClient;
-
-    /** Allowed WebAuthnCredential labels that qualify. */
-    @Nonnull @NonnullElements private Set<String> allowedLabels;
-    
-    /** Optional lockout tracker for rate limiting. */
-    @Nullable private AccountLockoutManager lockoutManager;
-    
-    /** Constructor. */
-    public DefaultPasswordlessEnrollmentCondition() {
-        allowedLabels = CollectionSupport.emptySet();
-    }
-    
-    /**
-     * Sets the {@link DuoAdminClient} to use.
-     * 
-     * @param client admin client
-     */
-    public void setDuoAdminClient(@Nonnull final DuoAdminClient client) {
-        checkSetterPreconditions();
-        adminClient = client;
-    }
-    
-    /**
-     * Sets the allowable {@link WebAuthnCredential} labels that qualify for the condition.
-     * 
-     * @param labels credential labels
-     */
-    public void setAllowedLabels(@Nonnull @NonnullElements final Collection<String> labels) {
-        checkSetterPreconditions();
-        allowedLabels = CollectionSupport.copyToSet(StringSupport.normalizeStringCollection(labels));
-    }
-    
-    /**
-     * Sets the optional {@link AccountLockoutManager} to use for rate limiting.
-     * 
-     * @param manager lockout manager
-     */
-    public void setLockoutManager(@Nullable final AccountLockoutManager manager) {
-        checkSetterPreconditions();
-        lockoutManager = manager;
-    }
-    
-    /** {@inheritDoc} */
-    @Override
-    public void doInitialize() throws ComponentInitializationException {
-        super.doInitialize();
-        
-        if (adminClient == null) {
-            throw new ComponentInitializationException("DuoAdminClient cannot be null");
-        }
-    }
-    
-// Checkstyle: CyclomaticComplexity|ReturnCount OFF
-    /** {@inheritDoc} */
-    public boolean test(@Nullable final ProfileRequestContext profileRequestContext, @Nullable final String username) {
-        checkComponentActive();
-        
-        if (profileRequestContext == null || username == null) {
-            log.trace("Unable to check passwordless enrollment status for '{}'", username);
-            return false;
-        }
-        
-        log.trace("Checking passwordless enrollment status for '{}'", username); 
-        try {
-            
-            if (lockoutManager != null) {
-                if (lockoutManager.check(profileRequestContext)) {
-                    log.warn("Lockout manager precludes enrollment check for '{}'", username);
-                    return false;
-                }
-                
-                assert lockoutManager != null;
-                lockoutManager.increment(profileRequestContext);
-            }
-            
-            final User response = adminClient.getUser(profileRequestContext, username);
-            if (response == null) {
-                log.info("User '{}' not found in Duo", username);
-                return false;
-            }
-            
-            final Boolean enrolled = response.isEnrolled();
-            if (enrolled == null || !enrolled) {
-                log.info("User '{}' not enrolled", response.getUsername());
-                return false;
-            }
-            
-            final List<WebAuthnCredential> creds = response.getWebAuthnCredentials();
-            if (creds.isEmpty()) {
-                log.info("User '{}' has no WebAuthn credentials", response.getUsername());
-                return false;
-            }
-            
-            if (allowedLabels.isEmpty()) {
-                log.debug("User '{}' is enrolled with {} WebAuthn credential(s)", response.getUsername(),
-                        response.getWebAuthnCredentials().size());
-                return true;
-            }
-            
-            final Set<String> enrolledLabels = creds.stream()
-                    .map(WebAuthnCredential::getLabel)
-                    .collect(CollectionSupport.nonnullCollector(Collectors.toUnmodifiableSet())).get();
-            for (final String label : allowedLabels) {
-                if (enrolledLabels.contains(label)) {
-                    log.debug("User '{}' has a qualifying WebAuthn credential: '{}'", response.getUsername(), label);
-                    return true;
-                }
-            }
-            
-            log.info("User '{}' has no acceptable WebAuthn credential, enrolled credentials: {}",
-                    response.getUsername(), enrolledLabels);
-            return false;
-            
-        } catch (final DuoException e) {
-            log.warn("Duo AdminAPI request failed, denying passwordless for '{}'", username, e);
-            return false;
-        }
-    }
-// Checkstyle: CyclomaticComplexity|ReturnCount ON
-
-    /**
-     * A function to generate a key for lockout storage, just the client address.
-     */
-    public static class IPLockoutKeyStrategy implements Function<ProfileRequestContext,String> { 
-        
-        /** Supplier for the Servlet request to pull client ip from. **/
-        @Nullable private NonnullSupplier<HttpServletRequest> httpRequestSupplier;
-        
-        /**
-         * Set the Supplier for the servlet request to read from.
-         * 
-         * @param requestSupplier servlet request Supplier
-         */
-        public void setHttpServletRequestSupplier(@Nonnull final NonnullSupplier<HttpServletRequest> requestSupplier) {
-            httpRequestSupplier = Constraint.isNotNull(requestSupplier, "HttpServletRequest cannot be null");
-        }
-
-        /**
-         * Get the current HTTP request if available.
-         *
-         * @return current HTTP request
-         */
-        @Nullable private HttpServletRequest getHttpServletRequest() {
-            if (httpRequestSupplier == null) {
-                return null;
-            }
-            assert httpRequestSupplier != null;
-            return httpRequestSupplier.get();
-        }
-
-        /** {@inheritDoc} */
-        @Nullable public String apply(@Nullable final ProfileRequestContext profileRequestContext) {
-            
-            final HttpServletRequest request = getHttpServletRequest();
-            if (request == null) {
-                return null;
-            }
-
-            final String ipAddr = HttpServletSupport.getRemoteAddr(request);
-            if (ipAddr == null || ipAddr.isEmpty()) {
-                return null;
-            }
-        
-            return ipAddr;
-        }
-    }
-
-}
\ No newline at end of file
diff --git a/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/context/DuoOIDCAuthenticationContext.java b/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/context/DuoOIDCAuthenticationContext.java
index 6f3f585c..a94920eb 100644
--- a/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/context/DuoOIDCAuthenticationContext.java
+++ b/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/context/DuoOIDCAuthenticationContext.java
@@ -60,6 +60,9 @@ public final class DuoOIDCAuthenticationContext extends BaseContext {
     /** The JWT token received from Duo as a result of 2FA. Token *must* be signed.*/
     @Nullable private JWT authToken;
     
+    /** The factor claim from the token. */
+    @Nullable private String factorUsed;
+    
     /** The Duo OIDC client to use for the lifetime of this authentication request.*/
     @Nullable private DuoOIDCClient client;   
     
@@ -181,6 +184,31 @@ public final class DuoOIDCAuthenticationContext extends BaseContext {
         return authToken;
     }
     
+    /**
+     * Set the factor claim from the token.
+     * 
+     * @param factor factor claim
+     * 
+     * @return this context
+     * 
+     * @since 2.1.0
+     */
+    @Nonnull public DuoOIDCAuthenticationContext setFactorUsed(@Nullable final String factor) {
+        factorUsed = factor;
+        return this;
+    }
+    
+    /**
+     * Get the factor claim from the token.
+     * 
+     * @return factor claim
+     * 
+     * @since 2.1.0
+     */
+    @Nullable public String getFactorUsed() {
+        return factorUsed;
+    }
+    
     /**
      * Get the request state.
      * 
@@ -190,7 +218,6 @@ public final class DuoOIDCAuthenticationContext extends BaseContext {
         return requestState;
     }
     
-    
     /**
      * Set the request state.
      * 
@@ -266,5 +293,4 @@ public final class DuoOIDCAuthenticationContext extends BaseContext {
         return integration;
     }
 
-    
 }
\ No newline at end of file
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
index 26627774..114e5d52 100644
--- 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
@@ -14,8 +14,6 @@
 
 package net.shibboleth.idp.plugin.authn.duo.context;
 
-import java.util.Objects;
-
 import javax.annotation.Nonnull;
 import javax.annotation.Nullable;
 
@@ -28,10 +26,10 @@ import net.shibboleth.idp.authn.context.AuthenticationContext;
  * 
  * <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>
+ * is tracked here since it is typically set by calling code.</p>
  * 
  * @parent {@link AuthenticationContext}
- * @added By configuration to signal username collection and enrollment checking
+ * @added By flow action
  * 
  * @since 2.1.0
  */
@@ -40,9 +38,6 @@ public final class DuoPasswordlessContext extends BaseContext {
     /** Username. */
     @Nullable private String username;
     
-    /** Whether user has appropriate devices enrolled. */
-    boolean enrolled;
-    
     /**
      * Get the username.
      * 
@@ -55,39 +50,13 @@ 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) {
-        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;
+        username = name;
         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
deleted file mode 100644
index 13e3004f..00000000
--- a/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/impl/CheckPasswordlessEnrollment.java
+++ /dev/null
@@ -1,493 +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.Collection;
-import java.util.List;
-import java.util.function.BiFunction;
-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.collection.CollectionSupport;
-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;
-
-    /** Whether to signal non-proceed events. */
-    private boolean signalEvents;
-    
-    /** 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;
-    
-    /** Order to pull username from. */
-    @Nonnull private List<String> precedence;
-    
-    /** Generic hook for remapping username. */
-    @Nullable private BiFunction<ProfileRequestContext,String,String> duoUsernameRemappingStrategy; 
-    
-    /** 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";
-
-        precedence = CollectionSupport.listOf("form", "session", "cookie");
-    }
-    
-    /**
-     * 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 whether the action should signal non-proceed events in defined cases, or suppress them.
-     * 
-     * <p>Defaults to false.</p>
-     * 
-     * @param flag flag to set
-     */
-    public void setSignalEvents(final boolean flag) {
-        checkSetterPreconditions();
-        
-        signalEvents = flag;
-    }
-    
-    /**
-     * 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 the precedence rules to use in populating the username.
-     * 
-     * <p>Valid tokens are "form", "session", and "cookie" and that is the default order.</p>
-     * 
-     * @param order precedence to use
-     */
-    public void setPrecedence(@Nonnull final Collection<String> order) {
-        checkSetterPreconditions();
-
-        precedence = CollectionSupport.copyToList(StringSupport.normalizeStringCollection(order));
-    }
-
-    /**
-     * Sets a general hook for remapping username.
-     * 
-     * @param strategy username remapping strategy
-     */
-    public void setDuoUsernameRemappingStrategy(
-            @Nullable final BiFunction<ProfileRequestContext,String,String> strategy) {
-        // TODO: Remove once API moved to 5.1
-        checkSetterPreconditions();
-        
-        duoUsernameRemappingStrategy = strategy;
-    }
-    
-    /** {@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|MethodLength OFF
-    /** {@inheritDoc} */
-    @Override
-    protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext,
-            @Nonnull final AuthenticationContext authenticationContext) {
-        
-        setCacheability(authenticationContext);
-        
-        boolean usernameChanged = false;
-        boolean exitLoop = false;
-
-        String username;
-        
-        for (final String source : precedence) {
-            switch (source) {
-                case "form":
-                    username = getUsernameFromForm(profileRequestContext, authenticationContext);
-                    if (username != null) {
-                        if (!username.equals(passwordlessContext.getUsername())) {
-                            log.debug("{} Populating username '{}' from form submission into DuoPasswordlessContext",
-                                    getLogPrefix(), username);
-                            passwordlessContext.setUsername(username);
-                            usernameChanged = true;
-                        }
-                        exitLoop = true;
-                    }
-                    break;
-                    
-                case "session":
-                    username = getUsernameFromSession(profileRequestContext, authenticationContext);
-                    if (username != null) {
-                        if (!username.equals(passwordlessContext.getUsername())) {
-                            log.debug("{} Populating username '{}' from session into DuoPasswordlessContext",
-                                    getLogPrefix(), username);
-                            passwordlessContext.setUsername(username);
-                            usernameChanged = true;
-                        }
-                        exitLoop = true;
-                    }
-                    break;
-                    
-                case "cookie":
-                    username = getUsernameFromCookie(profileRequestContext);
-                    if (username != null) {
-                        if (!username.equals(passwordlessContext.getUsername())) {
-                            log.debug("{} Populating cached username '{}' from cookie into DuoPasswordlessContext",
-                                    getLogPrefix(), username);
-                            passwordlessContext.setUsername(username);
-                            usernameChanged = true;
-                        }
-                        exitLoop = true;
-                    }
-                    break;
-                    
-                default:
-                    log.warn("{} Unsupported precedence value for username population: {}", getLogPrefix(), source);
-                    break;
-            }
-            
-            if (exitLoop) {
-                break;
-            }
-        }
-        
-        // Clear cookie if required.
-        if (!authenticationContext.isResultCacheable() && cookieManager != null && cookieName != null) {
-            cookieManager.unsetCookie(cookieName);
-        }
-        
-        final String finalUsername = passwordlessContext.getUsername(); 
-        if (finalUsername == null || finalUsername.isBlank()) {
-            passwordlessContext.setUsername(null);
-            passwordlessContext.setEnrolled(false);
-            if (signalEvents) {
-                ActionSupport.buildEvent(profileRequestContext, AuthnEventIds.UNKNOWN_USERNAME);
-            }
-            return;
-        }
-        
-        if (usernameChanged) {
-            passwordlessContext.setEnrolled(
-                    passwordlessCondition.test(profileRequestContext, finalUsername));
-            log.debug("{} Username '{}' found to be {} of passwordless attempt", getLogPrefix(), finalUsername,
-                    passwordlessContext.isEnrolled() ? "capable" : "incapable");
-                
-            // Upodate cookie if needed.
-            updateCookie(authenticationContext);
-        } else {
-            log.debug("{} Username not updated, leaving DuoPasswordlessContext unchanged", getLogPrefix());
-        }
-        
-        if (signalEvents && !passwordlessContext.isEnrolled()) {
-            ActionSupport.buildEvent(profileRequestContext, AuthnEventIds.REQUEST_UNSUPPORTED);
-        }
-    }
-// Checkstyle: CyclomaticComplexity|MethodLength ON
-    
-    /**
-     * Establishes result cacheability from form field.
-     * 
-     * @param authenticationContext authentication context
-     */
-    private void setCacheability(@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);
-            }
-        }
-    }
-    
-    /**
-     * Set or unset cokie based on enrollment status and result cacheability.
-     * 
-     * @param authenticationContext authentication context
-     */
-    private void updateCookie(@Nonnull final AuthenticationContext authenticationContext) {
-        if (passwordlessContext.isEnrolled()) {
-            final String localCookieName = cookieName;
-            if (authenticationContext.isResultCacheable()) {
-                if (cookieManager != null && dataSealer != null && localCookieName != null) {
-                    String wrapped = passwordlessContext.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);
-            }
-        }
-    }
-    
-    /**
-     * Gets the username from a form submission.
-     * 
-     * @param profileRequestContext profile request context
-     * @param authenticationContext authentication context
-     * 
-     * @return submitted username, after applying any configured transforms
-     */
-    @Nullable private String getUsernameFromForm(@Nonnull final ProfileRequestContext profileRequestContext,
-            @Nonnull final AuthenticationContext authenticationContext) {
-        
-        final HttpServletRequest request = getHttpServletRequest();
-        if (request != null) {
-            final String param = request.getParameter(usernameFieldName);
-            if (param != null) {
-                // TODO: Convert to 2 parameter version once API moves to 5.1.
-                final String s = applyTransforms(param);
-                return duoUsernameRemappingStrategy != null
-                        ? duoUsernameRemappingStrategy.apply(profileRequestContext, s) : s;
-            }
-        }
-        
-        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 @NotEmpty 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;
-                    // TODO: Convert to 2 parameter version once API moves to 5.1.
-                    return applyTransforms(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 @NotEmpty private String getUsernameFromSession(
-            @Nonnull final ProfileRequestContext profileRequestContext,
-            @Nonnull final AuthenticationContext authenticationContext) {
-        
-        if (!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/CreatePasswordlessCookie.java b/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/impl/CreatePasswordlessCookie.java
new file mode 100644
index 00000000..61ff2ed2
--- /dev/null
+++ b/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/impl/CreatePasswordlessCookie.java
@@ -0,0 +1,125 @@
+/*
+ * 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 javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+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 net.shibboleth.idp.authn.AuthnEventIds;
+import net.shibboleth.idp.authn.context.SubjectCanonicalizationContext;
+import net.shibboleth.idp.profile.AbstractProfileAction;
+import net.shibboleth.shared.annotation.constraint.NotEmpty;
+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;
+
+/**
+ * Finalization action that creates the passwordless guard cookkie based on the
+ * canonical principal name after the flow completes.
+ * 
+ * @event {@link EventIds#PROCEED_EVENT_ID}
+ * @event {@link AuthnEventIds#INVALID_SUBJECT_C14N_CTX}
+ * 
+ * @pre <pre>
+ *      ProfileRequestContext.ensureSubcontext(SubjectCanonicalizationContext.class).getPrincipalName() != null
+ *      </pre>
+ *      
+ * @since 2.1.0
+ */
+public class CreatePasswordlessCookie extends AbstractProfileAction {
+
+    /** Class logger.*/
+    @Nonnull private final Logger log = LoggerFactory.getLogger(CreatePasswordlessCookie.class);
+    
+    /** Passwordless 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;
+    
+    /**
+     * Set cookie name to use for "authorizing" passwordless use.
+     * 
+     * @param name cookie name
+     */
+    public void setCookieName(@Nullable final String name) {
+        checkSetterPreconditions();
+
+        cookieName = StringSupport.trimOrNull(name);
+    }
+
+    /**
+     * Sets {@link CookieManager} to use.
+     * 
+     * @param manager cookie manager
+     */
+    public void setCookieManager(@Nullable final CookieManager manager) {
+        checkSetterPreconditions();
+        
+        cookieManager = manager;
+    }
+
+    /**
+     * Sets {@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) {    
+                 
+        final String localCookieName = cookieName;
+        final CookieManager localManager = cookieManager;
+        final DataSealer localSealer = dataSealer;
+        if (localCookieName == null || localManager == null || localSealer == null) {
+            log.warn("{} Cookie management settings are absent, this shouldn't be possible");
+            return;
+        }
+
+        final SubjectCanonicalizationContext c14nContext =
+                profileRequestContext.getSubcontext(SubjectCanonicalizationContext.class);
+        final String username = c14nContext != null ? c14nContext.getPrincipalName() : null;
+        if (username == null) {
+            log.error("{} No username available", getLogPrefix());
+            ActionSupport.buildEvent(profileRequestContext, AuthnEventIds.INVALID_SUBJECT_C14N_CTX);
+            return;
+        }
+        
+        try {
+            final String wrapped = localSealer.wrap(username);
+            localManager.addCookie(localCookieName, UrlEscapers.urlFormParameterEscaper().escape(wrapped));
+        } catch (final DataSealerException e) {
+            log.warn("{} Unable to wrap username for guard cookie", getLogPrefix(), e);
+        }
+    }
+    
+}
\ 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 91020c3f..98898b3c 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
@@ -39,10 +39,12 @@ 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.annotation.constraint.NotEmpty;
 import net.shibboleth.shared.component.ComponentInitializationException;
 import net.shibboleth.shared.logic.Constraint;
 import net.shibboleth.shared.logic.FunctionSupport;
 import net.shibboleth.shared.primitive.LoggerFactory;
+import net.shibboleth.shared.primitive.StringSupport;
 
 
 /**
@@ -83,6 +85,9 @@ public class PopulateDuoAuthenticationContext extends AbstractAuthenticationActi
     
     /** Strategy used to compute the redirectURI from the given Duo integration if supported.*/
     @Nullable private BiFunction<HttpServletRequest, DynamicDuoOIDCIntegration, String> redirectURICreationStrategy;
+
+    /** Parameter name for SSO bypass. */
+    @Nonnull @NotEmpty private String ssoBypassFieldName;
     
     /** The registry for locating the DuoClient for the established integration.*/
     @NonnullAfterInit private DuoOIDCClientRegistry clientRegistry;
@@ -96,6 +101,8 @@ public class PopulateDuoAuthenticationContext extends AbstractAuthenticationActi
         usernameLookupStrategy = new CanonicalUsernameLookupStrategy();
         standardDuoIntegrationLookupStrategy = FunctionSupport.constant(null);
         passwordlessDuoIntegrationLookupStrategy = FunctionSupport.constant(null);
+        
+        ssoBypassFieldName = "donotcache";
     }
     
     /**
@@ -176,7 +183,20 @@ public class PopulateDuoAuthenticationContext extends AbstractAuthenticationActi
         passwordlessDuoIntegrationLookupStrategy =
                 Constraint.isNotNull(strategy, "Passwordless DuoIntegration lookup strategy cannot be null");
     }
-
+    
+    /**
+     * Set the SSO bypass parameter name.
+     * 
+     * @param fieldName the SSO bypass parameter name
+     * 
+     * @since 2.1.0
+     */
+    public void setSSOBypassFieldName(@Nonnull @NotEmpty final String fieldName) {
+        checkSetterPreconditions();
+        ssoBypassFieldName = Constraint.isNotNull(
+                StringSupport.trimOrNull(fieldName), "SSO Bypass field name cannot be null or empty.");
+    }
+    
     /** {@inheritDoc} */
     @Override protected void doInitialize() throws ComponentInitializationException {
         super.doInitialize();
@@ -205,7 +225,7 @@ public class PopulateDuoAuthenticationContext extends AbstractAuthenticationActi
         final DuoPasswordlessContext passwordlessContext =
                 passwordlessContextLookupStrategy.apply(profileRequestContext);
         if (passwordlessContext != null) {
-            if (!doPasswordless(profileRequestContext, duoContext, passwordlessContext)) {
+            if (!doPasswordless(profileRequestContext, authenticationContext, duoContext, passwordlessContext)) {
                 duoContext.removeFromParent();
                 return;
             }
@@ -246,12 +266,14 @@ public class PopulateDuoAuthenticationContext extends AbstractAuthenticationActi
      * Perform standard context creation and lookups.
      * 
      * @param profileRequestContext profile request context
+     * @param authenticationContext authentication 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 AuthenticationContext authenticationContext,
             @Nonnull final DuoOIDCAuthenticationContext duoContext,
             @Nonnull final DuoPasswordlessContext passwordlessContext) {
 
@@ -261,11 +283,6 @@ public class PopulateDuoAuthenticationContext extends AbstractAuthenticationActi
             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) {
@@ -280,6 +297,15 @@ public class PopulateDuoAuthenticationContext extends AbstractAuthenticationActi
         
         duoContext.setIntegration(duoIntegration);
         
+        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);
+            }
+        }
+        
         return true;
     }
     
diff --git a/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/impl/PopulatePasswordlessContext.java b/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/impl/PopulatePasswordlessContext.java
new file mode 100644
index 00000000..4fb3a494
--- /dev/null
+++ b/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/impl/PopulatePasswordlessContext.java
@@ -0,0 +1,179 @@
+/*
+ * 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 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.profile.AbstractProfileAction;
+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;
+
+/**
+ * A profile action to extract passwordless username from sealed cookie.
+ * 
+ * @since 2.1.0
+ * 
+ * @event {@link EventIds#PROCEED_EVENT_ID}
+ * @event {@link EventIds#INVALID_PROFILE_CTX}
+ * @event {@link AuthnEventIds#REQUEST_UNSUPPORTED}
+ * @pre <pre>AuthenticationContext.getSubcontext(DuoPasswordlessContext.class) != null</pre>
+ * @post {@link DuoPasswordlessContext#setUsername(String)} is called with an existing value if found
+ */
+public class PopulatePasswordlessContext extends AbstractProfileAction {
+    
+    /** Class logger. */
+    @Nonnull private final Logger log = LoggerFactory.getLogger(PopulatePasswordlessContext.class);
+
+    /** Strategy used to locate the {@link DuoPasswordlessContext} to operate on. */
+    @Nonnull private Function<ProfileRequestContext,DuoPasswordlessContext> duoPasswordlessContextLookupStrategy;
+    
+    /** Passwordless cookie name. */
+    @NonnullBeforeExec @NotEmpty private String cookieName;
+
+    /** Optional cookie manager to use. */
+    @NonnullBeforeExec private CookieManager cookieManager;
+
+    /** Optional data sealer to use. */
+    @NonnullBeforeExec private DataSealer dataSealer;
+    
+    /** Context to populate. */
+    @NonnullBeforeExec private DuoPasswordlessContext passwordlessContext;
+        
+    /** Constructor. */
+    public PopulatePasswordlessContext() {
+        duoPasswordlessContextLookupStrategy =
+                new ChildContextLookup<>(DuoPasswordlessContext.class).compose(
+                        new ChildContextLookup<>(AuthenticationContext.class));
+        
+    }
+    
+    /**
+     * Set the strategy used to locate the {@link DuoPasswordlessContext} to operate on.
+     * 
+     * @param strategy lookup strategy
+     */
+    public void setDuoPasswordlessContextLookupStrategy(
+            @Nonnull final Function<ProfileRequestContext,DuoPasswordlessContext> strategy) {
+        checkSetterPreconditions();
+
+        duoPasswordlessContextLookupStrategy =
+                Constraint.isNotNull(strategy, "DuoPasswordlessContext lookup strategy cannot be null");
+    }
+    
+    /**
+     * Set cookie name to use for "authorizing" passwordless use.
+     * 
+     * @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;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected boolean doPreExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+        if (!super.doPreExecute(profileRequestContext)) {
+            ActionSupport.buildEvent(profileRequestContext, AuthnEventIds.REQUEST_UNSUPPORTED);
+            return false;
+        }
+        
+        if (cookieName == null || dataSealer == null || cookieManager == null) {
+            ActionSupport.buildEvent(profileRequestContext, AuthnEventIds.REQUEST_UNSUPPORTED);
+            return false;
+        }
+        
+        passwordlessContext = duoPasswordlessContextLookupStrategy.apply(profileRequestContext);
+        if (passwordlessContext == null) {
+            ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_PROFILE_CTX);
+            return false;
+        }
+        
+        return true;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+
+        assert cookieName != null;
+        String cookie = cookieManager.getCookieValue(cookieName, null);
+        if (cookie == null) {
+            log.debug("{} Guard cookie missing from request", getLogPrefix());
+            ActionSupport.buildEvent(profileRequestContext, AuthnEventIds.REQUEST_UNSUPPORTED);
+            return;
+        }
+        
+        cookie = URISupport.doURLDecode(cookie);
+        if (cookie == null) {
+            log.debug("{} Unable to decode guard cookie", getLogPrefix());
+            ActionSupport.buildEvent(profileRequestContext, AuthnEventIds.REQUEST_UNSUPPORTED);
+            return;
+        }
+        
+        try {
+            final String username = dataSealer.unwrap(cookie);
+            passwordlessContext.setUsername(username);
+            log.debug("{} Extracted username for passwordless authentication from cookie: {}", getLogPrefix(),
+                    username);
+        } catch (final DataSealerException e) {
+            log.info("{} Unable to decrypt passwordless guard cookie", getLogPrefix(), e);
+            ActionSupport.buildEvent(profileRequestContext, AuthnEventIds.REQUEST_UNSUPPORTED);
+        }
+    }
+    
+}
\ No newline at end of file
diff --git a/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/impl/PostValidatePasswordlessEvaluation.java b/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/impl/PostValidatePasswordlessEvaluation.java
new file mode 100644
index 00000000..7082ffa8
--- /dev/null
+++ b/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/impl/PostValidatePasswordlessEvaluation.java
@@ -0,0 +1,269 @@
+/*
+ * 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.Consumer;
+import java.util.function.Predicate;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.opensaml.profile.action.ActionSupport;
+import org.opensaml.profile.action.EventIds;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.slf4j.Logger;
+
+import net.shibboleth.idp.authn.AbstractAuthenticationAction;
+import net.shibboleth.idp.authn.context.AuthenticationContext;
+import net.shibboleth.idp.plugin.authn.duo.DuoOIDCIntegration;
+import net.shibboleth.idp.plugin.authn.duo.context.DuoOIDCAuthenticationContext;
+import net.shibboleth.shared.annotation.constraint.NonnullBeforeExec;
+import net.shibboleth.shared.annotation.constraint.NotEmpty;
+import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.logic.PredicateSupport;
+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;
+
+/**
+ * This is a convoluted step that implements some of the cookie management logic
+ * needed after second-factor use, but before the possible opt-in to passwordless.
+ * 
+ * <p>The {@link EventIds#PROCEED_EVENT_ID} event is a signal that no further steps are
+ * required and the flow should complete as is.</p>
+ * 
+ * <p>The {@link #PROMPT_USER_EVENT} event is a signal that the user should be asked to
+ * opt into passwordless use in the future, resulting in the creation of a guard cookie.</p>
+ * 
+ * <p>This action will also remove the cookie if a non-cacheable login happens and is set
+ * to react to that, and will remove the cookie if the existing cookie's value doesn't
+ * match the username in the transaction.</p>
+ * 
+ * <p>This is also where the cleanup hook is relocated, in order to defer that step and
+ * preserve access to the Duo state.</p>
+ * 
+ * @event {@link #PROMPT_USER_EVENT}
+ * @event {@link EventIds#PROCEED_EVENT_ID}
+ * @event {@link EventIds#INVALID_PROFILE_CTX}
+ * @pre <pre>
+ *      ProfileRequestContext.getSubcontext(AuthenticationContext.class) != null
+ *      </pre>
+ * 
+ * @pre <pre>
+ *      AuthenticationContext.getSubcontext(DuoOIDCAuthenticationContext.class) != null
+ *      </pre>
+ *      
+ * @pre <pre>
+ *      DuoOIDCAuthenticationContext.getDuoIntegration() != null
+ *      </pre>
+ *      
+ *      @since 2.1.0
+ */
+public class PostValidatePasswordlessEvaluation extends AbstractAuthenticationAction {
+    
+    /** Custom event to signal that a prompt to opt into passwordless should be presented. */
+    @Nonnull @NotEmpty public static final String PROMPT_USER_EVENT = "PasswordlessPrompt";
+
+    /** Class logger.*/
+    @Nonnull private final Logger log = LoggerFactory.getLogger(PostValidatePasswordlessEvaluation.class);
+    
+    /** A cleanup hook to execute after processing. */
+    @Nullable private Consumer<ProfileRequestContext> cleanupHook;
+    
+    /** Condition governing "new" eligibility. */
+    @Nonnull private Predicate<ProfileRequestContext> passwordlessCondition;
+    
+    /** Whether to require the authentication be cacheable to allow this. */
+    private boolean requireResultCacheable;
+    
+    /** Passwordless 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;
+    
+    /** Duo authentiction context. */
+    @NonnullBeforeExec private DuoOIDCAuthenticationContext duoContext;
+    
+    /** Constructor. */
+    public PostValidatePasswordlessEvaluation() {
+        passwordlessCondition = PredicateSupport.alwaysFalse();
+        requireResultCacheable = true;
+    }
+    
+    /**
+     * Set the cleanup hook to execute after processing.
+     * 
+     * @param hook cleanup hook
+     */
+    public void setCleanupHook(@Nullable final Consumer<ProfileRequestContext> hook) {
+        checkSetterPreconditions();
+        cleanupHook = hook;
+    }
+    
+    /**
+     * Set condition governing eligibility for passwordless opt-in.
+     * 
+     * @param condition
+     */
+    public void setPasswordlessCondition(@Nonnull final Predicate<ProfileRequestContext> condition) {
+        checkSetterPreconditions();
+        
+        passwordlessCondition = Constraint.isNotNull(condition, "Passwordless eligibility condition cannot be null");
+    }
+    
+    /**
+     * Sets whether a non-cacheable result should force the condition to return false.
+     * 
+     * <p>This defaults to "true" which subsequently honors the "do not remember" option
+     * on the login views.</p>
+     * 
+     * @param flag flag to set
+     */
+    public void setRequireResultCacheable(final boolean flag) {
+        checkSetterPreconditions();
+        requireResultCacheable = flag;
+    }
+    
+    /**
+     * Set cookie name to use for "authorizing" passwordless use.
+     * 
+     * @param name cookie name
+     */
+    public void setCookieName(@Nullable final String name) {
+        checkSetterPreconditions();
+
+        cookieName = StringSupport.trimOrNull(name);
+    }
+
+    /**
+     * Sets {@link CookieManager} to use.
+     * 
+     * @param manager cookie manager
+     */
+    public void setCookieManager(@Nullable final CookieManager manager) {
+        checkSetterPreconditions();
+        
+        cookieManager = manager;
+    }
+
+    /**
+     * Sets {@link DataSealer} to use.
+     * 
+     * @param sealer data sealer
+     */
+    public void setDataSealer(@Nullable final DataSealer sealer) {
+        checkSetterPreconditions();
+        
+        dataSealer = sealer;
+    }
+    
+    /** {@inheritDoc} */
+    @Override protected boolean doPreExecute(@Nonnull final ProfileRequestContext profileRequestContext,
+            @Nonnull final AuthenticationContext authenticationContext) {
+        
+        // This needs to be true so the post-execute method runs, and thus the cleanup hook.
+        return true;
+    }
+    
+// Checkstyle: CyclomaticComplexity OFF
+    /** {@inheritDoc} */
+    @Override protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext,
+            @Nonnull final AuthenticationContext authenticationContext) {    
+                 
+
+        final String localCookieName = cookieName;
+        final CookieManager localManager = cookieManager;
+        final DataSealer localSealer = dataSealer;
+        if (localCookieName == null || localManager == null || localSealer == null) {
+            log.trace("{} Cookie management settings are absent, skipping this step");
+            return;
+        }
+        
+        duoContext = authenticationContext.getSubcontext(DuoOIDCAuthenticationContext.class);
+        if (duoContext == null) {
+            log.error("{} No DuoAuthenticationContext available", getLogPrefix());
+            ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_PROFILE_CTX);
+            return;
+        }
+        
+        final DuoOIDCIntegration integration = duoContext.getIntegration();
+        if (integration == null) {
+            log.error("{} No DuoOIDCIntegration available", getLogPrefix());
+            ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_PROFILE_CTX);
+            return;
+        }
+        
+        final String username = duoContext.getUsername();
+        if (username == null) {
+            log.error("{} No username available", getLogPrefix());
+            ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_PROFILE_CTX);
+            return;
+        }
+
+        if (!authenticationContext.isResultCacheable() && requireResultCacheable) {
+            log.debug("{} Non-cacheable authentication, clearing guard cookie if set", getLogPrefix());
+            localManager.unsetCookie(localCookieName);
+        } else if (!integration.isPasswordless()) {
+            // Read in existing cookie, if any.
+            String cookie = localManager.getCookieValue(localCookieName, null);
+            if (cookie != null) {
+                cookie = URISupport.doURLDecode(cookie);
+                if (cookie != null) {
+                    try {
+                        final String unwrapped = localSealer.unwrap(cookie);
+                        if (username.equals(unwrapped)) {
+                            // The username is the same, so there's nothing to do, the flow should complete.
+                            return;
+                        } else {
+                            // Clear the existing cookie to start fresh.
+                            log.info("{} Clearing existing guard cookie for original username '{}'", getLogPrefix(),
+                                    unwrapped);
+                        }
+                    } catch (final DataSealerException e) {
+                        log.warn("{} Unable to unwrap existing guard cookie", getLogPrefix(), e);
+                    }
+                }
+                localManager.unsetCookie(localCookieName);
+            }
+            
+            // This is a new user without an existing cookie set, so establish eligibility.
+            if (passwordlessCondition.test(profileRequestContext)) {
+                log.info("{} User '{}' eligible for passwordless, advancing to opt-in view", getLogPrefix(), username);
+                ActionSupport.buildEvent(profileRequestContext, PROMPT_USER_EVENT);
+            } else {
+                log.debug("{} User '{}' not eligible for passwordless", getLogPrefix(), username);
+            }
+        }
+    }
+ // Checkstyle: CyclomaticComplexity ON
+
+    /** {@inheritDoc} */
+    @Override
+    protected void doPostExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+        super.doPostExecute(profileRequestContext);
+        
+        if (cleanupHook != null) {
+            cleanupHook.accept(profileRequestContext);
+        }
+    }
+    
+}
\ 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 e64d8826..0bdef6ec 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
@@ -71,16 +71,19 @@ public class ValidateDuoTokenAuthenticationResult extends AbstractAuditingValida
     
     /** Class logger.*/
     @Nonnull private final Logger log = LoggerFactory.getLogger(ValidateDuoTokenAuthenticationResult.class);
-       
-    /** Duo authentiction context. */
-    @NonnullBeforeExec private DuoOIDCAuthenticationContext duoContext;
     
-    /** The Duo integration.*/
-    @NonnullBeforeExec private DuoOIDCIntegration duoIntegration;
+    /** Hook to map context information (often Duo factors in the Duo token) to principal collections.*/
+    @Nullable private Function<ProfileRequestContext,Collection<Principal>> contextToPrincipalMappingStrategy;
     
     /** The profile request context.*/
     @Nullable private ProfileRequestContext prc;
     
+    /** Duo authentiction context. */
+    @NonnullBeforeExec private DuoOIDCAuthenticationContext duoContext;
+     
+    /** The Duo integration.*/
+    @NonnullBeforeExec private DuoOIDCIntegration duoIntegration;
+
     /** The parsed claimset. */
     @NonnullBeforeExec private JWTClaimsSet claimsSet;
     
@@ -90,9 +93,6 @@ public class ValidateDuoTokenAuthenticationResult extends AbstractAuditingValida
     /** 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;
-    
     /** Constructor.*/
     public ValidateDuoTokenAuthenticationResult() {
         setMetricName(DEFAULT_METRIC_NAME);
@@ -121,7 +121,6 @@ public class ValidateDuoTokenAuthenticationResult extends AbstractAuditingValida
         
         contextToPrincipalMappingStrategy = hook;
     }
-
     
     /** {@inheritDoc} */
     @Override protected boolean doPreExecute(@Nonnull final ProfileRequestContext profileRequestContext,
@@ -211,6 +210,8 @@ public class ValidateDuoTokenAuthenticationResult extends AbstractAuditingValida
             if (DuoOIDCAuthAPI.DUO_AUTH_RESULT_ALLOW.equalsIgnoreCase(authResultStatus)){
                 factorUsed = extractFactor();
                 
+                duoContext.setFactorUsed(factorUsed);
+                
                 // Check if factor is allowed.
                 final Set<String> allowedFactors = duoIntegration.getAllowedFactors();
                 if (allowedFactors != null && !allowedFactors.contains(factorUsed)) {
@@ -277,7 +278,6 @@ public class ValidateDuoTokenAuthenticationResult extends AbstractAuditingValida
             // Do nothing, just return null
         }
         return null;
-
     }
     
     /** {@inheritDoc} */
@@ -322,7 +322,8 @@ public class ValidateDuoTokenAuthenticationResult extends AbstractAuditingValida
     }
 
     /** {@inheritDoc} */
-    @Override protected void buildAuthenticationResult(@Nonnull final ProfileRequestContext profileRequestContext,
+    @Override
+    protected void buildAuthenticationResult(@Nonnull final ProfileRequestContext profileRequestContext,
             @Nonnull final AuthenticationContext authenticationContext) {
         super.buildAuthenticationResult(profileRequestContext, authenticationContext);
 
diff --git a/idp-duo-impl/src/main/resources/META-INF/net.shibboleth.idp/postconfig.xml b/idp-duo-impl/src/main/resources/META-INF/net.shibboleth.idp/postconfig.xml
index a207f33f..f66adb1f 100644
--- a/idp-duo-impl/src/main/resources/META-INF/net.shibboleth.idp/postconfig.xml
+++ b/idp-duo-impl/src/main/resources/META-INF/net.shibboleth.idp/postconfig.xml
@@ -52,47 +52,4 @@
     <bean id="shibboleth.DuoOIDCAuthnController"
         class="net.shibboleth.idp.plugin.authn.duo.impl.DuoOIDCAuthnController" />
 
-    <!-- Default Duo Admin API Integration for IdP-wide use. -->
-    <bean id="shibboleth.authn.DuoOIDC.Admin.DuoIntegration" lazy-init="true"
-        class="net.shibboleth.idp.authn.duo.BasicDuoIntegration"
-        p:APIHost="%{idp.duo.oidc.admin.apiHost:%{idp.duo.oidc.apiHost:none}}"
-        p:integrationKey="%{idp.duo.oidc.admin.integrationKey:none}"
-        p:secretKey="%{idp.duo.oidc.admin.secretKey:none}"/>
-    <bean id="shibboleth.authn.DuoOIDC.Admin.DuoIntegrationStrategy" parent="shibboleth.Functions.Constant" lazy-init="true"
-        c:target-ref="shibboleth.authn.DuoOIDC.Admin.DuoIntegration" />
-
-    <bean id="shibboleth.authn.DuoOIDC.DefaultAdminClient" class="net.shibboleth.idp.plugin.authn.duo.impl.DefaultDuoAdminClient"
-        lazy-init="true"
-        p:objectMapper-ref="shibboleth.JSONObjectMapper"
-        p:httpClient="#{getObject('shibboleth.authn.DuoOIDC.Admin.HttpClient') ?: getObject('shibboleth.InternalHttpClient')}"
-        p:httpClientSecurityParameters="#{getObject('shibboleth.authn.DuoOIDC.Admin.HttpClientSecurityParameters')}"
-        p:adminDuoIntegrationLookupStrategy-ref="shibboleth.authn.DuoOIDC.Admin.DuoIntegrationStrategy"
-        p:backoffFactor="%{idp.duo.oidc.admin.backoffFactor:2}"
-        p:initialBackoff="%{idp.duo.oidc.admin.initialBackoff:1000}"
-        p:maxBackoff="%{idp.duo.oidc.admin.maxBackoff:16000}" />
-
-    <!-- Default passwordless condition that uses the admin API -->
-    <bean id="shibboleth.authn.DuoOIDC.Passwordless.DefaultCondition" lazy-init="true"
-            class="net.shibboleth.idp.plugin.authn.duo.DefaultPasswordlessEnrollmentCondition"
-            p:duoAdminClient="#{getObject('shibboleth.authn.DuoOIDC.AdminClient') ?: getObject('shibboleth.authn.DuoOIDC.DefaultAdminClient')}"
-            p:lockoutManager="#{%{idp.duo.oidc.passwordless.limitEnrollmentChecking:true} ? getObject('shibboleth.authn.DuoOIDC.Passwordless.LockoutManager') : null}">
-        <property name="allowedLabels">
-            <bean parent="shibboleth.CommaDelimStringArray"
-                c:_0="#{'%{idp.duo.oidc.passwordless.allowedLabels:}'.trim()}" />
-        </property>
-    </bean>
-
-    <bean id="shibboleth.authn.DuoOIDC.Passwordless.LockoutManager" lazy-init="true"
-            parent="shibboleth.StorageBackedAccountLockoutManager"
-            p:storageService-ref="#{'%{idp.duo.oidc.passwordless.enrollmentLockoutStorageService:shibboleth.StorageService}'.trim()}"
-            p:maxAttempts="%{idp.duo.oidc.passwordless.limitEnrollmentChecking.maxAttempts:30}"
-            p:counterInterval="%{idp.duo.oidc.passwordless.limitEnrollmentChecking.counterInterval:PT1M}"
-            p:lockoutDuration="%{idp.duo.oidc.passwordless.limitEnrollmentChecking.lockoutDuration:PT2M}"
-            p:extendLockoutDuration="false">
-        <property name="lockoutKeyStrategy">
-            <bean class="net.shibboleth.idp.plugin.authn.duo.DefaultPasswordlessEnrollmentCondition.IPLockoutKeyStrategy"
-                p:httpServletRequestSupplier-ref="shibboleth.HttpServletRequestSupplier" />
-        </property>
-    </bean>
-
 </beans>
\ No newline at end of file
diff --git a/idp-duo-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/authn/DuoOIDC/duo-oidc-authn-beans.xml b/idp-duo-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/authn/DuoOIDC/duo-oidc-authn-beans.xml
index 286c8e28..b2c3f549 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
@@ -79,6 +79,15 @@
     </bean>
     <bean id="shibboleth.authn.DuoOIDC.Passwordless.DuoIntegrationStrategy" parent="shibboleth.Functions.Constant"
         c:target="#{getObject('shibboleth.authn.DuoOIDC.Passwordless.DuoIntegration')}" />
+
+    <!-- Built-in condition to apply for factor-based passwordless authorization. -->
+    <bean id="shibboleth.authn.DuoOIDC.Passwordless.DefaultCondition" lazy-init="true"
+            class="net.shibboleth.idp.plugin.authn.duo.DefaultPasswordlessCondition">
+        <property name="allowedFactors">
+            <bean parent="shibboleth.CommaDelimStringArray"
+                c:_0="#{'%{idp.duo.oidc.passwordless.allowedFactors:Platform authenticator (2fa)}'.trim()}" />
+        </property>
+    </bean>
         
     <!-- Default "optional" non-browser integration. -->
     <bean id="shibboleth.authn.DuoOIDC.NonBrowser.DuoIntegration" lazy-init="false"
@@ -97,7 +106,7 @@
     <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
         'shibboleth.authn.DuoOIDC.clientFactory' for it to be auto-loaded. 
@@ -146,35 +155,17 @@
         p:resultCachingPredicate="#{getObject('shibboleth.authn.DuoOIDC.resultCachingPredicate')}" />
 
     <!-- Passwordless beans  -->
-    <bean id="CheckPasswordlessEnrollment2"
-        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('shibboleth.authn.DuoOIDC.Passwordless.DefaultCondition')}"
-        p:dataSealer="#{'%{idp.authn.usernameCookieName:}'.trim().isEmpty() ? null : getObject('shibboleth.DataSealer')}"
-        p:cookieManager="#{'%{idp.authn.usernameCookieName:}'.trim().isEmpty() ? null : getObject('shibboleth.PersistentCookieManager')}"
-        p:cookieName="%{idp.authn.usernameCookieName:}"
-        p:usernameFieldName="#{'%{idp.duo.oidc.usernameFieldName:j_username}'.trim()}"
-        p:lowercase="%{idp.duo.oidc.lowercase:false}"
-        p:uppercase="%{idp.duo.oidc.uppercase:false}"
-        p:trim="%{idp.duo.oidc.trim:true}"
-        p:transforms="#{getObject('shibboleth.authn.DuoOIDC.Transforms')}"
-        p:duoUsernameRemappingStrategy="#{getObject('shibboleth.authnn.DuoOIDC.UsernameRemappingStrategy')}"
-        p:signalEvents="true"
-        p:precedence="form" />
+    <bean id="PopulatePasswordlessContext" scope="prototype"
+        class="net.shibboleth.idp.plugin.authn.duo.impl.PopulatePasswordlessContext"
+        p:dataSealer="#{%{idp.duo.oidc.passwordless.enabled:false} ? getObject('shibboleth.DataSealer') : null}"
+        p:cookieManager="#{%{idp.duo.oidc.passwordless.enabled:false} ? getObject('shibboleth.PersistentCookieManager') : null}"
+        p:cookieName="%{idp.duo.oidc.passwordless.guardCookieName:__Host-shib_idp_duo_passwordless}" />
         
-    <!-- Same bean as above but property-based enrollment check for first iteration of loop. -->
-    <bean id="CheckPasswordlessEnrollment1" parent="CheckPasswordlessEnrollment2" scope="prototype"
-            p:signalEvents="%{idp.duo.oidc.passwordless.alwaysSignal:false}">
-        <property name="precedence">
-            <bean parent="shibboleth.CommaDelimStringArray"
-                c:_0="#{'%{idp.authn.usernamePrecedence:form,session,cookie}'.trim()}" />
-        </property>
-    </bean>
-    
     <!-- Duo OIDC beans -->
     <bean id="PopulateDuoAuthenticationContext" scope="prototype"
         class="net.shibboleth.idp.plugin.authn.duo.impl.PopulateDuoAuthenticationContext"
         p:httpServletRequestSupplier-ref="shibboleth.HttpServletRequestSupplier"
+        p:SSOBypassFieldName="%{idp.duo.oidc.passwordless.ssoBypassFieldName:donotcache}"
         p:standardDuoIntegrationLookupStrategy-ref="shibboleth.authn.DuoOIDC.DuoIntegrationStrategy"
         p:passwordlessDuoIntegrationLookupStrategy-ref="shibboleth.authn.DuoOIDC.Passwordless.DuoIntegrationStrategy"
         p:redirectURICreationStrategy-ref="shibboleth.authn.DuoOIDC.RedirectURICreationStrategy"
@@ -299,15 +290,29 @@
     <bean id="shibboleth.authn.DuoOIDC.DefaultCleanupHook" class="net.shibboleth.idp.plugin.authn.duo.DefaultDuoCleanupHook" />
           
     <bean id="ValidateDuoTokenAuthenticationResult" scope="prototype"
-        class="net.shibboleth.idp.plugin.authn.duo.impl.ValidateDuoTokenAuthenticationResult"        
+        class="net.shibboleth.idp.plugin.authn.duo.impl.ValidateDuoTokenAuthenticationResult"
         p:classifiedMessages="#{getObject('shibboleth.authn.DuoOIDC.ClassifiedMessageMap')}"
         p:resultCachingPredicate="#{getObject('shibboleth.authn.DuoOIDC.resultCachingPredicate')}"
-        p:cleanupHook="#{getObject('shibboleth.authn.DuoOIDC.CleanUpHook') ?: getObject('shibboleth.authn.DuoOIDC.DefaultCleanupHook')}"
         p:contextToPrincipalMappingStrategy="#{getObject('shibboleth.authn.DuoOIDC.ContextToPrincipalMappingStrategy')}" 
         p:addDefaultPrincipals="#{%{idp.authn.DuoOIDC.addDefaultPrincipals:true} and getObject('shibboleth.authn.DuoOIDC.ContextToPrincipalMappingStrategy') == null}"
         p:populateAuditContextAction="#{%{idp.duo.oidc.audit.enabled:%{idp.authn.audit.enabled:false}} ? getObject('PostDuoPopulateAuditContext') : null}"
         p:writeAuditLogAction="#{%{idp.duo.oidc.audit.enabled:%{idp.authn.audit.enabled:false}} ? getObject('WriteAuthnAuditLog') : null}" />
 
+    <bean id="PostValidatePasswordlessEvaluation"
+        class="net.shibboleth.idp.plugin.authn.duo.impl.PostValidatePasswordlessEvaluation" scope="prototype"
+        p:passwordlessCondition-ref="#{%{idp.duo.oidc.passwordless.enabled:false} ? '%{idp.duo.oidc.passwordless.guardCondition:shibboleth.authn.DuoOIDC.Passwordless.DefaultCondition}'.trim() : 'shibboleth.Conditions.FALSE'}"
+        p:cookieName="#{'%{idp.duo.oidc.passwordless.guardCookieName:__Host-shib_idp_duo_passwordless}'.trim()}"
+        p:cookieManager="#{%{idp.duo.oidc.passwordless.enabled:false} ? getObject('shibboleth.PersistentCookieManager') : null}"
+        p:dataSealer="#{%{idp.duo.oidc.passwordless.enabled:false} ? getObject('shibboleth.DataSealer') : null}"
+        p:cleanupHook="#{getObject('shibboleth.authn.DuoOIDC.CleanUpHook') ?: getObject('shibboleth.authn.DuoOIDC.DefaultCleanupHook')}"
+        p:requireResultCacheable="%{idp.duo.oidc.passwordless.requireResultCacheable:true}" />
+
+    <bean id="CreatePasswordlessCookie"
+        class="net.shibboleth.idp.plugin.authn.duo.impl.CreatePasswordlessCookie" scope="prototype"
+        p:cookieName="#{'%{idp.duo.oidc.passwordless.guardCookieName:__Host-shib_idp_duo_passwordless}'.trim()}"
+        p:cookieManager="#{%{idp.duo.oidc.passwordless.enabled:false} ? getObject('shibboleth.PersistentCookieManager') : null}"
+        p:dataSealer="#{%{idp.duo.oidc.passwordless.enabled:false} ? getObject('shibboleth.DataSealer') : null}" />
+
     <!-- Audit logging beans -->
     
     <!-- 
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 ce310a0f..35332df7 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
@@ -12,18 +12,15 @@
 
     <decision-state id="IsPasswordlessPossible">
         <if test="!opensamlProfileRequestContext.ensureSubcontext(T(net.shibboleth.idp.authn.context.AuthenticationContext)).isPassive() and opensamlProfileRequestContext.isBrowserProfile()"
-            then="CheckPasswordlessEnrollment1"
+            then="PopulatePasswordlessContext"
             else="RequestUnsupported" />
     </decision-state>
 
-    
-    <action-state id="CheckPasswordlessEnrollment1">
-        <evaluate expression="CheckPasswordlessEnrollment1" />
+    <action-state id="PopulatePasswordlessContext">
+        <evaluate expression="PopulatePasswordlessContext" />
         <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">
@@ -31,7 +28,7 @@
             <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.ensureSubcontext(T(net.shibboleth.idp.plugin.authn.duo.context.DuoPasswordlessContext)).getUsername()" result="viewScope.username" />
             <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" />
@@ -41,19 +38,14 @@
             <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="proceed" to="CheckDuoOIDCAuthAPI" />
         <transition on="cancel" to="RequestUnsupported" />
+        
+        <on-exit>
+            <evaluate expression="opensamlProfileRequestContext.addSubcontext(new net.shibboleth.idp.consent.context.ConsentManagementContext(), true).setRevokeConsent(requestParameters._shib_idp_revokeConsent == 'true')" />
+        </on-exit>
     </view-state>
     
-    <action-state id="CheckPasswordlessEnrollment2">
-        <evaluate expression="CheckPasswordlessEnrollment2" />
-        <evaluate expression="'proceed'" />
-        
-        <transition on="proceed" to="CheckDuoOIDCAuthAPI" />
-        <transition on="UnknownUsername" to="PasswordlessView" />
-        <transition on="RequestUnsupported" to="PasswordlessView" />
-    </action-state>
-
     <action-state id="ExtractDuoAuthenticationFromHeaders">
         <evaluate expression="ExtractDuoAuthenticationFromHeaders" />
         <evaluate expression="'proceed'" />
@@ -94,10 +86,10 @@
         <transition on="proceed" to="ValidateDuoResponse" />
     </view-state>
 
-    <!-- match the response state to the request state, fail if error  -->
+    <!-- match the response state to the request state, fail if error -->
     <action-state id="ValidateDuoResponse">
         <evaluate expression="ValidateExternalAuthenticationContext"/> 
-        <!-- Populate the audit context with the state before it is internall removed -->
+        <!-- Populate the audit context with the state before it is internally removed -->
         <evaluate expression="PreStateValidationPopulateAuditContext" />
         <evaluate expression="ValidateDuoResponseState"/>  
         <evaluate expression="'proceed'" />
@@ -112,10 +104,37 @@
         <evaluate expression="ValidateTokenClaims"/>
         <!-- final validation of the response status to build an authn result -->
         <evaluate expression="ValidateDuoTokenAuthenticationResult"/>
+        <evaluate expression="PostValidatePasswordlessEvaluation"/>
         <evaluate expression="'proceed'" />
         
         <transition on="proceed" to="proceed" />
-    </action-state>    
+        <transition on="PasswordlessPrompt" to="PasswordlessOptIn"/>
+    </action-state>
+
+    <view-state id="PasswordlessOptIn" view="passwordless-optin">
+        <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.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="CreatePasswordlessCookie" />
+        <transition on="cancel" to="proceed" />
+    </view-state>
+    
+    <action-state id="CreatePasswordlessCookie">
+        <evaluate expression="CreatePasswordlessCookie"/>
+        <evaluate expression="'proceed'" />
+        
+        <transition on="proceed" to="proceed" />
+    </action-state>
 
     <bean-import resource="duo-oidc-authn-beans.xml" />
 </flow>
\ No newline at end of file
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
index db2f051b..9262969a 100644
--- 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
@@ -8,15 +8,15 @@
 # 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.explain = If this isn't you, or you wish to bypass the option \
+    to use a Passkey or device to login, click the 'Login with Password' button.
 
 idp.duo.passwordless.proceed = Login with Passkey or Device
 idp.duo.passwordless.cancel = Login with Password
 
-idp.duo.passwordless.unsupported = The specified user has not enrolled a qualifying device for Passwordless use.
-idp.duo.passwordless.username = Please enter your username before attempting a Passwordless login.
-
 idp.duo.enrollment = Enroll New Devices
 idp.duo.enrollment.url = #
+
+idp.duo.passwordless.optin = Default to using your Passkey or device to login in the future?
+idp.duo.passwordless.optin.yes = Yes
+idp.duo.passwordless.optin.no = No
diff --git a/idp-duo-impl/src/main/resources/net/shibboleth/idp/plugin/authn/duo/views/passwordless-optin.vm b/idp-duo-impl/src/main/resources/net/shibboleth/idp/plugin/authn/duo/views/passwordless-optin.vm
new file mode 100644
index 00000000..7501a29f
--- /dev/null
+++ b/idp-duo-impl/src/main/resources/net/shibboleth/idp/plugin/authn/duo/views/passwordless-optin.vm
@@ -0,0 +1,62 @@
+##
+## 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
+## 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
+##
+<!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")" />
+            </header>
+            
+            <section>
+                <form action="$flowExecutionUrl" method="post">
+                    #parse("csrf/csrf.vm")
+
+                    <p>#springMessageText("idp.duo.passwordless.optin", "Default to using your Passkey or device to login in the future?")</p>
+    
+                    <div class="grid">
+                        <div class="grid-item">
+                            <button type="submit" name="_eventId_proceed"
+                                >#springMessageText("idp.duo.passwordless.optin.yes", "Yes")</button>
+                        </div>
+                        <div class="grid-item">
+                            <button type="submit" name="_eventId_cancel"
+                                >#springMessageText("idp.duo.passwordless.optin.no", "No")</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/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
index 25eb3ca5..b8f70303 100644
--- 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
@@ -7,7 +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
+## username - Duo username
 ## rpUIContext - the context with SP UI information from the metadata
 ## encoder - HTMLEncoder class
 ## cspDigester - Calculates base64-encoded SHA-2 hashes (call apply)
@@ -18,11 +18,6 @@
 ## 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 ($nonce = $cspNonce.generateIdentifier())
 $response.addHeader("Content-Security-Policy", "script-src-elem 'nonce-$nonce'")
 ##
@@ -67,28 +62,21 @@ $response.addHeader("Content-Security-Policy", "script-src-elem 'nonce-$nonce'")
                     <p>$encoder.encodeForHTML($desc)</p>
                 #end
 
-                <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>
-                
-                #if ($eventId == "RequestUnsupported")
-                    <p class="output-message output--error">$encoder.encodeForHTML("#springMessageText('idp.duo.passwordless.unsupported', 'The specified user has not enrolled a qualifying device for Passwordless use.')")</p>
-                #elseif ($eventId == "UnknownUsername")
-                    <p class="output-message output--error">$encoder.encodeForHTML("#springMessageText('idp.duo.passwordless.username', 'Please enter your username before attempting a Passwordless login.')")</p>
-                #end
-                        
                 <form action="$flowExecutionUrl" method="post">
                     #parse("csrf/csrf.vm")
                     
                     <label for="username">#springMessageText("idp.login.username", "Username")</label>
-                    <input id="j_username" name="j_username" type="text"
-                        value="#if($username)$encoder.encodeForHTML($username)#end" />
-                        
+                    <input id="unused" name="unused" type="text" readonly="true" value="$encoder.encodeForHTML($username)" />
+
+                    <p>#springMessageText("idp.duo.passwordless.explain", "If this isn't you, or you wish to bypass the option
+                    to use a Passkey or device to login, click the 'Login with Password' button.")</p>
+                            
                     <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>
+                    <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">
@@ -96,7 +84,7 @@ $response.addHeader("Content-Security-Policy", "script-src-elem 'nonce-$nonce'")
                                 >#springMessageText("idp.duo.passwordless.proceed", "Login with Passkey or Device")</button>
                         </div>
                         <div class="grid-item">
-                            <button type="submit" name="_eventId_cancel" onClick="$onClick"
+                            <button type="submit" name="_eventId_cancel"
                                 >#springMessageText("idp.duo.passwordless.cancel", "Login with Password")</button>
                         </div>
                     </div>
@@ -116,7 +104,7 @@ $response.addHeader("Content-Security-Policy", "script-src-elem 'nonce-$nonce'")
         
         <script #if ($nonce)nonce="$nonce"#end>
         <!--
-        const input = document.getElementById('j_username');
+        const input = document.getElementById('unused');
         const end = input.value.length;
         input.setSelectionRange(end, end);
         input.focus();
diff --git a/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/AbstractAuthnXmlFlowExecutionTests.java b/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/AbstractAuthnXmlFlowExecutionTests.java
index dfcb4743..33d8c2e3 100644
--- a/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/AbstractAuthnXmlFlowExecutionTests.java
+++ b/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/AbstractAuthnXmlFlowExecutionTests.java
@@ -458,12 +458,6 @@ public abstract class AbstractAuthnXmlFlowExecutionTests extends CustomAbstractX
         // Add bean to allow runtime exceptions to be logged (rather than a stack overflow).
         addBeanDefinition(builderContext, "LogRuntimeException",BeanDefinitionBuilder.
                 genericBeanDefinition(net.shibboleth.idp.profile.LogRuntimeException.class).getBeanDefinition());
-        
-        addBeanDefinition(builderContext, "shibboleth.StorageService",BeanDefinitionBuilder.
-                genericBeanDefinition(MemoryStorageService.class).setInitMethodName("initialize").getBeanDefinition());
-        
-        addBeanDefinition(builderContext, "shibboleth.StorageBackedAccountLockoutManager",BeanDefinitionBuilder.
-                genericBeanDefinition(StorageBackedAccountLockoutManager.class).setAbstract(true).getBeanDefinition());
 
         if (clientFactory != null) {
             //register the client factory
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
deleted file mode 100644
index 1a5eeaa4..00000000
--- a/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/CheckPasswordlessEnrollmentTest.java
+++ /dev/null
@@ -1,348 +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.time.Instant;
-import java.util.Set;
-
-import javax.annotation.Nonnull;
-import javax.annotation.Nullable;
-import javax.security.auth.Subject;
-
-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.net.URISupport;
-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.setSignalEvents(true);
-        action.initialize();
-    }
-    
-    @AfterMethod public void tearDown() {
-        HttpServletRequestResponseContext.clearCurrent();
-    }
-    
-    @Test public void testNoServlet() throws ComponentInitializationException {
-        action = new CheckPasswordlessEnrollment();
-        action.setSignalEvents(true);
-        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());
-        
-        final Cookie cookie = ensureMockResponse().getCookie(COOKIE_NAME);
-        assert cookie != null;
-        Assert.assertNull(cookie.getValue());
-        Assert.assertEquals(cookie.getMaxAge(), 0);
-    }
-    
-    @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());
-
-        final Cookie cookie2 = ensureMockResponse().getCookie(COOKIE_NAME);
-        assert cookie2 != null;
-        Assert.assertEquals(cookie2.getMaxAge(), 300);
-        String unwrapped = URISupport.doURLDecode(cookie2.getValue());
-        if (unwrapped != null) {
-            unwrapped = dataSealer.unwrap(unwrapped);
-        }
-        Assert.assertEquals(unwrapped, "foo");
-    }
-
-    @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());
-        ac.getActiveResults().put("foo", new AuthenticationResult("foo", new Subject()));
-        
-        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());
-        
-        final Cookie cookie2 = ensureMockResponse().getCookie(COOKIE_NAME);
-        assert cookie2 != null;
-        Assert.assertEquals(cookie2.getMaxAge(), 300);
-        String unwrapped = URISupport.doURLDecode(cookie2.getValue());
-        if (unwrapped != null) {
-            unwrapped = dataSealer.unwrap(unwrapped);
-        }
-        Assert.assertEquals(unwrapped, "foo");
-    }
-    
-    @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-nimbus-client-impl/src/main/resources/net/shibboleth/idp/plugin/authn/duo/nimbus/conf/authn/duo-oidc.properties b/idp-duo-nimbus-client-impl/src/main/resources/net/shibboleth/idp/plugin/authn/duo/nimbus/conf/authn/duo-oidc.properties
index 05389cae..e9be1df0 100644
--- a/idp-duo-nimbus-client-impl/src/main/resources/net/shibboleth/idp/plugin/authn/duo/nimbus/conf/authn/duo-oidc.properties
+++ b/idp-duo-nimbus-client-impl/src/main/resources/net/shibboleth/idp/plugin/authn/duo/nimbus/conf/authn/duo-oidc.properties
@@ -36,12 +36,16 @@ idp.duo.oidc.redirectURL = https://<hostname>:<port>/idp/profile/Authn/Duo/2FA/d
 #idp.authn.DuoOIDC.addDefaultPrincipals = true
 
 # Passwordless integration if desired
+#idp.duo.oidc.passwordless.enabled = false
 #idp.duo.oidc.passwordless.apiHost = %{idp.duo.oidc.apiHost}
-#idp.duo.oidc.passwordless.integrationKey = ikey
-# Suggest defining this in credentials/secrets.properties
+#idp.duo.oidc.passwordless.clientId = clientId
+# We suggest defining this in credentials/secrets.properties
 #idp.duo.oidc.passwordless.secretKey = key
-# Controls whether to report enrollment check on entry to form
-#idp.duo.oidc.passwordless.alwaysSignal = false
+#idp.duo.oidc.passwordless.ssoBypassFieldName = donotcache
+# Name of cookie used to "grant" access to passwordless login option
+#idp.duo.oidc.passwordless.guardCookieName = __Host-shib_idp_duo_passwordless
+# Override to plug in your own condition bean governing eligibility.
+#idp.duo.oidc.passwordless.guardCondition = (internal default)
 
 # Non-Browser AuthAPI integration if desired
 #idp.duo.oidc.nonbrowser.apiHost = %{idp.duo.oidc.apiHost}
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 9e090a54..0a66858c 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
@@ -15,3 +15,5 @@ idp.authn.DuoOIDC.2.src = /net/shibboleth/idp/plugin/authn/duo/nimbus/conf/authn
 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
+idp.authn.DuoOIDC.4.src = /net/shibboleth/idp/plugin/authn/duo/views/passwordless-optin.vm
+idp.authn.DuoOIDC.4.dest = views/passwordless-optin.vm
diff --git a/idp-duo-sdk-client-impl/src/main/resources/net/shibboleth/idp/plugin/authn/duo/sdk/conf/authn/duo-oidc.properties b/idp-duo-sdk-client-impl/src/main/resources/net/shibboleth/idp/plugin/authn/duo/sdk/conf/authn/duo-oidc.properties
index c05549cf..212b5335 100644
--- a/idp-duo-sdk-client-impl/src/main/resources/net/shibboleth/idp/plugin/authn/duo/sdk/conf/authn/duo-oidc.properties
+++ b/idp-duo-sdk-client-impl/src/main/resources/net/shibboleth/idp/plugin/authn/duo/sdk/conf/authn/duo-oidc.properties
@@ -36,12 +36,16 @@ idp.duo.oidc.redirectURL = https://<hostname>:<port>/idp/profile/Authn/Duo/2FA/d
 #idp.authn.DuoOIDC.addDefaultPrincipals = true
 
 # Passwordless integration if desired
+#idp.duo.oidc.passwordless.enabled = false
 #idp.duo.oidc.passwordless.apiHost = %{idp.duo.oidc.apiHost}
-#idp.duo.oidc.passwordless.integrationKey = ikey
-# Suggest defining this in credentials/secrets.properties
+#idp.duo.oidc.passwordless.clientId = clientId
+# We suggest defining this in credentials/secrets.properties
 #idp.duo.oidc.passwordless.secretKey = key
-# Controls whether to report enrollment check on entry to form
-#idp.duo.oidc.passwordless.alwaysSignal = false
+#idp.duo.oidc.passwordless.ssoBypassFieldName = donotcache
+# Name of cookie used to "grant" access to passwordless login option
+#idp.duo.oidc.passwordless.guardCookieName = __Host-shib_idp_duo_passwordless
+# Override to plug in your own condition bean governing eligibility.
+#idp.duo.oidc.passwordless.guardCondition = (internal default)
 
 # Non-Browser AuthAPI integration if desired
 #idp.duo.oidc.nonbrowser.apiHost = %{idp.duo.oidc.apiHost}
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 96865df4..36a21ef7 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
@@ -15,3 +15,5 @@ idp.authn.DuoOIDC.2.src = /net/shibboleth/idp/plugin/authn/duo/sdk/conf/authn/du
 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
+idp.authn.DuoOIDC.4.src = /net/shibboleth/idp/plugin/authn/duo/views/passwordless-optin.vm
+idp.authn.DuoOIDC.4.dest = views/passwordless-optin.vm

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


More information about the commits mailing list