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

Scott Cantor cantor.2 at osu.edu
Tue Apr 9 14:41:07 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=017d032022ab7fa0b74e64c37980f30a6e51b56c

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

commit 017d032022ab7fa0b74e64c37980f30a6e51b56c
Author: Scott Cantor <cantor.2 at osu.edu>
AuthorDate: Tue Apr 9 10:41:04 2024 -0400

    JDUO-80 - Use of Duo as a passwordless solution
    
    https://shibboleth.atlassian.net/browse/JDUO-80
    
    Third revamp, adjusting event signalling.
    Properly track opt-out to avoid nagging.
    Added new API for managing cookie for easier use by deployers.
    Allow bypass of cookie and view by MFA flow.
---
 .../authn/duo/PasswordlessCookieManager.java       | 247 +++++++++++++++++++++
 .../authn/duo/context/DuoPasswordlessContext.java  |  24 ++
 .../authn/duo/impl/ClearPasswordlessCookie.java    |  51 ++---
 .../authn/duo/impl/CreatePasswordlessCookie.java   | 100 +++++----
 .../duo/impl/PopulateDuoAuthenticationContext.java |   6 +-
 .../duo/impl/PopulatePasswordlessContext.java      |  97 +++-----
 .../impl/PostValidatePasswordlessEvaluation.java   |  99 +++------
 .../flows/authn/DuoOIDC/duo-oidc-authn-beans.xml   |  26 +--
 .../flows/authn/DuoOIDC/duo-oidc-authn-flow.xml    |  13 +-
 .../plugin/authn/duo/views/passwordless-optin.vm   |   8 +-
 10 files changed, 437 insertions(+), 234 deletions(-)

