[java-idp-plugin-duo] branch main updated: JDUO-87 - Add an admin flow for managing the passwordless cookie

Scott Cantor cantor.2 at osu.edu
Tue Apr 23 19:43:58 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=ed715b7ed5c94b2291c7988018df3e7ce6cfaaea

The following commit(s) were added to refs/heads/main by this push:
     new ed715b7e JDUO-87 - Add an admin flow for managing the passwordless cookie
ed715b7e is described below

commit ed715b7ed5c94b2291c7988018df3e7ce6cfaaea
Author: Scott Cantor <cantor.2 at osu.edu>
AuthorDate: Tue Apr 23 15:43:55 2024 -0400

    JDUO-87 - Add an admin flow for managing the passwordless cookie
    
    https://shibboleth.atlassian.net/browse/JDUO-87
---
 idp-duo-impl/pom.xml                               |  17 +-
 .../authn/duo/admin/impl/AdminAccessPredicate.java |  78 ++++++++
 .../authn/duo/admin/impl/AdminFlowDescriptor.java  |  81 +++++++++
 .../duo/admin/impl/ManagePasswordlessCookie.java   | 197 +++++++++++++++++++++
 .../plugin/authn/duo/admin/impl/package-info.java  |  16 ++
 .../plugin/authn/duo/audit/impl/package-info.java  |  16 ++
 .../authn/duo/impl/ClearPasswordlessCookie.java    |   2 +-
 .../authn/duo/impl/CreatePasswordlessCookie.java   |   2 +-
 .../META-INF/net.shibboleth.idp/postconfig.xml     |  21 ++-
 .../flows/admin/DuoOIDC/duo-oidc-admin-beans.xml   |  40 +++++
 .../flows/admin/DuoOIDC/duo-oidc-admin-flow.xml    |  57 ++++++
 .../idp/plugin/authn/duo/messages.properties       |  10 ++
 .../authn/duo/views/admin/passwordless-admin.vm    |  98 ++++++++++
 .../duo/nimbus/conf/authn/duo-oidc.properties      |   9 +
 .../idp/plugin/authn/duo/nimbus/module.properties  |   2 +
 .../authn/duo/sdk/conf/authn/duo-oidc.properties   |   9 +
 .../idp/plugin/authn/duo/sdk/module.properties     |   2 +
 17 files changed, 648 insertions(+), 9 deletions(-)

diff --git a/idp-duo-impl/pom.xml b/idp-duo-impl/pom.xml
index 95285957..2537c0c8 100644
--- a/idp-duo-impl/pom.xml
+++ b/idp-duo-impl/pom.xml
@@ -40,12 +40,7 @@
         </dependency>        
         <dependency>
             <groupId>${idp.groupId}</groupId>
-            <artifactId>idp-saml-api</artifactId>
-            <scope>provided</scope>
-        </dependency>
-        <dependency>
-            <groupId>${idp.groupId}</groupId>
-            <artifactId>idp-session-api</artifactId>
+            <artifactId>idp-admin-api</artifactId>
             <scope>provided</scope>
         </dependency>
         <dependency>
@@ -64,6 +59,16 @@
             <artifactId>idp-profile-api</artifactId>
             <scope>provided</scope>
         </dependency>
+        <dependency>
+            <groupId>${idp.groupId}</groupId>
+            <artifactId>idp-saml-api</artifactId>
+            <scope>provided</scope>
+        </dependency>
+        <dependency>
+            <groupId>${idp.groupId}</groupId>
+            <artifactId>idp-session-api</artifactId>
+            <scope>provided</scope>
+        </dependency>
         <dependency>
             <groupId>${idp.groupId}</groupId>
             <artifactId>idp-ui</artifactId>