diff --git a/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/PasswordlessCookieManager.java b/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/PasswordlessCookieManager.java
new file mode 100644
index 00000000..999559a6
--- /dev/null
+++ b/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/PasswordlessCookieManager.java
@@ -0,0 +1,247 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.shibboleth.idp.plugin.authn.duo;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.slf4j.Logger;
+
+import com.google.common.net.UrlEscapers;
+
+import net.shibboleth.shared.annotation.constraint.NotEmpty;
+import net.shibboleth.shared.component.AbstractInitializableComponent;
+import net.shibboleth.shared.component.ComponentInitializationException;
+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;
+
+/**
+ * Wrapper for managing the passwordless guard cookie, allowing read/write with less explicit code,
+ * error handling, etc.
+ * 
+ * <p>The component can be wired up without the necessary components, but then all operations do nothing.
+ * This is allowed for the case where deployers disable the shared key feature of the IdP, though very rare.</p>
+ *      
+ * @since 2.1.0
+ */
+public class PasswordlessCookieManager extends AbstractInitializableComponent {
+
+    /** A negative signal to allow caching opt-out. */
+    @Nonnull @NotEmpty private static final String NEGATIVE_VALUE = "__NO";
+    
+    /** Class logger.*/
+    @Nonnull private final Logger log = LoggerFactory.getLogger(PasswordlessCookieManager.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;
+    
+    /** Flags whether the component is active or should no-op. */
+    private boolean active;
+    
+    /**
+     * 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 doInitialize() throws ComponentInitializationException {
+        super.doInitialize();
+        
+        active = cookieName != null && cookieManager != null && dataSealer != null;
+    }
+    
+    /**
+     * Tests whether the cookie's value indicates a cached negative response.
+     * 
+     * @return true iff the input value corresponds to the "opt-out" constant
+     */
+    public boolean isOptOut() {
+        checkComponentActive();
+
+        if (!active) {
+            return true;
+        }
+        
+        assert cookieManager != null;
+        assert cookieName != null;
+        final String value = cookieManager.getCookieValue(cookieName, null);
+        
+        return NEGATIVE_VALUE.equals(value);
+    }
+    
+    /**
+     * Read back existing cookie and return the username embedded in it, if any.
+     * 
+     * <p>A null is returned in the event of various decoding errors or if the cookie
+     * contains the "negative" magic value.</p>
+     * 
+     * @return username from sealed cookie, or null
+     */
+    @Nullable @NotEmpty public String readCookie() {
+        checkComponentActive();
+        
+        if (!active) {
+            return null;
+        }
+        
+        assert cookieManager != null;
+        assert cookieName != null;
+        String wrapped = cookieManager.getCookieValue(cookieName, null);
+        if (wrapped == null) {
+            return null;
+        }
+        
+        if (NEGATIVE_VALUE.equals(wrapped)) {
+            return null;
+        }
+        
+        wrapped = URISupport.doURLDecode(wrapped);
+        if (wrapped == null) {
+            log.error("Error decoding unwrapped cookie value");
+            return null;
+        }
+        
+        try {
+            assert dataSealer != null;
+            return dataSealer.unwrap(wrapped);
+        } catch (final DataSealerException e) {
+            log.warn("Unable to unwrap sealed cookie", e);
+        }
+        
+        return null;
+    }
+    
+    /**
+     * Creates a fresh cookie for a given username (or a placeholder if null to indicate the negative).
+     * 
+     * <p>TODO: Notably the second parameter is currently unimplemented until a version of the plugin with the correct
+     * API dependency is released.</p>
+     * 
+     * @param username username or null
+     * @param maxAge lifetime of the cookie
+     * 
+     * @return true iff the operation succeeded
+     */
+    public boolean writeCookie(@Nullable final String username, final int maxAge) {
+        checkComponentActive();
+        
+        if (!active) {
+            return false;
+        }
+        
+        if (username == null || NEGATIVE_VALUE.equals(username)) {
+            assert cookieManager != null;
+            assert cookieName != null;
+            cookieManager.addCookie(cookieName, NEGATIVE_VALUE);
+            return true;
+        }
+        
+        try {
+            assert dataSealer != null;
+            String wrapped = dataSealer.wrap(username);
+            wrapped = UrlEscapers.urlFormParameterEscaper().escape(wrapped);
+            assert cookieManager != null;
+            assert cookieName != null;
+            assert wrapped != null;
+            cookieManager.addCookie(cookieName, wrapped);
+            return true;
+        } catch (final DataSealerException e) {
+            log.warn("Unable to wrap username for cookie", e);
+            return false;
+        }
+    }
+
+    /**
+     * For a non-negative cookie, this recreates the cookie using the current default key to ensure it can
+     * continue to be read.
+     * 
+     * <p>TODO: Notably the second parameter is currently unimplemented until a version of the plugin with the correct
+     * API dependency is released.</p>
+     * 
+     * @param maxAge lifetime of the cookie
+     * 
+     * @return true iff the operation succeeded
+     */
+    public boolean refreshCookie(final int maxAge) {
+        checkComponentActive();
+
+        if (!active) {
+            return false;
+        }
+        
+        final String username = readCookie();
+        if (username != null) {
+            return writeCookie(username, maxAge);
+        }
+        
+        return true;
+    }
+    
+    /**
+     * Unset the cookie.
+     */
+    public void clearCookie() {
+        checkComponentActive();
+
+        if (!active) {
+            return;
+        }
+
+        assert cookieManager != null;
+        assert cookieName != null;
+        cookieManager.unsetCookie(cookieName);
+    }
+    
+    
+}
\ 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 114e5d52..8f75d0cc 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
@@ -38,6 +38,9 @@ public final class DuoPasswordlessContext extends BaseContext {
     /** Username. */
     @Nullable private String username;
     
+    /** Whether to bypass the passwordless view directly into Duo. */
+    private boolean bypassView;
+    
     /**
      * Get the username.
      * 
@@ -59,4 +62,25 @@ public final class DuoPasswordlessContext extends BaseContext {
         return this;
     }
 
+    /**
+     * Get whether to bypass the view in favor of directly invoking the service.
+     * 
+     * @return true iff the view should be bypassed
+     */
+    public boolean isBypassView() {
+        return bypassView;
+    }
+    
+    /**
+     * Set whether to bypass the view in favor of directly invoking the service.
+     * 
+     * @param flag flag to set
+     * 
+     * @return this context
+     */
+    @Nonnull public DuoPasswordlessContext setBypassView(final boolean flag) {
+        bypassView = flag;
+        return this;
+    }
+    
 }
\ No newline at end of file
diff --git a/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/impl/ClearPasswordlessCookie.java b/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/impl/ClearPasswordlessCookie.java
index dc8eaaa1..d022a6b2 100644
--- a/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/impl/ClearPasswordlessCookie.java
+++ b/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/impl/ClearPasswordlessCookie.java
@@ -17,18 +17,16 @@ 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 net.shibboleth.idp.authn.AuthnEventIds;
+import net.shibboleth.idp.plugin.authn.duo.PasswordlessCookieManager;
 import net.shibboleth.idp.profile.AbstractProfileAction;
-import net.shibboleth.shared.annotation.constraint.NonnullBeforeExec;
-import net.shibboleth.shared.annotation.constraint.NotEmpty;
-import net.shibboleth.shared.net.CookieManager;
+import net.shibboleth.shared.annotation.constraint.NonnullAfterInit;
+import net.shibboleth.shared.component.ComponentInitializationException;
+import net.shibboleth.shared.logic.Constraint;
 import net.shibboleth.shared.primitive.LoggerFactory;
-import net.shibboleth.shared.primitive.StringSupport;
 
 /**
  * A profile action to clear the passwordless guard cookie.
@@ -42,49 +40,29 @@ public class ClearPasswordlessCookie extends AbstractProfileAction {
     
     /** Class logger. */
     @Nonnull private final Logger log = LoggerFactory.getLogger(ClearPasswordlessCookie.class);
-    
-    /** Passwordless cookie name. */
-    @NonnullBeforeExec @NotEmpty private String cookieName;
 
     /** Optional cookie manager to use. */
-    @NonnullBeforeExec private CookieManager cookieManager;
-    
-    /**
-     * 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);
-    }
+    @NonnullAfterInit private PasswordlessCookieManager cookieManager;
     
     /**
-     * Sets optional {@link CookieManager} to use.
+     * Sets {@link PasswordlessCookieManager} to use.
      * 
      * @param manager cookie manager
      */
-    public void setCookieManager(@Nullable final CookieManager manager) {
+    public void setCookieManager(@Nullable final PasswordlessCookieManager manager) {
         checkSetterPreconditions();
         
-        cookieManager = manager;
+        cookieManager = Constraint.isNotNull(manager, "PasswordlessCookieManager cannot be null");
     }
-
+    
     /** {@inheritDoc} */
     @Override
-    protected boolean doPreExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
-        if (!super.doPreExecute(profileRequestContext)) {
-            ActionSupport.buildEvent(profileRequestContext, AuthnEventIds.REQUEST_UNSUPPORTED);
-            return false;
-        }
+    protected void doInitialize() throws ComponentInitializationException {
+        super.doInitialize();
         
-        if (cookieName == null || cookieManager == null) {
-            ActionSupport.buildEvent(profileRequestContext, AuthnEventIds.REQUEST_UNSUPPORTED);
-            return false;
+        if (cookieManager == null) {
+            throw new ComponentInitializationException("PasswordlessCookieManager cannot be null");
         }
-        
-        return true;
     }
 
     /** {@inheritDoc} */
@@ -93,8 +71,7 @@ public class ClearPasswordlessCookie extends AbstractProfileAction {
         
         log.debug("{} Clearing passwordless guard cookie at user instruction", getLogPrefix());
         
-        assert cookieName != null;
-        cookieManager.unsetCookie(cookieName);
+        cookieManager.clearCookie();
     }
     
 }
\ 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
index 61ff2ed2..364e0914 100644
--- 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
@@ -22,22 +22,25 @@ 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.AuthnEventIds;
 import net.shibboleth.idp.authn.context.SubjectCanonicalizationContext;
+import net.shibboleth.idp.plugin.authn.duo.PasswordlessCookieManager;
 import net.shibboleth.idp.profile.AbstractProfileAction;
+import net.shibboleth.shared.annotation.constraint.NonnullAfterInit;
 import net.shibboleth.shared.annotation.constraint.NotEmpty;
-import net.shibboleth.shared.net.CookieManager;
+import net.shibboleth.shared.component.ComponentInitializationException;
+import net.shibboleth.shared.logic.Constraint;
 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.
  * 
+ * <p>A form field indicates whether an opt-in or opt-out is to be created.</p>
+ * 
+ * <p>TODO: In a future release the cookie lifetimes will be controllable.</p>
+ * 
  * @event {@link EventIds#PROCEED_EVENT_ID}
  * @event {@link AuthnEventIds#INVALID_SUBJECT_C14N_CTX}
  * 
@@ -49,62 +52,67 @@ import net.shibboleth.shared.security.DataSealerException;
  */
 public class CreatePasswordlessCookie extends AbstractProfileAction {
 
+    /** Name of form field signalling yes/no to opt-in. */
+    @Nonnull @NotEmpty public static final String OPTIN_FIELD_NAME = "optin";
+    
     /** 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;
+    @NonnullAfterInit private PasswordlessCookieManager cookieManager;
     
-    /**
-     * 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);
+    /** Max-Age of opt-in cookie. */
+    private int optInMaxAge;
+    
+    /** Max-Age of opt-out cookie. */
+    private int optOutMaxAge;
+    
+    /** Constructor. */
+    public CreatePasswordlessCookie() {
+        optInMaxAge = 0;
+        optOutMaxAge = 0;
     }
-
+    
     /**
-     * Sets {@link CookieManager} to use.
+     * Sets {@link PasswordlessCookieManager} to use.
      * 
      * @param manager cookie manager
      */
-    public void setCookieManager(@Nullable final CookieManager manager) {
+    public void setCookieManager(@Nullable final PasswordlessCookieManager manager) {
         checkSetterPreconditions();
         
-        cookieManager = manager;
+        cookieManager = Constraint.isNotNull(manager, "PasswordlessCookieManager cannot be null");
     }
-
-    /**
-     * Sets {@link DataSealer} to use.
-     * 
-     * @param sealer data sealer
-     */
-    public void setDataSealer(@Nullable final DataSealer sealer) {
-        checkSetterPreconditions();
+    
+    /** {@inheritDoc} */
+    @Override
+    protected void doInitialize() throws ComponentInitializationException {
+        super.doInitialize();
         
-        dataSealer = sealer;
+        if (cookieManager == null) {
+            throw new ComponentInitializationException("PasswordlessCookieManager cannot be null");
+        }
     }
     
     /** {@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");
+        
+        final String optin;
+        final HttpServletRequest request = getHttpServletRequest();
+        if (request == null) {
+            log.error("{} HttpServletRequest absent, assuming opt-out", getLogPrefix());
+            optin = "0";
+        } else {
+            optin = request.getParameter(OPTIN_FIELD_NAME);
+        }
+        
+        if (!"1".equals(optin)) {
+            if (!cookieManager.writeCookie(null, optOutMaxAge)) {
+                log.warn("{} Failed to create passwordless cookie for opt-out", getLogPrefix());
+            }
             return;
         }
-
+        
         final SubjectCanonicalizationContext c14nContext =
                 profileRequestContext.getSubcontext(SubjectCanonicalizationContext.class);
         final String username = c14nContext != null ? c14nContext.getPrincipalName() : null;
@@ -114,11 +122,9 @@ public class CreatePasswordlessCookie extends AbstractProfileAction {
             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);
+        log.debug("{} Creating passwordless cookie for username '{}'", getLogPrefix(), username);
+        if (!cookieManager.writeCookie(username, optInMaxAge)) {
+            log.warn("{} Failed to create passwordless cookie for username '{}'", getLogPrefix(), username);
         }
     }
     
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 98898b3c..6d148017 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
@@ -62,7 +62,7 @@ import net.shibboleth.shared.primitive.StringSupport;
  * 
  * @event {@link EventIds#PROCEED_EVENT_ID}
  * @event {@link EventIds#INVALID_PROFILE_CTX}
- * @event {@link AuthnEventIds#NO_CREDENTIALS}
+ * @event {@link AuthnEventIds#UNKNOWN_USERNAME}
  * @event {@link AuthnEventIds#AUTHN_EXCEPTION}
  * @post See above.
  */
@@ -279,7 +279,7 @@ public class PopulateDuoAuthenticationContext extends AbstractAuthenticationActi
 
         if (passwordlessContext.getUsername() == null) {
             log.warn("{} No principal name available to initiate a Duo 2FA request", getLogPrefix());
-            ActionSupport.buildEvent(profileRequestContext, AuthnEventIds.NO_CREDENTIALS);
+            ActionSupport.buildEvent(profileRequestContext, AuthnEventIds.UNKNOWN_USERNAME);
             return false;
         }
         duoContext.setUsername(passwordlessContext.getUsername());
@@ -331,7 +331,7 @@ public class PopulateDuoAuthenticationContext extends AbstractAuthenticationActi
         final String username = usernameLookupStrategy.apply(profileRequestContext);
         if (username == null) {
             log.warn("{} No principal name available to initiate a Duo 2FA request", getLogPrefix());
-            ActionSupport.buildEvent(profileRequestContext, AuthnEventIds.NO_CREDENTIALS);
+            ActionSupport.buildEvent(profileRequestContext, AuthnEventIds.UNKNOWN_USERNAME);
             return false;
         }
         duoContext.setUsername(username);
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
index 88a9eb0d..6e3e3e5b 100644
--- 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
@@ -27,29 +27,30 @@ import org.slf4j.Logger;
 
 import net.shibboleth.idp.authn.AuthnEventIds;
 import net.shibboleth.idp.authn.context.AuthenticationContext;
+import net.shibboleth.idp.plugin.authn.duo.PasswordlessCookieManager;
 import net.shibboleth.idp.plugin.authn.duo.context.DuoPasswordlessContext;
 import net.shibboleth.idp.profile.AbstractProfileAction;
+import net.shibboleth.shared.annotation.constraint.NonnullAfterInit;
 import net.shibboleth.shared.annotation.constraint.NonnullBeforeExec;
-import net.shibboleth.shared.annotation.constraint.NotEmpty;
+import net.shibboleth.shared.component.ComponentInitializationException;
 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 and populate it
+ * A profile action to extract passwordless username from a sealed cookie and populate it
  * into an existing {@link DuoPasswordlessContext}.
  * 
- * <p>If the context contains a username already, the action simply exits.</p>
+ * <p>If the context contains a username already, the action simply proceeds.</p>
+ * 
+ * <p>If the cookie exists but signals an opt-out, then the {@link AuthnEventIds#RESELECT_FLOW}
+ * event is signalled.</p>
  * 
  * @since 2.1.0
  * 
  * @event {@link EventIds#PROCEED_EVENT_ID}
  * @event {@link EventIds#INVALID_PROFILE_CTX}
  * @event {@link AuthnEventIds#REQUEST_UNSUPPORTED}
+ * @event {@link AuthnEventIds#RESELECT_FLOW}
  * @pre  <pre>AuthenticationContext.getSubcontext(DuoPasswordlessContext.class) != null</pre>
  * @post <pre>DuoPasswordlessContext.getUsername() != null</pre> 
  */
@@ -60,15 +61,9 @@ public class PopulatePasswordlessContext extends AbstractProfileAction {
 
     /** 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;
+    @NonnullAfterInit private PasswordlessCookieManager cookieManager;
     
     /** Context to populate. */
     @NonnullBeforeExec private DuoPasswordlessContext passwordlessContext;
@@ -95,36 +90,24 @@ public class PopulatePasswordlessContext extends AbstractProfileAction {
     }
     
     /**
-     * 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.
+     * Sets {@link PasswordlessCookieManager} to use.
      * 
      * @param manager cookie manager
      */
-    public void setCookieManager(@Nullable final CookieManager manager) {
+    public void setCookieManager(@Nullable final PasswordlessCookieManager manager) {
         checkSetterPreconditions();
         
-        cookieManager = manager;
+        cookieManager = Constraint.isNotNull(manager, "PasswordlessCookieManager cannot be null");
     }
-
-    /**
-     * Sets optional {@link DataSealer} to use.
-     * 
-     * @param sealer data sealer
-     */
-    public void setDataSealer(@Nullable final DataSealer sealer) {
-        checkSetterPreconditions();
+    
+    /** {@inheritDoc} */
+    @Override
+    protected void doInitialize() throws ComponentInitializationException {
+        super.doInitialize();
         
-        dataSealer = sealer;
+        if (cookieManager == null) {
+            throw new ComponentInitializationException("PasswordlessCookieManager cannot be null");
+        }
     }
 
     /** {@inheritDoc} */
@@ -135,11 +118,6 @@ public class PopulatePasswordlessContext extends AbstractProfileAction {
             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);
@@ -152,37 +130,28 @@ public class PopulatePasswordlessContext extends AbstractProfileAction {
     /** {@inheritDoc} */
     @Override
     protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
-
-        final String existingUsername = passwordlessContext.getUsername();
-        if (existingUsername != null) {
-            log.debug("Existing username '{}' in context left in place", getLogPrefix(), existingUsername);
+        
+        if (passwordlessContext.getUsername() != null) {
+            log.debug("{} Pre-existing username for passwordless authentication: {}", getLogPrefix(),
+                    passwordlessContext.getUsername());
             return;
         }
         
-        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);
+        if (cookieManager.isOptOut()) {
+            log.debug("{} Guard cookie indicates opt-out", getLogPrefix());
+            ActionSupport.buildEvent(profileRequestContext, AuthnEventIds.RESELECT_FLOW);
             return;
         }
         
-        cookie = URISupport.doURLDecode(cookie);
-        if (cookie == null) {
-            log.debug("{} Unable to decode guard cookie", getLogPrefix());
+        final String username = cookieManager.readCookie();
+        if (username == null) {
+            log.debug("{} Guard cookie missing or unreadable", 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);
-        }
+        passwordlessContext.setUsername(username);
+        log.debug("{} Extracted username for passwordless authentication from cookie: {}", getLogPrefix(), username);
     }
     
 }
\ 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
index 7082ffa8..3bdeb37b 100644
--- 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
@@ -28,17 +28,15 @@ 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.PasswordlessCookieManager;
 import net.shibboleth.idp.plugin.authn.duo.context.DuoOIDCAuthenticationContext;
+import net.shibboleth.shared.annotation.constraint.NonnullAfterInit;
 import net.shibboleth.shared.annotation.constraint.NonnullBeforeExec;
 import net.shibboleth.shared.annotation.constraint.NotEmpty;
+import net.shibboleth.shared.component.ComponentInitializationException;
 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
@@ -91,14 +89,8 @@ public class PostValidatePasswordlessEvaluation extends AbstractAuthenticationAc
     /** 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;
+    /** Cookie manager to use. */
+    @NonnullAfterInit private PasswordlessCookieManager cookieManager;
     
     /** Duo authentiction context. */
     @NonnullBeforeExec private DuoOIDCAuthenticationContext duoContext;
@@ -142,38 +134,26 @@ public class PostValidatePasswordlessEvaluation extends AbstractAuthenticationAc
         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.
+     * Sets {@link PasswordlessCookieManager} to use.
      * 
      * @param manager cookie manager
      */
-    public void setCookieManager(@Nullable final CookieManager manager) {
+    public void setCookieManager(@Nullable final PasswordlessCookieManager manager) {
         checkSetterPreconditions();
         
-        cookieManager = manager;
+        cookieManager = Constraint.isNotNull(manager, "PasswordlessCookieManager cannot be null");
     }
-
-    /**
-     * Sets {@link DataSealer} to use.
-     * 
-     * @param sealer data sealer
-     */
-    public void setDataSealer(@Nullable final DataSealer sealer) {
-        checkSetterPreconditions();
+    
+    /** {@inheritDoc} */
+    @Override
+    protected void doInitialize() throws ComponentInitializationException {
+        super.doInitialize();
         
-        dataSealer = sealer;
+        if (cookieManager == null) {
+            throw new ComponentInitializationException("PasswordlessCookieManager cannot be null");
+        }
     }
     
     /** {@inheritDoc} */
@@ -189,14 +169,6 @@ public class PostValidatePasswordlessEvaluation extends AbstractAuthenticationAc
     @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) {
@@ -221,28 +193,31 @@ public class PostValidatePasswordlessEvaluation extends AbstractAuthenticationAc
 
         if (!authenticationContext.isResultCacheable() && requireResultCacheable) {
             log.debug("{} Non-cacheable authentication, clearing guard cookie if set", getLogPrefix());
-            localManager.unsetCookie(localCookieName);
-        } else if (!integration.isPasswordless()) {
+            cookieManager.clearCookie();
+        } else if (integration.isPasswordless()) {
+            log.debug("{} Refreshing passwordless cookie for '{}' if set", getLogPrefix(), username);
+            if (!cookieManager.refreshCookie(0)) {
+                log.warn("{} Unable to refresh passwordless cookie for '{}'", getLogPrefix(), username);
+            }
+        } else if (cookieManager.isOptOut()) {
+            log.debug("{} Opt-out cookie found, skipping prompt for '{}'", getLogPrefix(), username);
+            return;
+        } else {
             // Read in existing cookie, if any.
-            String cookie = localManager.getCookieValue(localCookieName, null);
+            final String cookie = cookieManager.readCookie();
             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);
+                if (username.equals(cookie)) {
+                    log.debug("{} Refreshing passwordless cookie for '{}' if set", getLogPrefix(), username);
+                    if (!cookieManager.refreshCookie(0)) {
+                        log.warn("{} Unable to refresh passwordless cookie for '{}'", getLogPrefix(), username);
                     }
+                    return;
+                } else {
+                    // Clear the existing cookie to start fresh.
+                    log.info("{} Clearing existing guard cookie for original username '{}'", getLogPrefix(),
+                            cookie);
+                    cookieManager.clearCookie();
                 }
-                localManager.unsetCookie(localCookieName);
             }
             
             // This is a new user without an existing cookie set, so establish eligibility.
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 31c619a4..74aea3b0 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
@@ -155,16 +155,24 @@
         p:resultCachingPredicate="#{getObject('shibboleth.authn.DuoOIDC.resultCachingPredicate')}" />
 
     <!-- Passwordless beans  -->
-    <bean id="PopulatePasswordlessContext" scope="prototype"
-        class="net.shibboleth.idp.plugin.authn.duo.impl.PopulatePasswordlessContext"
+    <bean id="PasswordlessCookieManager" lazy-init="true"
+        class="net.shibboleth.idp.plugin.authn.duo.PasswordlessCookieManager"
         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}" />
+    
+    <bean id="PopulatePasswordlessContext" scope="prototype"
+        class="net.shibboleth.idp.plugin.authn.duo.impl.PopulatePasswordlessContext"
+        p:cookieManager-ref="PasswordlessCookieManager" />
 
     <bean id="ClearPasswordlessCookie" scope="prototype"
         class="net.shibboleth.idp.plugin.authn.duo.impl.ClearPasswordlessCookie"
-        p:cookieManager="#{%{idp.duo.oidc.passwordless.enabled:false} ? getObject('shibboleth.PersistentCookieManager') : null}"
-        p:cookieName="%{idp.duo.oidc.passwordless.guardCookieName:__Host-shib_idp_duo_passwordless}" />
+        p:cookieManager-ref="PasswordlessCookieManager" />
+
+    <bean id="CreatePasswordlessCookie"
+        class="net.shibboleth.idp.plugin.authn.duo.impl.CreatePasswordlessCookie" scope="prototype"
+        p:cookieManager-ref="PasswordlessCookieManager"
+        p:httpServletRequestSupplier-ref="shibboleth.HttpServletRequestSupplier" />
         
     <!-- Duo OIDC beans -->
     <bean id="PopulateDuoAuthenticationContext" scope="prototype"
@@ -306,18 +314,10 @@
     <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:cookieManager-ref="PasswordlessCookieManager"
         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 1ed4db5c..449b404a 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,10 +12,16 @@
 
     <decision-state id="IsPasswordlessPossible">
         <if test="!opensamlProfileRequestContext.ensureSubcontext(T(net.shibboleth.idp.authn.context.AuthenticationContext)).isPassive() and opensamlProfileRequestContext.isBrowserProfile()"
-            then="PopulatePasswordlessContext"
+            then="CheckForViewBypass"
             else="RequestUnsupported" />
     </decision-state>
 
+    <decision-state id="CheckForViewBypass">
+        <if test="opensamlProfileRequestContext.ensureSubcontext(T(net.shibboleth.idp.authn.context.AuthenticationContext)).ensureSubcontext(T(net.shibboleth.idp.plugin.authn.duo.context.DuoPasswordlessContext)).isBypassView()"
+            then="CheckDuoOIDCAuthAPI"
+            else="PopulatePasswordlessContext" />
+    </decision-state>
+
     <action-state id="PopulatePasswordlessContext">
         <evaluate expression="PopulatePasswordlessContext" />
         <evaluate expression="'proceed'" />
@@ -40,7 +46,7 @@
 
         <transition on="proceed" to="CheckDuoOIDCAuthAPI" />
         <transition on="clear" to="ClearPasswordlessCookie" />
-        <transition on="cancel" to="RequestUnsupported" />
+        <transition on="cancel" to="ReselectFlow" />
         
         <on-exit>
             <evaluate expression="opensamlProfileRequestContext.addSubcontext(new net.shibboleth.idp.consent.context.ConsentManagementContext(), true).setRevokeConsent(requestParameters._shib_idp_revokeConsent == 'true')" />
@@ -51,7 +57,7 @@
         <evaluate expression="ClearPasswordlessCookie" />
         <evaluate expression="'proceed'" />
 
-        <transition on="proceed" to="RequestUnsupported" />
+        <transition on="proceed" to="IdentitySwitch" />
     </action-state>
     
     <action-state id="ExtractDuoAuthenticationFromHeaders">
@@ -134,7 +140,6 @@
         </on-render>
 
         <transition on="proceed" to="CreatePasswordlessCookie" />
-        <transition on="cancel" to="proceed" />
     </view-state>
     
     <action-state id="CreatePasswordlessCookie">
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
index 7501a29f..770e8487 100644
--- 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
@@ -34,16 +34,16 @@
                 <form action="$flowExecutionUrl" method="post">
                     #parse("csrf/csrf.vm")
 
+                    <input type="hidden" name="_eventId_proceed" value="1" />
+
                     <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>
+                            <button type="submit" name="optin" value="1">#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>
+                            <button type="submit" name="optin" value="0">#springMessageText("idp.duo.passwordless.optin.no", "No")</button>
                         </div>
                     </div>
                 </form>

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


More information about the commits mailing list