diff --git a/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/admin/impl/AdminAccessPredicate.java b/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/admin/impl/AdminAccessPredicate.java
new file mode 100644
index 00000000..1bc9c466
--- /dev/null
+++ b/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/admin/impl/AdminAccessPredicate.java
@@ -0,0 +1,78 @@
+/*
+ * 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.admin.impl;
+
+import java.util.function.Supplier;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.opensaml.profile.context.ProfileRequestContext;
+
+import jakarta.servlet.http.HttpServletRequest;
+import net.shibboleth.profile.context.RelyingPartyContext;
+import net.shibboleth.profile.context.logic.AbstractRelyingPartyPredicate;
+import net.shibboleth.shared.annotation.constraint.NonnullAfterInit;
+import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.security.AccessControlService;
+
+/**
+ * Wrapper predicate for implementing access control check for admin flow.
+ */
+public class AdminAccessPredicate extends AbstractRelyingPartyPredicate {
+
+    /** Access control service. */
+    @NonnullAfterInit private AccessControlService accessControlService;
+    
+    /** ServletRequest supplier. */
+    @Nullable private Supplier<HttpServletRequest> requestSupplier;
+
+    /**
+     * Sets {@link AccessControlService} to use for guarding admin access to cookie.
+     * 
+     * @param service access control service
+     */
+    public void setAccessControlService(@Nonnull final AccessControlService service) {
+        
+        accessControlService = Constraint.isNotNull(service, "AccessControlService cannot be null");
+    }
+    
+    /**
+     * Sets the supplier of the active {@link HttpServletRequest}.
+     * 
+     * @param supplier request supplier
+     */
+    public void setHttpServletRequestSupplier(@Nonnull final Supplier<HttpServletRequest> supplier) {
+        requestSupplier = Constraint.isNotNull(supplier, "HttpServletRequest supplier cannot be null");
+    }
+    
+    /** {@inheritDoc} */
+    public boolean test(@Nullable final ProfileRequestContext input) {
+        
+        final HttpServletRequest request = requestSupplier != null ? requestSupplier.get() : null;
+        if (request != null) {
+            final RelyingPartyContext rpCtx = getRelyingPartyContext(input);
+            if (rpCtx != null && rpCtx.getProfileConfig() instanceof AdminFlowDescriptor duo) {
+                final String policy = duo.getAdminPolicyName(input);
+                if (policy != null) {
+                    return accessControlService.getInstance(policy).checkAccess(request, "write", "cookie");
+                }
+            }
+        }
+        
+        return false;
+    }
+
+}
\ No newline at end of file
diff --git a/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/admin/impl/AdminFlowDescriptor.java b/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/admin/impl/AdminFlowDescriptor.java
new file mode 100644
index 00000000..02272b70
--- /dev/null
+++ b/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/admin/impl/AdminFlowDescriptor.java
@@ -0,0 +1,81 @@
+/*
+ * 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.admin.impl;
+
+import java.util.function.Function;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.opensaml.profile.context.ProfileRequestContext;
+
+import net.shibboleth.idp.admin.BasicAdministrativeFlowDescriptor;
+import net.shibboleth.shared.annotation.constraint.NotEmpty;
+import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.logic.FunctionSupport;
+import net.shibboleth.shared.primitive.StringSupport;
+
+/**
+ * Flow descriptor for Duo admin flow.
+ * 
+ * <p>This is primarily for managing passwordless opt-in state, but may be extended for
+ * other purposes in the future.</p>
+ */
+public class AdminFlowDescriptor extends BasicAdministrativeFlowDescriptor {
+
+    /** Lookup strategy for access control policy to apply for determining admin vs. user-level access. */
+    @Nonnull private Function<ProfileRequestContext,String> adminPolicyNameLookupStrategy;
+    
+    /**
+     * Constructor.
+     *
+     * @param id
+     */
+    public AdminFlowDescriptor(@Nonnull @NotEmpty final String id) {
+        super(id);
+        
+        adminPolicyNameLookupStrategy = FunctionSupport.constant(null);
+    }
+
+    /**
+     * Get the access control policy name to apply for admin access.
+     * 
+     * @param profileRequestContext profile request context
+     * 
+     * @return admin policy name
+     */
+    @Nullable public String getAdminPolicyName(@Nullable final ProfileRequestContext profileRequestContext) {
+        return adminPolicyNameLookupStrategy.apply(profileRequestContext);
+    }  
+    
+    /**
+     * Set the explicit access control policy name to apply for admin access.
+     * 
+     * @param name  admin policy name
+     */
+    public void setAdminPolicyName(@Nullable final String name) {
+        adminPolicyNameLookupStrategy = FunctionSupport.constant(StringSupport.trimOrNull(name));
+    }
+
+    /**
+     * Set a lookup strategy to use to obtain the admin-level access control policy for this flow.
+     * 
+     * @param strategy  lookup strategy
+     */
+    public void setAdminPolicyNameLookupStrategy(@Nonnull final Function<ProfileRequestContext,String> strategy) {
+        adminPolicyNameLookupStrategy = Constraint.isNotNull(strategy, "Admin policy lookup strategy cannot be null");
+    }
+
+}
\ No newline at end of file
diff --git a/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/admin/impl/ManagePasswordlessCookie.java b/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/admin/impl/ManagePasswordlessCookie.java
new file mode 100644
index 00000000..60caa511
--- /dev/null
+++ b/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/admin/impl/ManagePasswordlessCookie.java
@@ -0,0 +1,197 @@
+/*
+ * 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.admin.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 jakarta.servlet.http.HttpServletRequest;
+import net.shibboleth.idp.authn.context.SubjectContext;
+import net.shibboleth.idp.plugin.authn.duo.PasswordlessCookieManager;
+import net.shibboleth.idp.profile.AbstractProfileAction;
+import net.shibboleth.profile.context.RelyingPartyContext;
+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.primitive.LoggerFactory;
+import net.shibboleth.shared.security.AccessControlService;
+
+/**
+ * Administrative management of passwordless cookie by end-user or administrator.
+ * 
+ * <p>Incoming form fields are used to determine what to do and for whom, with admin
+ * manipulation of a cookie for a different identity based on a separate access policy.
+ * 
+ * @event {@link EventIds#PROCEED_EVENT_ID}
+ * @event {@link EventIds#INVALID_PROFILE_CTX}
+ * @event {@link EventIds#INVALID_MESSAGE}
+ * @event {@link EventIds#ACCESS_DENIED}
+ * @event {@link EventIds#IO_ERROR}
+ *      
+ * @since 2.1.0
+ */
+public class ManagePasswordlessCookie extends AbstractProfileAction {
+
+    /** Name of form field signalling username to operate with. */
+    @Nonnull @NotEmpty public static final String USERNAME_FIELD_NAME = "j_username";
+
+    /** Name of form field signalling the operation to perform. */
+    @Nonnull @NotEmpty public static final String OPERATION_FIELD_NAME = "operation";
+
+    /** Class logger.*/
+    @Nonnull private final Logger log = LoggerFactory.getLogger(ManagePasswordlessCookie.class);
+    
+    /** Access control service. */
+    @NonnullAfterInit private AccessControlService accessControlService;
+    
+    /** Cookie manager to use. */
+    @NonnullAfterInit private PasswordlessCookieManager cookieManager;
+    
+    /** Currently authenticated user. */
+    @NonnullBeforeExec private String currentUsername;
+    
+    /**
+     * Sets {@link AccessControlService} to use for guarding admin access to cookie.
+     * 
+     * @param service access control service
+     */
+    public void setAccessControlService(@Nonnull final AccessControlService service) {
+        checkSetterPreconditions();
+        
+        accessControlService = Constraint.isNotNull(service, "AccessControlService cannot be null");
+    }
+    
+    /**
+     * Sets {@link PasswordlessCookieManager} to use.
+     * 
+     * @param manager cookie manager
+     */
+    public void setPasswordlessCookieManager(@Nullable final PasswordlessCookieManager manager) {
+        checkSetterPreconditions();
+        
+        cookieManager = Constraint.isNotNull(manager, "PasswordlessCookieManager cannot be null");
+    }
+    
+    /** {@inheritDoc} */
+    @Override
+    protected void doInitialize() throws ComponentInitializationException {
+        super.doInitialize();
+        
+        if (accessControlService == null) {
+            throw new ComponentInitializationException("AccessControlService cannot be null");
+        } else if (cookieManager == null) {
+            throw new ComponentInitializationException("PasswordlessCookieManager cannot be null");
+        }
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected boolean doPreExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+        
+        if (!super.doPreExecute(profileRequestContext)) {
+            return false;
+        } else if (getHttpServletRequest() == null) {
+            log.warn("{} No HttpServletRequest available", getLogPrefix());
+            ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_PROFILE_CTX);
+            return false;
+        }
+        
+        final SubjectContext subjectCtx = profileRequestContext.getSubcontext(SubjectContext.class);
+        currentUsername = subjectCtx != null ? subjectCtx.getPrincipalName() : null;
+        
+        if (currentUsername == null) {
+            log.warn("{} No current username available", getLogPrefix());
+            ActionSupport.buildEvent(profileRequestContext, EventIds.ACCESS_DENIED);
+            return false;
+        }
+        
+        return true;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext) {    
+        
+        final HttpServletRequest request = ensureHttpServletRequest();
+        
+        final String operation = request.getParameter(OPERATION_FIELD_NAME);
+        if ("clear".equals(operation)) {
+            cookieManager.clearCookie();
+        } else if ("optout".equals(operation)) {
+            if (cookieManager.writeCookie(null)) {
+                log.debug("{} Created passwordless cookie for opt-out", getLogPrefix());
+            } else {
+                log.warn("{} Failed to create passwordless cookie for opt-out", getLogPrefix());
+                ActionSupport.buildEvent(profileRequestContext, EventIds.IO_ERROR);
+            }
+        } else if ("optin".equals(operation)) {
+            String targetName = request.getParameter(USERNAME_FIELD_NAME);
+            if (targetName == null) {
+                targetName = currentUsername;
+            } else if (!targetName.equals(currentUsername)) {
+                if (!isAdminAllowed(profileRequestContext, targetName)) {
+                    ActionSupport.buildEvent(profileRequestContext, EventIds.ACCESS_DENIED);
+                    return;
+                }
+            }
+            
+            log.debug("{} Creating passwordless cookie for username '{}'", getLogPrefix(), targetName);
+
+            if (!cookieManager.writeCookie(targetName)) {
+                log.warn("{} Failed to create passwordless cookie for username '{}'", getLogPrefix(), targetName);
+                ActionSupport.buildEvent(profileRequestContext, EventIds.IO_ERROR);
+            }
+        }
+    }
+    
+    /**
+     * Tests whether an admin operation (on a different user than is accessing the flow) should be permitted.
+     * 
+     * @param profileRequestContext profile request context
+     * @param username username to operate on
+     * 
+     * @return true iff the operation should be permitted
+     */
+    private boolean isAdminAllowed(@Nonnull final ProfileRequestContext profileRequestContext,
+            @Nonnull @NotEmpty final String username) {
+        
+        final RelyingPartyContext rpCtx = profileRequestContext.getSubcontext(RelyingPartyContext.class);
+        if (rpCtx != null && rpCtx.getProfileConfig() instanceof AdminFlowDescriptor duo) {
+            final String policy = duo.getAdminPolicyName(profileRequestContext);
+            if (policy != null) {
+                log.debug("{} Checking for admin access to Duo passwordless cookie with policy {}", getLogPrefix(),
+                        policy);
+                
+                final boolean result = accessControlService.getInstance(policy).checkAccess(ensureHttpServletRequest(),
+                        "write", username);
+                if (!result) {
+                    log.warn("{} Access to passwordless cookie for username '{}' denied", getLogPrefix(), username);
+                }
+                return result;
+            }
+        }
+        
+        log.warn("{} Unable to obtain admin access policy to use", getLogPrefix());
+        return false;
+    }
+    
+}
\ No newline at end of file
diff --git a/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/admin/impl/package-info.java b/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/admin/impl/package-info.java
new file mode 100644
index 00000000..d90882c7
--- /dev/null
+++ b/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/admin/impl/package-info.java
@@ -0,0 +1,16 @@
+/*
+ * 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.
+ */
+
+/** Duo OIDC 2FA login flow admin implementation classes. */
+package net.shibboleth.idp.plugin.authn.duo.admin.impl;
diff --git a/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/audit/impl/package-info.java b/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/audit/impl/package-info.java
new file mode 100644
index 00000000..509d4394
--- /dev/null
+++ b/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/audit/impl/package-info.java
@@ -0,0 +1,16 @@
+/*
+ * 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.
+ */
+
+/** Duo OIDC 2FA login flow audit implementation classes. */
+package net.shibboleth.idp.plugin.authn.duo.audit.impl;
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 d022a6b2..1d97fec7 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
@@ -41,7 +41,7 @@ public class ClearPasswordlessCookie extends AbstractProfileAction {
     /** Class logger. */
     @Nonnull private final Logger log = LoggerFactory.getLogger(ClearPasswordlessCookie.class);
 
-    /** Optional cookie manager to use. */
+    /** Cookie manager to use. */
     @NonnullAfterInit private PasswordlessCookieManager cookieManager;
     
     /**
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 a6bdbb9a..679a3672 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
@@ -56,7 +56,7 @@ public class CreatePasswordlessCookie extends AbstractProfileAction {
     /** Class logger.*/
     @Nonnull private final Logger log = LoggerFactory.getLogger(CreatePasswordlessCookie.class);
     
-    /** Optional cookie manager to use. */
+    /** Cookie manager to use. */
     @NonnullAfterInit private PasswordlessCookieManager cookieManager;
     
     /**
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 61e88e1a..700042be 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
@@ -30,7 +30,26 @@
                 c:_0="#{'%{idp.authn.DuoOIDC.supportedPrincipals:%{idp.authn.Duo.supportedPrincipals:}}'.trim()}" />
         </property>
     </bean>
-    
+
+    <!-- Admin flow descriptor -->    
+    <bean class="net.shibboleth.idp.plugin.authn.duo.admin.impl.AdminFlowDescriptor"
+            c:id="http://shibboleth.net/ns/profiles/admin/duo"
+            p:loggingId="%{idp.duo.oidc.admin.logging:DuoOIDCAdmin}"
+            p:policyName="%{idp.duo.oidc.admin.accessPolicy:AccessByAnyone}"
+            p:adminPolicyName="%{idp.duo.oidc.admin.adminAccessPolicy:AccessByAdminUser}"
+            p:nonBrowserSupported="%{idp.duo.oidc.admin.nonBrowserSupported:false}"
+            p:authenticated="%{idp.duo.oidc.admin.authenticated:true}"
+            p:resolveAttributes="%{idp.duo.oidc.admin.resolveAttributes:false}">
+        <property name="postAuthenticationFlows">
+            <bean parent="shibboleth.CommaDelimStringArray"
+                c:_0="#{'%{idp.duo.oidc.admin.postAuthenticationFlows:}'.trim()}" />
+        </property>
+        <property name="defaultAuthenticationMethodsByString">
+            <bean parent="shibboleth.CommaDelimStringArray"
+                c:_0="#{'%{idp.duo.oidc.admin.defaultAuthenticationMethods:}'.trim()}" />
+        </property>
+    </bean>
+        
     <!-- Principal serialization support. -->
     <bean p:id="duo" class="net.shibboleth.idp.authn.principal.GenericPrincipalService"
             c:claz="net.shibboleth.idp.authn.duo.DuoPrincipal">
diff --git a/idp-duo-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/admin/DuoOIDC/duo-oidc-admin-beans.xml b/idp-duo-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/admin/DuoOIDC/duo-oidc-admin-beans.xml
new file mode 100644
index 00000000..6c11302d
--- /dev/null
+++ b/idp-duo-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/admin/DuoOIDC/duo-oidc-admin-beans.xml
@@ -0,0 +1,40 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<beans
+    xmlns="http://www.springframework.org/schema/beans"
+    xmlns:c="http://www.springframework.org/schema/c"
+    xmlns:context="http://www.springframework.org/schema/context"
+    xmlns:p="http://www.springframework.org/schema/p"
+    xmlns:util="http://www.springframework.org/schema/util"
+    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+    xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd
+                           http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd
+                           http://www.springframework.org/schema/util http://www.springframework.org/schema/util/spring-util.xsd"
+    default-init-method="initialize"
+    default-destroy-method="destroy">
+
+    <!-- Profile ID for flow. -->
+    <bean id="shibboleth.AdminProfileId" class="java.lang.String" c:_0="http://shibboleth.net/ns/profiles/admin/duo" />
+
+    <!-- Profile counter name. -->
+    <bean id="shibboleth.metrics.ProfileCounter" class="java.lang.String" c:_0="net.shibboleth.idp.profiles.duo.admin" />
+    
+    <!-- Default operation/resource suppliers for access checks. -->
+
+    <bean id="shibboleth.AdminOperationLookupStrategy" parent="shibboleth.Functions.Constant" c:target="write" />
+        
+    <bean id="shibboleth.AdminResourceLookupStrategy" parent="shibboleth.Functions.Constant" c:target="cookie" />
+    
+    <!--  Work beans. -->
+
+    <bean id="AdminLandingRedirect" class="java.lang.String" c:_0="%{idp.duo.oidc.admin.landingPage:https://example.org}" />
+
+    <bean id="AdminAccessPredicate" class="net.shibboleth.idp.plugin.authn.duo.admin.impl.AdminAccessPredicate"
+        p:httpServletRequestSupplier-ref="shibboleth.HttpServletRequestSupplier"
+        p:accessControlService-ref="shibboleth.AccessControlService" />
+        
+    <bean id="ManagePasswordlessCookie" class="net.shibboleth.idp.plugin.authn.duo.admin.impl.ManagePasswordlessCookie" scope="prototype"
+        p:httpServletRequestSupplier-ref="shibboleth.HttpServletRequestSupplier"
+        p:accessControlService-ref="shibboleth.AccessControlService"
+        p:passwordlessCookieManager-ref="shibboleth.authn.DuoOIDC.Passwordless.CookieManager" />
+        
+</beans>
diff --git a/idp-duo-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/admin/DuoOIDC/duo-oidc-admin-flow.xml b/idp-duo-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/admin/DuoOIDC/duo-oidc-admin-flow.xml
new file mode 100644
index 00000000..af88154d
--- /dev/null
+++ b/idp-duo-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/admin/DuoOIDC/duo-oidc-admin-flow.xml
@@ -0,0 +1,57 @@
+<flow xmlns="http://www.springframework.org/schema/webflow"
+    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+    xsi:schemaLocation="http://www.springframework.org/schema/webflow http://www.springframework.org/schema/webflow/spring-webflow.xsd"
+    parent="admin.abstract">
+
+    <!-- Start action. -->
+        
+    <action-state id="InitializeProfileRequestContext">
+        <evaluate expression="InitializeProfileRequestContext" />
+<!--        <evaluate expression="FlowStartPopulateAuditContext" />-->
+        <evaluate expression="'proceed'" />
+        
+        <!-- Branch to determine if authentication is required. -->
+        <transition on="proceed" to="DoAdminPreamble" />
+    </action-state>
+
+    <!-- Resume actual flow processing. -->
+
+    <action-state id="DoProfileWork">
+        <evaluate expression="CheckAccess" />
+        <evaluate expression="'proceed'" />
+        
+        <transition on="proceed" to="AdminUI" />
+    </action-state>
+
+    <view-state id="AdminUI" view="admin/passwordless-admin">
+        <on-entry>
+            <evaluate expression="environment" result="viewScope.environment" />
+            <evaluate expression="opensamlProfileRequestContext" result="viewScope.profileRequestContext" />
+            <evaluate expression="opensamlProfileRequestContext.ensureSubcontext(T(net.shibboleth.idp.authn.context.SubjectContext)).getPrincipalName()" result="viewScope.username" />
+            <evaluate expression="AdminAccessPredicate.test(opensamlProfileRequestContext)" result="viewScope.admin" />
+            <evaluate expression="T(net.shibboleth.shared.codec.HTMLEncoder)" result="viewScope.encoder" />
+            <evaluate expression="flowRequestContext.getActiveFlow().getApplicationContext().getBean('shibboleth.authn.DuoOIDC.Passwordless.CookieManager')" result="viewScope.cookieManager" />
+            <evaluate expression="flowRequestContext.getActiveFlow().getApplicationContext().getBean('shibboleth.CSPDigester')" result="viewScope.cspDigester" />
+            <evaluate expression="flowRequestContext.getActiveFlow().getApplicationContext().getBean('shibboleth.CSPNonce')" 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-entry>
+        
+        <transition on="proceed" to="ManagePasswordlessCookie" />
+        <transition on="end" to="end" />
+    </view-state>
+    
+    <action-state id="ManagePasswordlessCookie">
+        <evaluate expression="ManagePasswordlessCookie" />
+        <evaluate expression="'proceed'" />
+        
+        <transition on="proceed" to="AdminUI" />
+    </action-state>
+    
+    <!-- Terminus -->
+    <end-state id="end" view="externalRedirect:#{flowRequestContext.getActiveFlow().getApplicationContext().getBean('AdminLandingRedirect')}" />
+    
+    <bean-import resource="duo-oidc-admin-beans.xml" />
+
+</flow>
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 b97030f7..8390a9fc 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
@@ -17,3 +17,13 @@ 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
+
+idp.duo.passwordless.admin.title = Manage Duo Passwordless Opt-In Cookie
+idp.duo.passwordless.admin.current = Current State:
+idp.duo.passwordless.admin.optedout = Opted Out
+idp.duo.passwordless.admin.optedin = Opted In As
+idp.duo.passwordless.admin.unset = Not Set
+idp.duo.passwordless.admin.optin = Opt-In
+idp.duo.passwordless.admin.optout = Opt-Out
+idp.duo.passwordless.admin.clear = Clear
+idp.duo.passwordless.admin.finish = Finish
diff --git a/idp-duo-impl/src/main/resources/net/shibboleth/idp/plugin/authn/duo/views/admin/passwordless-admin.vm b/idp-duo-impl/src/main/resources/net/shibboleth/idp/plugin/authn/duo/views/admin/passwordless-admin.vm
new file mode 100644
index 00000000..d5d76688
--- /dev/null
+++ b/idp-duo-impl/src/main/resources/net/shibboleth/idp/plugin/authn/duo/views/admin/passwordless-admin.vm
@@ -0,0 +1,98 @@
+##
+## 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
+## admin - boolean flag indicating admin access to override username
+## username - authenticated username
+## cookieManager - PasswordlessCookieManager object
+## encoder - HTMLEncoder class
+## cspDigester - Calculates base64-encoded SHA-2 hashes (call apply)
+## cspNonce - Calculates secure nonces (call generateIdentifier)
+## request - HttpServletRequest
+## response - HttpServletResponse
+## environment - Spring Environment object for property resolution
+## custom - arbitrary object injected by deployer
+##
+#set ($optout = $cookieManager.isOptOut())
+#set ($currentUsername = $cookieManager.readCookie())
+#if (!$optout && $currentUsername)
+    #set ($username = $currentUsername)
+#end
+<!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>
+                <h1>#springMessageText("idp.duo.passwordless.admin.title", "Manage Duo Passwordless Opt-In Cookie")</h1>
+            
+                <form action="$flowExecutionUrl" method="post">
+                    #parse("csrf/csrf.vm")
+
+                    <input type="hidden" name="_eventId_proceed" value="1" />
+
+                    <p>
+                    <strong>#springMessageText("idp.duo.passwordless.admin.current", "Current State:")</strong>
+                    #if ($optout)
+                        #springMessageText("idp.duo.passwordless.admin.optedout", "Opted Out")
+                    #elseif ($currentUsername)
+                        #springMessageText("idp.duo.passwordless.admin.optedin", "Opted In As")
+                        $encoder.encodeForHTML($currentUsername)
+                    #else
+                        #springMessageText("idp.duo.passwordless.admin.unset", "Not Set")
+                    #end
+                    </p>
+
+                    #if ($admin)
+                        <label for="j_username">#springMessageText("idp.login.username", "Username")</label>
+                        <input id="j_username" name="j_username" type="text" value="$encoder.encodeForHTML($username)" />
+                    #end
+
+                    <div class="grid">
+                        <div class="grid-item">
+                            <button type="submit" name="operation" value="optin"
+                                >#springMessageText("idp.duo.passwordless.admin.optin", "Opt-In")</button>
+                        </div>
+                        <div class="grid-item">
+                            <button type="submit" name="operation" value="optout"
+                                >#springMessageText("idp.duo.passwordless.admin.optout", "Opt-Out")</button>
+                        </div>
+                        <div class="grid-item">
+                            <button type="submit" name="operation" value="clear"
+                                >#springMessageText("idp.duo.passwordless.admin.clear", "Clear")</button>
+                        </div>
+                        <div class="grid-item">
+                            <button type="submit" formaction="$flowExecutionUrl&_eventId_end=1#parse("csrf/csrf-qparam.vm")"
+                                >#springMessageText("idp.duo.passwordless.admin.finish", "Finish")</button>
+                        </div>
+                    </div>
+                </form>
+    
+                <ul>
+                    <li><a href="#springMessageText('idp.duo.enrollment.url', '#')">#springMessageText("idp.duo.enrollment", "Enroll New Devices")</a></li>
+                    <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-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 bbff2d11..43a4f082 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
@@ -49,6 +49,15 @@ idp.duo.oidc.redirectURL = https://<hostname>:<port>/idp/profile/Authn/Duo/2FA/d
 # Override to plug in your own condition bean governing eligibility.
 #idp.duo.oidc.passwordless.guardCondition = (internal default)
 
+# Settings for Passwordless cookie management flow
+#idp.duo.oidc.admin.resolveAttributes = false
+# Policy for access by people in general
+#idp.duo.oidc.admin.accessPolicy = AccessByAnyone
+# Policy for access by administrators
+#idp.duo.oidc.admin.adminAccessPolicy = AccessByAdminUser
+# Destination after flow completion.
+idp.duo.oidc.admin.landingPage = https://example.org
+
 # Non-Browser AuthAPI integration if desired
 #idp.duo.oidc.nonbrowser.apiHost = %{idp.duo.oidc.apiHost}
 #idp.duo.oidc.nonbrowser.integrationKey = ikey
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 0a66858c..1a9351a5 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
@@ -17,3 +17,5 @@ idp.authn.DuoOIDC.3.src = /net/shibboleth/idp/plugin/authn/duo/views/passwordles
 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
+idp.authn.DuoOIDC.5.src = /net/shibboleth/idp/plugin/authn/duo/views/admin/passwordless-admin.vm
+idp.authn.DuoOIDC.5.dest = views/admin/passwordless-admin.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 dea141c9..0de63343 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
@@ -49,6 +49,15 @@ idp.duo.oidc.redirectURL = https://<hostname>:<port>/idp/profile/Authn/Duo/2FA/d
 # Override to plug in your own condition bean governing eligibility.
 #idp.duo.oidc.passwordless.guardCondition = (internal default)
 
+# Settings for Passwordless cookie management flow
+#idp.duo.oidc.admin.resolveAttributes = false
+# Policy for access by people in general
+#idp.duo.oidc.admin.accessPolicy = AccessByAnyone
+# Policy for access by administrators
+#idp.duo.oidc.admin.adminAccessPolicy = AccessByAdminUser
+# Destination after flow completion.
+idp.duo.oidc.admin.landingPage = https://example.org
+
 # Non-Browser AuthAPI integration if desired
 #idp.duo.oidc.nonbrowser.apiHost = %{idp.duo.oidc.apiHost}
 #idp.duo.oidc.nonbrowser.integrationKey = ikey
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 36a21ef7..2b1b4a6b 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
@@ -17,3 +17,5 @@ idp.authn.DuoOIDC.3.src = /net/shibboleth/idp/plugin/authn/duo/views/passwordles
 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
+idp.authn.DuoOIDC.5.src = /net/shibboleth/idp/plugin/authn/duo/views/admin/passwordless-admin.vm
+idp.authn.DuoOIDC.5.dest = views/admin/passwordless-admin.vm

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


More information about the commits mailing list