[java-idp-plugin-duo] branch main updated: JDUO-103 - Forward max_age requirement for forced authentication

Codeberg noreply at shibboleth.net
Fri Sep 11 16:46:54 UTC 2026


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

codeberg pushed a commit to branch main
in repository java-idp-plugin-duo.

View the commit online:
https://codeberg.org/Shibboleth/java-idp-plugin-duo/commit/8f0a458477bffa81489ec11c277c16c784c002aa

The following commit(s) were added to refs/heads/main by this push:
     new 8f0a4584 JDUO-103 - Forward max_age requirement for forced authentication
8f0a4584 is described below

commit 8f0a458477bffa81489ec11c277c16c784c002aa
Author: Phil Smart <philip.smart at jisc.ac.uk>
AuthorDate: Fri Sep 11 17:46:43 2026 +0100

    JDUO-103 - Forward max_age requirement for forced authentication
    
     - Add support for OIDC max_age and prompt parameters in the interface
     - Default the API, so existing implementations are compatible
     - Include support for the additional parameters in the Nimbus plugin
     - Add new authentication lifetime logic based on what was request, see
    detailed description in the Jira issue
     - Update tests
    
    https://shibboleth.atlassian.net/browse/JDUO-103
---
 .../authn/duo/AuthenticationRequestOptions.java    | 183 ++++++++++++++++
 .../authn/duo/DefaultDuoOIDCIntegration.java       |  79 +++++--
 .../DuoAuthenticationLifetimeLookupStrategy.java   | 111 ++++++----
 .../idp/plugin/authn/duo/DuoOIDCClient.java        |  24 ++
 .../idp/plugin/authn/duo/DuoOIDCIntegration.java   |  12 +
 .../duo/context/DuoOIDCAuthenticationContext.java  |  92 +++++++-
 ...uoAuthenticationLifetimeLookupStrategyTest.java | 115 ++++++----
 .../authn/duo/impl/DuoOIDCAuthnController.java     |  16 +-
 .../duo/impl/PopulateDuoAuthenticationContext.java |  61 ++++++
 .../flows/authn/DuoOIDC/duo-oidc-authn-beans.xml   |   2 +-
 .../impl/AbstractAuthnXmlFlowExecutionTests.java   |   5 +
 .../plugin/authn/duo/impl/DuoAuthnFlowTest.java    | 244 ++++++---------------
 .../impl/PopulateDuoAuthenticationContextTest.java |   2 +
 .../plugin/authn/duo/nimbus/impl/NimbusClient.java |  33 ++-
 .../authn/duo/nimbus/impl/NimbusClientSupport.java |  55 ++++-
 .../authn/duo/sdk/impl/DuoSDKClientAdaptor.java    |   7 +-
 .../authn/duo/sdk/impl/DuoSDKClientFactory.java    |   3 +-
 17 files changed, 733 insertions(+), 311 deletions(-)

diff --git a/idp-duo-api/src/main/java/net/shibboleth/idp/authn/duo/AuthenticationRequestOptions.java b/idp-duo-api/src/main/java/net/shibboleth/idp/authn/duo/AuthenticationRequestOptions.java
new file mode 100644
index 00000000..532e6dd3
--- /dev/null
+++ b/idp-duo-api/src/main/java/net/shibboleth/idp/authn/duo/AuthenticationRequestOptions.java
@@ -0,0 +1,183 @@
+
+package net.shibboleth.idp.authn.duo;
+
+import java.time.Duration;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import javax.annotation.concurrent.NotThreadSafe;
+
+/**
+ * The set of authentication request options to supply to the Duo OpenID Provider.
+ * 
+ * @since 2.4.0
+ */
+ at NotThreadSafe
+public class AuthenticationRequestOptions {
+
+	/** Username being authenticated. */
+	@Nullable private String username;
+
+	/** OIDC state parameter. */
+	@Nullable private String state;
+
+	/** OIDC nonce parameter. */
+	@Nullable private String nonce;
+
+	/** OIDC prompt parameter. */
+	@Nullable private String prompt;
+
+	/** OIDC max_age parameter in seconds. Is an Integer value in Duo.*/
+	@Nullable private Duration maxAge;
+	
+	/** Optional redirect URI override to use. */
+	@Nullable private String redirectURIOverride;
+
+	/**
+	 * Get the username.
+	 *
+	 * @return username
+	 */
+	@Nullable public String getUsername() {
+		return username;
+	}
+
+	/**
+	 * Set the username.
+	 *
+	 * @param value username
+	 *
+	 * @return this options
+	 */
+	public AuthenticationRequestOptions setUsername(@Nullable final String value) {
+		username = value;
+		return this;
+	}
+
+	/**
+	 * Get the state.
+	 *
+	 * @return state
+	 */
+	@Nullable public String getState() {
+		return state;
+	}
+
+	/**
+	 * Set the state.
+	 *
+	 * @param value state
+	 *
+	 * @return this options
+	 */
+	@Nonnull public AuthenticationRequestOptions setState(@Nullable final String value) {
+		state = value;
+		return this;
+	}
+
+	/**
+	 * Get the nonce.
+	 *
+	 * @return nonce
+	 */
+	@Nullable public String getNonce() {
+		return nonce;
+	}
+
+	/**
+	 * Set the nonce.
+	 *
+	 * @param value nonce
+	 *
+	 * @return this options
+	 */
+	@Nonnull public AuthenticationRequestOptions setNonce(@Nullable final String value) {
+		nonce = value;
+		return this;
+	}
+
+	/**
+	 * Get the prompt parameter.
+	 *
+	 * @return prompt
+	 */
+	@Nullable public String getPrompt() {
+		return prompt;
+	}
+
+	/**
+	 * Set the prompt parameter.
+	 *
+	 * @param value prompt
+	 *
+	 * @return this options
+	 */
+	@Nonnull public AuthenticationRequestOptions setPrompt(@Nullable final String value) {
+		prompt = value;
+		return this;
+	}
+
+	/**
+	 * Get the maximum authentication age in seconds.
+	 *
+	 * @return max_age
+	 */
+	@Nullable public Duration getMaxAge() {
+		return maxAge;
+	}
+
+	/**
+	 * Set the maximum authentication age in seconds.
+	 *
+	 * @param value max_age
+	 *
+	 * @return this options
+	 */
+	@Nonnull public AuthenticationRequestOptions setMaxAge(@Nullable final Duration value) {
+		maxAge = value;
+		return this;
+	}
+	
+    
+    /**
+     * Get the override redirect URI.
+     * 
+     * @return the override redirect URI.
+     */
+    @Nullable public String getRedirectURIOverride() {
+        return redirectURIOverride;
+    }
+    
+    /**
+     * Set the override redirect URI.
+     * 
+     * @param override the override redirect URI.
+     * 
+     * @return this options.
+     */
+    @Nonnull public AuthenticationRequestOptions setRedirectURIOverride(
+            @Nullable final String override) {
+        redirectURIOverride = override;
+        return this;
+    }
+
+	@Override
+	public String toString() {
+		final StringBuilder builder = new StringBuilder();
+		builder.append("AuthenticationRequestOptions [username=");
+		builder.append(username);
+		builder.append(", state=");
+		builder.append(state);
+		builder.append(", nonce=");
+		builder.append(nonce);
+		builder.append(", prompt=");
+		builder.append(prompt);
+		builder.append(", maxAge=");
+		builder.append(maxAge);
+		builder.append(", redirectURIOverride=");
+		builder.append(redirectURIOverride);
+		builder.append("]");
+		return builder.toString();
+	}
+
+}
diff --git a/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/DefaultDuoOIDCIntegration.java b/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/DefaultDuoOIDCIntegration.java
index 6175eeb4..e2b3049f 100644
--- a/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/DefaultDuoOIDCIntegration.java
+++ b/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/DefaultDuoOIDCIntegration.java
@@ -15,6 +15,7 @@
 package net.shibboleth.idp.plugin.authn.duo;
 
 import java.security.Principal;
+import java.time.Duration;
 import java.util.Collection;
 import java.util.Objects;
 import java.util.Set;
@@ -96,6 +97,9 @@ public final class DefaultDuoOIDCIntegration
     /** Hook to map context information to principal collections.*/
     @GuardedBy("this") @Nullable private Function<ProfileRequestContext,Collection<Principal>>
     contextToPrincipalMappingStrategy;
+    
+    /** The maximum authentication age.*/
+    @GuardedBy("this") @Nullable private Duration maxAge;
 
     /** Constructor. */
     public DefaultDuoOIDCIntegration() {
@@ -118,7 +122,8 @@ public final class DefaultDuoOIDCIntegration
     }
     
     /** {@inheritDoc} */
-    public synchronized boolean isPasswordless() {
+    @Override
+	public synchronized boolean isPasswordless() {
         checkComponentActive();
         return passwordless;
     }
@@ -134,7 +139,8 @@ public final class DefaultDuoOIDCIntegration
     }
     
     /** {@inheritDoc} */
-    @Nonnull @NotLive @Unmodifiable public synchronized Set<String> getAllowedOrigins() {
+    @Override
+	@Nonnull @NotLive @Unmodifiable public synchronized Set<String> getAllowedOrigins() {
         return allowedOrigins;
     }
     
@@ -152,12 +158,14 @@ public final class DefaultDuoOIDCIntegration
     }
     
     /** {@inheritDoc} */
-    @Nullable @NotLive @Unmodifiable public synchronized Set<String> getAllowedFactors() {
+    @Override
+	@Nullable @NotLive @Unmodifiable public synchronized Set<String> getAllowedFactors() {
         return allowedFactors;
     }
 
     /** {@inheritDoc} */
-    @Nonnull @NotEmpty public synchronized String getAPIHost() {
+    @Override
+	@Nonnull @NotEmpty public synchronized String getAPIHost() {
         checkComponentActive();
         assert apiHost != null;
         return apiHost;
@@ -174,7 +182,8 @@ public final class DefaultDuoOIDCIntegration
     }
     
     /** {@inheritDoc} */
-    @Nonnull @NotEmpty public synchronized String getHealthCheckEndpoint() {
+    @Override
+	@Nonnull @NotEmpty public synchronized String getHealthCheckEndpoint() {
         checkComponentActive();
         assert healthEndpoint != null;
         return healthEndpoint;
@@ -192,7 +201,8 @@ public final class DefaultDuoOIDCIntegration
     }
 
     /** {@inheritDoc} */
-    @Nonnull @NotEmpty public synchronized String getAuthorizeEndpoint() {
+    @Override
+	@Nonnull @NotEmpty public synchronized String getAuthorizeEndpoint() {
         checkComponentActive();
         assert authorizeEndpoint != null;
         return authorizeEndpoint;
@@ -210,7 +220,8 @@ public final class DefaultDuoOIDCIntegration
     }
 
     /** {@inheritDoc} */
-    @Nonnull @NotEmpty public synchronized String getTokenEndpoint() {
+    @Override
+	@Nonnull @NotEmpty public synchronized String getTokenEndpoint() {
         checkComponentActive();
         assert tokenEndpoint != null;
         return tokenEndpoint;
@@ -228,7 +239,8 @@ public final class DefaultDuoOIDCIntegration
     }
     
     /** {@inheritDoc} */
-    @Nullable public synchronized String getRedirectURI() {
+    @Override
+	@Nullable public synchronized String getRedirectURI() {
         return redirectURI;
     }
     
@@ -243,12 +255,14 @@ public final class DefaultDuoOIDCIntegration
     }
     
     /** {@inheritDoc} */
-    @Nullable public synchronized String getRegisteredRedirectURI() {
+    @Override
+	@Nullable public synchronized String getRegisteredRedirectURI() {
         return registeredRedirectURI;
     }
     
     /** {@inheritDoc} */
-    public synchronized boolean isRedirectURIPreregistered() {
+    @Override
+	public synchronized boolean isRedirectURIPreregistered() {
         if (getRegisteredRedirectURI() == null) {
             return false;
         }
@@ -256,7 +270,8 @@ public final class DefaultDuoOIDCIntegration
     }
     
     /** {@inheritDoc} */
-    public synchronized void setRedirectURIIfAbsent(
+    @Override
+	public synchronized void setRedirectURIIfAbsent(
             @Nonnull @NotEmpty final String computedRedirectURI){   
         // Specifically do not check if component has been initialized. This can change during use.
         Constraint.isNotEmpty(computedRedirectURI, "Computed redirect URI can not be null or empty");
@@ -278,7 +293,8 @@ public final class DefaultDuoOIDCIntegration
     }
     
     /** {@inheritDoc} */
-    @Nonnull @NotEmpty public synchronized String getClientId() {
+    @Override
+	@Nonnull @NotEmpty public synchronized String getClientId() {
         checkComponentActive();
         assert clientId != null;
         return clientId;
@@ -295,7 +311,8 @@ public final class DefaultDuoOIDCIntegration
     }
     
     /** {@inheritDoc} */
-    @Nonnull @NotEmpty public synchronized String getSecretKey() {
+    @Override
+	@Nonnull @NotEmpty public synchronized String getSecretKey() {
         checkComponentActive();
         assert secretKey != null;
         return secretKey;
@@ -303,7 +320,8 @@ public final class DefaultDuoOIDCIntegration
     
 
     /** {@inheritDoc} */
-    @Nonnull @NonnullElements @Unmodifiable
+    @Override
+	@Nonnull @NonnullElements @Unmodifiable
     public synchronized <T extends Principal> Set<T> getSupportedPrincipals(@Nonnull final Class<T> c) {
         final Set<T> result = supportedPrincipals.getPrincipals(c);
         assert result != null;
@@ -335,7 +353,7 @@ public final class DefaultDuoOIDCIntegration
      * 
      * @param hook principal mapping hook
      */
-    public void setContextToPrincipalMappingStrategy(
+    public synchronized void setContextToPrincipalMappingStrategy(
             @Nullable final Function<ProfileRequestContext,Collection<Principal>> hook) {
         checkSetterPreconditions();
         
@@ -348,11 +366,31 @@ public final class DefaultDuoOIDCIntegration
      * 
      * @return the mapping hook
      */
-    @Nullable public Function<ProfileRequestContext,Collection<Principal>> getContextToPrincipalMappingStrategy() {
+    @Override
+	@Nullable public synchronized Function<ProfileRequestContext,Collection<Principal>> 
+    	getContextToPrincipalMappingStrategy() {
         checkComponentActive();
         return contextToPrincipalMappingStrategy;
     }
     
+    /**
+     * Set the maximum authentication age.
+     * 
+     * @param age the maximum authentication age
+     * 
+     * @since 2.4.0
+     */
+    public synchronized void setMaxAuthenticationAge(@Nullable final Duration age) {
+    	checkSetterPreconditions();
+		maxAge = age;
+	}
+    
+    @Override
+    @Nullable public synchronized Duration getMaxAuthenticationAge() {
+    	checkComponentActive();
+    	return maxAge;
+    }
+    
     /** {@inheritDoc} */
     @Override
     protected void doInitialize() throws ComponentInitializationException {
@@ -360,9 +398,10 @@ public final class DefaultDuoOIDCIntegration
             if (apiHost == null || clientId == null || secretKey == null 
                     ||  healthEndpoint == null || authorizeEndpoint == null
                     || tokenEndpoint == null || (registeredRedirectURI == null && allowedOrigins.isEmpty())) {
-                throw new ComponentInitializationException("API host, clientId, secret key,"
-                        + "token endpoint, health check endpoint, authorization endpoint, and one of "
-                        + "redirectURI or allowed redirect URI origins must be set");
+                throw new ComponentInitializationException("""
+					API host, clientId, secret key,\
+					token endpoint, health check endpoint, authorization endpoint, and one of \
+					redirectURI or allowed redirect URI origins must be set""");
             }
         }
        
@@ -400,6 +439,8 @@ public final class DefaultDuoOIDCIntegration
         builder.append(clientId);
         builder.append(", redirectURI=");
         builder.append(redirectURI);
+        builder.append(", maxAge=");
+        builder.append(maxAge);
         builder.append("]");
         return builder.toString();
     }
diff --git a/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/DuoAuthenticationLifetimeLookupStrategy.java b/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/DuoAuthenticationLifetimeLookupStrategy.java
index ffcbf7ec..ffb1007f 100644
--- a/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/DuoAuthenticationLifetimeLookupStrategy.java
+++ b/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/DuoAuthenticationLifetimeLookupStrategy.java
@@ -11,19 +11,30 @@ import org.opensaml.profile.context.ProfileRequestContext;
 import org.slf4j.Logger;
 
 import net.shibboleth.idp.authn.context.AuthenticationContext;
+import net.shibboleth.idp.plugin.authn.duo.context.DuoOIDCAuthenticationContext;
 import net.shibboleth.shared.component.AbstractIdentifiableInitializableComponent;
 import net.shibboleth.shared.logic.Constraint;
 import net.shibboleth.shared.logic.FunctionSupport;
 import net.shibboleth.shared.primitive.LoggerFactory;
 
+
 /**
- * An authentication lifetime lookup strategy that uses different lookup strategies depending on whether a 'fresh'
- * authentication has been requested or not. That is, it determines, indirectly, how long a Duo authentication should 
- * be considered valid. 
- * 
- * <p>A {@code null} value from a lookup strategy is passed back to the caller as {@code null}. A 
- * {@code null} value should be interpreted as, do not enforce authentication lifetime.</p>
- */
+ * An authentication lifetime lookup strategy that determines the authentication lifetime to enforce.
+ *
+* <p>The lifetime is determined as follows:</p>
+* 	<ol>
+* 		<li>If the {@link DuoOIDCIntegration} explicitly specifies a maximum
+* 		authentication age, that value is used.</li>
+* 		<li>Otherwise, if a non-zero {@code max_age} value was stored in the {@link DuoOIDCAuthenticationContext} , that 
+* 		value is used.</li>
+* 		<li>Otherwise, if a fresh authentication was requested ({@code max_age=0}), the reauthentication lifetime 
+* 		lookup strategy	is used.</li>
+* 		<li>Otherwise, the default authentication lifetime lookup strategy is used.</li>
+* </ol>
+ *
+ * <p>If either lookup strategy returns {@code null} or if no lifetime can be determined, {@code null} is returned. 
+ * Callers should interpret {@code null} as meaning that no authentication lifetime should be enforced.</p>
+*/
 public class DuoAuthenticationLifetimeLookupStrategy extends AbstractIdentifiableInitializableComponent 
 		implements Function<ProfileRequestContext, Duration> {
 	
@@ -32,14 +43,15 @@ public class DuoAuthenticationLifetimeLookupStrategy extends AbstractIdentifiabl
 
 	/**
 	 * Lookup strategy to find the amount of time for which the auth_time inside a
-	 * token is valid for.
+	 * token is valid for. Used when no request-specific authentication age requirement has been established.
 	 */
 	@Nonnull
 	private Function<ProfileRequestContext, Duration> authnLifetimeLookupStrategy;
 	
 	/**
-	 * Lookup strategy to find the amount of time for which the auth_time inside a
-	 * token is valid for when a 'fresh' authentication is requested.
+	 * Lookup strategy to find the amount of time for which the auth_time inside a token is valid for when a 'fresh' 
+	 * authentication is requested. Used when no request-specific authentication age  requirement has been established 
+	 * from the Duo Integration used.
 	 * 
 	 */
 	@Nonnull
@@ -53,9 +65,8 @@ public class DuoAuthenticationLifetimeLookupStrategy extends AbstractIdentifiabl
 	
     /**
      * Sets the amount of time for which the auth_time inside a
-	 * token is valid for. That is, the time from which the end-user interactively
-	 * authenticated. This only applies to requests that <b>require</b> a fresh
-	 * authentication e.g. using forcedAuthn or max_age=0. 
+	 * token is valid for. This only applies to requests that <b>require</b> a fresh
+	 * authentication and no integration-specific authentication age override is present.
      * 
      * @param lifetime amount of time for which the auth_time inside a token is valid for. 
      * 				Can be {@code null} if a lifetime should not be enforced.
@@ -67,10 +78,9 @@ public class DuoAuthenticationLifetimeLookupStrategy extends AbstractIdentifiabl
     }
     
     /**
-     * Set the Lookup strategy to find the amount of time for which the auth_time inside a
-	 * token is valid for. That is, the time from which the end-user interactively
-	 * authenticated. This only applies to requests that <b>require</b> a fresh
-	 * authentication e.g. using forcedAuthn or max_age=0.
+     * Sets the strategy to determine the amount of time for which the auth_time inside a
+	 * token is valid for. This only applies to requests that <b>require</b> a fresh
+	 * authentication and no integration-specific authentication age override is present.
      * 
      * @param strategy the strategy. Can return {@code null} if a lifetime should not be enforced.
      */
@@ -82,10 +92,8 @@ public class DuoAuthenticationLifetimeLookupStrategy extends AbstractIdentifiabl
     }
     
     /**
-     * Sets the amount of time for which the auth_time inside a
-	 * token is valid for. That is, the time from which the end-user interactively
-	 * authenticated. This only applies to requests that <b>do not</b> require a fresh
-	 * authentication. 
+	 * Set the Lookup strategy to find the default amount of time for which the auth_time inside a
+	 * token is valid for. This only applies to requests that did not specify an authentication age.
      * 
      * @param lifetime amount of time for which the auth_time inside a token is valid for. 
      * 					Can be {@code null} if a lifetime should not be enforced.
@@ -97,10 +105,8 @@ public class DuoAuthenticationLifetimeLookupStrategy extends AbstractIdentifiabl
     }
     
     /**
-     * Set the Lookup strategy to find the amount of time for which the auth_time inside a
-	 * token is valid for. That is, the time from which the end-user interactively
-	 * authenticated. This only applies to requests that <b>do not</b> require a fresh
-	 * authentication. 
+     * Set the Lookup strategy to find the default amount of time for which the auth_time inside a
+	 * token is valid for. This only applies to requests that did not specify an authentication age.
      * 
      * @param strategy the strategy. Can return {@code null} if a lifetime should not be enforced.
      */
@@ -124,25 +130,54 @@ public class DuoAuthenticationLifetimeLookupStrategy extends AbstractIdentifiabl
             return null;
         }
         
-        if (authnContext.isForceAuthn()) {        	
-        	final Duration lifetime = reauthnLifetimeLookupStrategy.apply(prc);
-        	log.trace("Fresh authentication lifetime: {}",
+        final DuoOIDCAuthenticationContext duoContext = authnContext.getSubcontext(DuoOIDCAuthenticationContext.class);
+		if (duoContext == null) {
+			return null;
+		}
+		
+		final DuoOIDCIntegration integration = duoContext.getIntegration();
+		if (integration == null) {
+			return null;
+		}
+		
+		final Duration maxAgeFromCtx = duoContext.getMaxAge();
+		final Duration maxAgeFromInteg = integration.getMaxAuthenticationAge();
+		
+		// If set on the integration, we should enforce whatever value is recorded there, whether it needs to be 'fresh'
+		// or has a specific value. At this point the max_age will also be on the context, as it was sent to Duo.
+		if (maxAgeFromInteg != null) {
+			log.trace("Authentication lifetime from integration: {}", maxAgeFromInteg);
+			return maxAgeFromInteg;
+		}
+		
+		// Now check only if a max_age was on the context, and hence has been sent to Duo
+		if (maxAgeFromCtx != null) {
+			// If max_age is set on the context and is not 0, then use it
+			if (!maxAgeFromCtx.isZero()) {
+				log.trace("Authentication lifetime from request: {}", maxAgeFromCtx);
+				return maxAgeFromCtx;
+			}
+			// Else we have requested a fresh authentication, and this can be adjusted by global policy
+			final Duration lifetime = reauthnLifetimeLookupStrategy.apply(prc);
+        	log.trace("Fresh authentication lifetime from global policy: {}",
         			lifetime == null ? "disabled"
         			: lifetime.isZero()
         			? "authentication must occur after the authentication request"
         			: "maximum age=" + lifetime);
-
         	return lifetime;
-        } else {        	
-        	final Duration lifetime = authnLifetimeLookupStrategy.apply(prc);
-        	log.trace("Authentication lifetime: {}",
-        			lifetime == null ? "disabled"
-        			: lifetime.isZero()
-        			? "authentication must occur after the authentication request"
-        			: "maximum age=" + lifetime);
+		}
+		
+		// If we've not sent max_age to Duo, use the global default maximum authentication age lifetime value
+		final Duration lifetime = authnLifetimeLookupStrategy.apply(prc);
+    	log.trace("Authentication lifetime from global policy: {}",
+    			lifetime == null ? "disabled"
+    			: lifetime.isZero()
+    			? "authentication must occur after the authentication request"
+    			: "maximum age=" + lifetime);
+
+    	return lifetime;
+
 
-        	return lifetime;
-        }
 	}
 
 }
diff --git a/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/DuoOIDCClient.java b/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/DuoOIDCClient.java
index ca02240c..b86de82b 100644
--- a/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/DuoOIDCClient.java
+++ b/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/DuoOIDCClient.java
@@ -19,6 +19,7 @@ import javax.annotation.Nullable;
 
 import com.nimbusds.jwt.JWT;
 
+import net.shibboleth.idp.authn.duo.AuthenticationRequestOptions;
 import net.shibboleth.idp.plugin.authn.duo.model.DuoHealthCheck;
 import net.shibboleth.shared.annotation.constraint.NotEmpty;
 
@@ -62,10 +63,33 @@ public interface DuoOIDCClient extends DuoOIDCClientCapabilities{
      * @return the authorization redirect URL as a string, never {@code null}.
      * 
      * @throws DuoClientException if there is an error creating the authentication URL.
+     * 
+     * @deprecated Use {@link #createAuthUrl(AuthenticationRequestOptions)} instead
      */
+    @Deprecated(since = "2.4.0", forRemoval = true)
     @Nonnull @NotEmpty String createAuthUrl(@Nonnull @NotEmpty final String username, 
             @Nonnull @NotEmpty final String state, @Nullable final String nonce,
             @Nullable final String redirectURIOverride) throws DuoClientException;
+    
+    
+    /**
+     * Constructs an authorization redirection URL string with the query parameters required to initiate 
+     * a Duo 2FA request.
+     * 
+     * @param authOptions the set of authentication options to use for authentication request construction.
+     * 
+     * @return the authorization redirect URL as a string, never {@code null}.
+     * 
+     * @since 2.4.0
+     * 
+     * @throws DuoClientException if there is an error creating the authentication URL.
+     */
+    //TODO reverse the defaulted call to the old method in 3.0.0
+    @Nonnull @NotEmpty default String createAuthUrl(@Nonnull final AuthenticationRequestOptions options) 
+    			throws DuoClientException {
+    	return createAuthUrl(options.getUsername(), options.getState(), 
+    			options.getNonce(), options.getRedirectURIOverride());
+    }
    
     
     /**
diff --git a/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/DuoOIDCIntegration.java b/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/DuoOIDCIntegration.java
index f48896fe..a2165407 100644
--- a/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/DuoOIDCIntegration.java
+++ b/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/DuoOIDCIntegration.java
@@ -15,6 +15,7 @@
 package net.shibboleth.idp.plugin.authn.duo;
 
 import java.security.Principal;
+import java.time.Duration;
 import java.util.Collection;
 import java.util.Set;
 import java.util.function.Function;
@@ -125,5 +126,16 @@ public interface DuoOIDCIntegration extends PrincipalSupportingComponent {
                 getContextToPrincipalMappingStrategy() {
         return null;
     }
+    
+    /**
+     * Get the maximum authentication age in seconds.
+     * 
+     * @return the maximum authentication age
+     * 
+     * @since 2.4.0
+     */
+    default @Nullable public Duration getMaxAuthenticationAge() {
+    	return null;
+    }
         
 }
\ 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 e8f96b66..74783b0a 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
@@ -14,6 +14,7 @@
 
 package net.shibboleth.idp.plugin.authn.duo.context;
 
+import java.time.Duration;
 import java.time.Instant;
 
 import javax.annotation.Nonnull;
@@ -25,8 +26,10 @@ import org.opensaml.messaging.context.BaseContext;
 import com.nimbusds.jwt.JWT;
 
 import net.shibboleth.idp.authn.context.AuthenticationContext;
+import net.shibboleth.idp.authn.duo.AuthenticationRequestOptions;
 import net.shibboleth.idp.plugin.authn.duo.DuoOIDCClient;
 import net.shibboleth.idp.plugin.authn.duo.DuoOIDCIntegration;
+import net.shibboleth.shared.logic.Constraint;
 
 
 /**
@@ -80,6 +83,17 @@ public final class DuoOIDCAuthenticationContext extends BaseContext {
     
     /** The time at which the IdP made the authentication request to Duo.*/
     @Nullable private Instant authnRequestTime;
+    
+    /** 
+     * The max_age parameter to send. Typically used in a forced authentication scenario to signal interactive user
+     * reauthentication.
+     */
+    @Nullable private Duration maxAge;
+    
+    /** 
+     * The prompt request parameter. 
+     */
+    @Nullable private String prompt;
 
     /** Public no-arg constructor to allow auto-creation. */
     public DuoOIDCAuthenticationContext() {
@@ -331,7 +345,7 @@ public final class DuoOIDCAuthenticationContext extends BaseContext {
      * 
      * @param time the time the request was made
      * 
-     * @since 2.3.1
+     * @since 2.4.0
      */
     @Nonnull public DuoOIDCAuthenticationContext setAuthnRequestTime(@Nullable final Instant time) {
     	authnRequestTime = time;
@@ -343,10 +357,84 @@ public final class DuoOIDCAuthenticationContext extends BaseContext {
      * 
      * @return the time the request was made
      * 
-     * @since 2.3.1
+     * @since 2.4.0
      */
     @Nullable public Instant getAuthnRequestTime() {
         return authnRequestTime;
     }
     
+    /**
+     * Set the maximum authentication age parameter.
+     * 
+     * @param age the max_age
+     * 
+     * @return this context
+     * 
+     * @since 2.4.0
+     */
+    @Nonnull public DuoOIDCAuthenticationContext setMaxAge(@Nullable final Duration age) {
+		maxAge = age;
+		return this;
+	}
+    
+    /**
+     * Get the maximum authentication age parameter.
+     * 
+     * @return the max_age parameter
+     * 
+     * @since 2.4.0
+     */
+    @Nullable public Duration getMaxAge() {
+		return maxAge;
+	}
+    
+    /**
+     * Set the prompt request parameters.
+     * 
+     * @param promptIn the prompt request parameter value
+     * 
+     * @return this context
+     * 
+     * @since 2.4.0
+     */
+    @Nonnull public DuoOIDCAuthenticationContext setPrompt(@Nullable final String promptIn) {
+		prompt = promptIn;
+		return this;
+	}
+    
+    /**
+     * Get the prompt request parameter.
+     * 
+     * @return the prompt parameter
+     * 
+     * @since 2.4.0
+     */
+    @Nullable public String getPrompt() {
+		return prompt;
+	}
+    
+    /**
+     * Convert the authentication request parameters in this context into their own options object.
+     * 
+     * @param state the state value to use instead of {@link #requestState}. State might be a combination of
+     * 			{@link #requestState} and some other value e.g. SWF execution key, before it is appended to the request.
+     * 
+     * @return the authentication options to use when building an authentication request
+     * 
+     * @since 2.4.0
+     */
+    @Nonnull public AuthenticationRequestOptions toAuthenticationRequestOptions(@Nonnull final String state) {
+    	Constraint.isNotNull(state, "The OAuth state parameter can not be null");
+        final AuthenticationRequestOptions options = new AuthenticationRequestOptions();
+
+        options.setUsername(username);
+        options.setState(state);
+        options.setNonce(nonce);
+        options.setPrompt(prompt);
+        options.setMaxAge(maxAge);
+        options.setRedirectURIOverride(redirectURIOverride);
+
+        return options;
+    }
+    
 }
\ No newline at end of file
diff --git a/idp-duo-api/src/test/java/net/shibboleth/idp/plugin/authn/duo/DuoAuthenticationLifetimeLookupStrategyTest.java b/idp-duo-api/src/test/java/net/shibboleth/idp/plugin/authn/duo/DuoAuthenticationLifetimeLookupStrategyTest.java
index 230965b2..29b8b04e 100644
--- a/idp-duo-api/src/test/java/net/shibboleth/idp/plugin/authn/duo/DuoAuthenticationLifetimeLookupStrategyTest.java
+++ b/idp-duo-api/src/test/java/net/shibboleth/idp/plugin/authn/duo/DuoAuthenticationLifetimeLookupStrategyTest.java
@@ -1,6 +1,8 @@
 
 package net.shibboleth.idp.plugin.authn.duo;
 
+import static org.testng.Assert.assertEquals;
+
 import java.time.Duration;
 
 import org.opensaml.profile.context.ProfileRequestContext;
@@ -9,6 +11,7 @@ import org.testng.annotations.BeforeMethod;
 import org.testng.annotations.Test;
 
 import net.shibboleth.idp.authn.context.AuthenticationContext;
+import net.shibboleth.idp.plugin.authn.duo.context.DuoOIDCAuthenticationContext;
 import net.shibboleth.shared.component.ComponentInitializationException;
 
 /**
@@ -21,6 +24,10 @@ public class DuoAuthenticationLifetimeLookupStrategyTest {
 	private ProfileRequestContext prc;
 
 	private AuthenticationContext authnContext;
+	
+	private DuoOIDCAuthenticationContext duoCtx;
+	
+	private DefaultDuoOIDCIntegration integ;
 
 	@BeforeMethod
 	public void setup() {
@@ -29,11 +36,22 @@ public class DuoAuthenticationLifetimeLookupStrategyTest {
 
 		prc = new ProfileRequestContext();
 		authnContext = new AuthenticationContext();
+		duoCtx = authnContext.ensureSubcontext(DuoOIDCAuthenticationContext.class);
 		prc.addSubcontext(authnContext);
+		integ = new DefaultDuoOIDCIntegration();
+		integ.setAPIHost("host.com");
+        integ.setAuthorizeEndpoint("https://host.com/authz");
+        integ.setTokenEndpoint("https://host.com/token");
+        integ.setHealthCheckEndpoint("https://host.com/health");
+        integ.setClientId("DIU6GEFWG5LIUBVV2M3P");
+        integ.setRegisteredRedirectURI("http://localhost/");
+        integ.setSecretKey("rFvDfPul27v3Wew2zb6xRPzAJewJ34MP2w8UitPh");
+		duoCtx.setIntegration(integ);
 	}
 
 	@Test
 	public void testNullProfileRequestContext() throws ComponentInitializationException {
+		integ.initialize();
 		strategy.initialize();
 
 		Assert.assertNull(strategy.apply(null));
@@ -41,6 +59,7 @@ public class DuoAuthenticationLifetimeLookupStrategyTest {
 
 	@Test
 	public void testNullAuthenticationContext() throws ComponentInitializationException {
+		integ.initialize();
 		strategy.initialize();
 
 		prc.removeSubcontext(AuthenticationContext.class);
@@ -50,13 +69,14 @@ public class DuoAuthenticationLifetimeLookupStrategyTest {
 
 	@Test
 	public void testDefaultAuthnLifetimeNotEnforced() throws ComponentInitializationException {
-
+		integ.initialize();
 		strategy.initialize();
 		Assert.assertNull(strategy.apply(prc));
 	}
 
 	@Test
 	public void testAuthnLifetime() throws ComponentInitializationException {
+		integ.initialize();
 
 		final Duration expected = Duration.ofMinutes(5);	
 		strategy.setAuthnLifetime(expected);
@@ -67,6 +87,7 @@ public class DuoAuthenticationLifetimeLookupStrategyTest {
 	
 	@Test
 	public void testAuthnLifetime_Null() throws ComponentInitializationException {
+		integ.initialize();
 
 		final Duration expected = null;	
 		strategy.setAuthnLifetime(expected);
@@ -76,83 +97,91 @@ public class DuoAuthenticationLifetimeLookupStrategyTest {
 	}
 
 	@Test
-	public void testReauthnLifetime() throws ComponentInitializationException {
-
-		final Duration expected = Duration.ofSeconds(30);
-		
-		strategy.setReauthnLifetime(expected);
+	public void testAuthnStrategyUsedForNonForceAuthn() throws ComponentInitializationException {
+		integ.initialize();
+		final Duration expected = Duration.ofMinutes(10);
+	
+		strategy.setAuthnLifetimeLookupStrategy(prfctx -> expected);
 		strategy.initialize();
-		
-		authnContext.setForceAuthn(true);		
-		
+	
 		Assert.assertEquals(strategy.apply(prc), expected);
 	}
 	
 	@Test
-	public void testReauthnLifetime_Null() throws ComponentInitializationException {
-
-		final Duration expected = null;
-		
-		strategy.setReauthnLifetime(expected);
+	public void testAuthnStrategyUsedForForceAuthn() throws ComponentInitializationException {
+		integ.initialize();
+		final Duration expected = Duration.ofMinutes(0);
+	
+		strategy.setAuthnLifetimeLookupStrategy(prfctx -> expected);
 		strategy.initialize();
-		
-		authnContext.setForceAuthn(true);		
-		
+	
 		Assert.assertEquals(strategy.apply(prc), expected);
 	}
-
-	@Test
-	public void testAuthnStrategyUsedForNonForceAuthn() throws ComponentInitializationException {
-		final Duration expected = Duration.ofMinutes(10);
 	
-		strategy.setAuthnLifetimeLookupStrategy(prfctx -> expected);
+	@Test
+	public void testContextMaxAgeForForceAuthn() throws ComponentInitializationException {
+		integ.initialize();
+		final Duration expected = Duration.ofMinutes(0);
+		duoCtx.setMaxAge(expected);
+		strategy.setReauthnLifetime(Duration.ZERO);
 		strategy.initialize();
 	
-		authnContext.setForceAuthn(false);
-	
 		Assert.assertEquals(strategy.apply(prc), expected);
 	}
 	
 	@Test
 	public void testAuthnStrategyUsedForNonForceAuthn_Null() throws ComponentInitializationException {
+		integ.initialize();
 		final Duration expected = null;
 	
 		strategy.setAuthnLifetimeLookupStrategy(prfctx -> expected);
 		strategy.initialize();
 	
-		authnContext.setForceAuthn(false);
-	
 		Assert.assertEquals(strategy.apply(prc), expected);
 	}
-
+	
 	@Test
-	public void testReauthnStrategyUsedForForceAuthn() throws ComponentInitializationException {
+	public void testAuthnLifetimeFromDuoContext() throws ComponentInitializationException {
+		integ.initialize();
 
-		final Duration expected = Duration.ofMinutes(1);
-	
-		strategy.setReauthnLifetimeLookupStrategy(prfctx -> expected);
+		final Duration expected = Duration.ofMinutes(5);	
+		duoCtx.setMaxAge(expected);
 		strategy.initialize();
-
-		authnContext.setForceAuthn(true);
-	
-		prc.addSubcontext(authnContext);
 	
 		Assert.assertEquals(strategy.apply(prc), expected);
 	}
 	
 	@Test
-	public void testReauthnStrategyUsedForForceAuthn_Null() throws ComponentInitializationException {
-
-		final Duration expected = null;
+	public void testIntegrationPriority() throws ComponentInitializationException {	
+		integ.setMaxAuthenticationAge(Duration.ofMinutes(10));
+		duoCtx.setMaxAge(Duration.ofMinutes(1));
+		
+		integ.initialize();
+		strategy.initialize();
+	
+		assertEquals(strategy.apply(prc),Duration.ofMinutes(10));
+	}
 	
-		strategy.setReauthnLifetimeLookupStrategy(prfctx -> expected);
+	@Test
+	public void testReauthenticationWhenMaxAgeZero() throws ComponentInitializationException {	
+		duoCtx.setMaxAge(Duration.ZERO);
+		strategy.setReauthnLifetime(Duration.ofSeconds(10));
+		
+		integ.initialize();
 		strategy.initialize();
-
-		authnContext.setForceAuthn(true);
 	
-		prc.addSubcontext(authnContext);
+		assertEquals(strategy.apply(prc),Duration.ofSeconds(10));
+	}
 	
-		Assert.assertEquals(strategy.apply(prc), expected);
+	@Test
+	public void testReauthenticationWhenMaxAgeStrategyZero() throws ComponentInitializationException {	
+		duoCtx.setMaxAge(Duration.ZERO);
+		strategy.setReauthnLifetimeLookupStrategy(prc -> Duration.ofSeconds(10));
+		
+		integ.initialize();
+		strategy.initialize();
+	
+		assertEquals(strategy.apply(prc),Duration.ofSeconds(10));
 	}
 
 
diff --git a/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/impl/DuoOIDCAuthnController.java b/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/impl/DuoOIDCAuthnController.java
index e94529f4..6d1af303 100644
--- a/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/impl/DuoOIDCAuthnController.java
+++ b/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/impl/DuoOIDCAuthnController.java
@@ -143,7 +143,7 @@ public class DuoOIDCAuthnController extends AbstractInitializableComponent{
                 throw new DuoClientException("Duo request state, username, or integration not set"); 
             }
             
-            final String state = DuoSupport.generateState(requestState, key);            
+            final String state = DuoSupport.generateState(requestState, key);
             
             log.info("Starting Duo 2FA for client '{}', user '{}', and unique request state '{}'",
                     duoContext.getIntegration() != null ? integration.getClientId(): 
@@ -152,19 +152,11 @@ public class DuoOIDCAuthnController extends AbstractInitializableComponent{
             String authURL;
             //if an OIDC id_token nonce is supported, add it to the authz request and context
             if (client.getCapabilities().isSupportsNonce()) {
-                final String oidcNonce = DuoSupport.generateNonce(36);
-                authURL = client.createAuthUrl(username, state, oidcNonce, 
-                        duoContext.getRedirectURIOverride());
-                duoContext.setNonce(oidcNonce);
-               
-            } else {
-                authURL = client.createAuthUrl(username, state, null, 
-                        duoContext.getRedirectURIOverride());
-            }
-            
+            	duoContext.setNonce(DuoSupport.generateNonce(36));              
+            } 
             // Set the time at which we are making this authentication request
             duoContext.setAuthnRequestTime(Instant.now());
-
+            authURL = client.createAuthUrl(duoContext.toAuthenticationRequestOptions(state));
             httpResponse.sendRedirect(authURL);
 
         } catch (final DuoClientException e) {
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..6c3d76d9 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
@@ -14,6 +14,7 @@
 
 package net.shibboleth.idp.plugin.authn.duo.impl;
 
+import java.time.Duration;
 import java.util.function.BiFunction;
 import java.util.function.Function;
 
@@ -244,6 +245,11 @@ public class PopulateDuoAuthenticationContext extends AbstractAuthenticationActi
         // Store only the nonce component as the request state. The SWF key is added by the controller
         // And included in the authorization request to Duo.
         duoContext.setRequestState(nonce);
+        
+        // Determine if there are requirements on the max authentication age
+        determineMaxAuthenticationAgeRequirement(authenticationContext, duoContext, duoIntegration);
+        // Determine if we want to ensure a fresh, interactive, user authentication by setting prompt if appropriate.
+        determinePromptRequirement(duoContext);
 
         try {
             computeAndStoreRedirectURIIfSupported(duoIntegration, request, duoContext);     
@@ -262,6 +268,61 @@ public class PopulateDuoAuthenticationContext extends AbstractAuthenticationActi
         log.debug("Created Duo authentication context for '{}'", duoContext.getUsername());
     }
     
+    /**
+     * Determines the effective maximum authentication age requirement.
+     *
+     * <p>The following rules apply:</p>
+     * 	<ol>
+     * 		<li>If the integration specifies a maximum authentication age, that value
+     * 		is used.</li>
+     * 		<li>Otherwise, if force authentication has been requested, the user is
+     * 		required to re-authenticate by setting {@code max_age=0}.</li>
+     * 		<li>Otherwise, if a maximum authentication age is present on the
+     * 		authentication context, that value is used.</li>
+     * 		<li>Otherwise, no maximum authentication age requirement is applied.</li>
+     * </ol>
+     *
+     * @param authenticationContext the current authentication context
+     * @param duoContext the Duo OIDC authentication context
+     * @param duoIntegration the Duo integration configuration
+    */
+    private void determineMaxAuthenticationAgeRequirement(@Nonnull final AuthenticationContext authenticationContext,
+    		@Nonnull final DuoOIDCAuthenticationContext duoContext, @Nonnull final DuoOIDCIntegration duoIntegration) {
+    	
+    	if (duoIntegration.getMaxAuthenticationAge() != null) {
+    		log.trace("{} Using integration max_age override of {}", getLogPrefix(),
+    				duoIntegration.getMaxAuthenticationAge());
+    		duoContext.setMaxAge(duoIntegration.getMaxAuthenticationAge());
+    		
+    	} else if (authenticationContext.isForceAuthn()) {
+    		log.trace("{} Forced authentication has been requested, setting prompt to 'login' and max_age to '0'", 
+    				getLogPrefix());
+    		duoContext.setMaxAge(Duration.ZERO);
+    		
+    	} else if (authenticationContext.getMaxAge() != null) {
+    		log.trace("{} Using requested max_age from authentication context of '{}'", getLogPrefix(), 
+    				authenticationContext.getMaxAge());
+    		duoContext.setMaxAge(authenticationContext.getMaxAge());
+    	} else {
+    		log.trace("No max_age requirements");
+    	}
+    	
+    }
+    
+    /**
+     * Determine if we should set the prompt parameter in support of any fresh authentication requirement. That is,
+     * if max_age is set to 0, also set prompt to login. Whilst these have identical semantics in this case, we set
+     * this for consistency.
+     * 
+     * @param duoContext the Duo authentication context
+     */
+    private void determinePromptRequirement(@Nonnull final DuoOIDCAuthenticationContext duoContext) {
+    	
+    	if (duoContext.getMaxAge() != null && duoContext.getMaxAge().isZero()) {
+    		duoContext.setPrompt("login");
+    	}
+    }
+    
     /**
      * Perform standard context creation and lookups.
      * 
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 098aea4c..ee84ef24 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
@@ -66,7 +66,7 @@
         p:healthCheckEndpoint="%{idp.duo.oidc.endpoint.health:/oauth/v1/health_check}"
         p:tokenEndpoint="%{idp.duo.oidc.endpoint.token:/oauth/v1/token}"
         p:authorizeEndpoint="%{idp.duo.oidc.endpoint.authorize:/oauth/v1/authorize}"
-        p:allowedOrigins="%{idp.duo.oidc.redirecturl.allowedOrigins:}" />
+        p:allowedOrigins="%{idp.duo.oidc.redirecturl.allowedOrigins:}"/>
     <bean id="shibboleth.authn.DuoOIDC.DuoIntegrationStrategy" parent="shibboleth.Functions.Constant"
         c:target-ref="shibboleth.authn.DuoOIDC.DuoIntegration" />
 
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 672a94bc..38662101 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
@@ -449,6 +449,11 @@ public abstract class AbstractAuthnXmlFlowExecutionTests extends CustomAbstractX
                 genericBeanDefinition(SpringExpressionBiPredicate.class)
                 .setAbstract(true).getBeanDefinition());
         
+        
+        addBeanDefinition(builderContext, "shibboleth.Conditions.FALSE",BeanDefinitionBuilder.
+                genericBeanDefinition(net.shibboleth.shared.logic.PredicateSupport.class)
+                .setFactoryMethod("alwaysFalse").setAbstract(false).getBeanDefinition());
+        
         addBeanDefinition(builderContext, "shibboleth.CommaDelimStringArray",BeanDefinitionBuilder.
                 genericBeanDefinition(StringUtils.class)
                 .setFactoryMethod("commaDelimitedListToStringArray").setAbstract(true).getBeanDefinition());
diff --git a/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/DuoAuthnFlowTest.java b/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/DuoAuthnFlowTest.java
index 518b2733..0cfd8c96 100644
--- a/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/DuoAuthnFlowTest.java
+++ b/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/DuoAuthnFlowTest.java
@@ -18,6 +18,7 @@ package net.shibboleth.idp.plugin.authn.duo.impl;
 import java.io.IOException;
 import java.net.UnknownHostException;
 import java.security.Principal;
+import java.time.Duration;
 import java.time.Instant;
 import java.util.List;
 import java.util.Map;
@@ -253,6 +254,43 @@ public class DuoAuthnFlowTest extends AbstractAuthnXmlFlowExecutionTests {
 
     }
     
+    /** 
+     * Test the Duo flow up to the external authorization request when forced authentication has been
+     * requested.
+     */
+    @Test
+    public void testDuoAuthnFlowToAuthorizationRequest_WithForcedAuthentication() {
+              
+        setFlowPath(FLOW);
+        setFlowModelResources(flowResources);
+        setSubflows(subflows);
+        setClientFactory(new MockDuoOIDCClientFactory_OK_Client());
+
+        
+       final Map<String,String> mockProperties = Map.of(
+                "idp.duo.oidc.redirectURL","http://localhost/authorization-callback",
+                "idp.duo.oidc.apiHost","api-c9f24c5a.duosecurity.com",
+                "idp.duo.oidc.clientId","DIU6GEFWG5LIUBVV2M3P",
+                "idp.duo.oidc.secretKey","rFvDfPul27v3Wew2zb6xRPzAJewJ34MP2w8UitPh",
+                "idp.duo.oidc.user.config","duo-oidc-authn-config.xml",
+                "idp.duo.oidc.clientFactoryBean","shibboleth.authn.DuoOIDC.test.clientFactory");
+        
+        setMockProperties(mockProperties);
+        
+        final LocalAttributeMap<Object> inputMap = new LocalAttributeMap<>();
+        inputMap.put("calledAsSubflow", true);
+       
+        final FlowExecution flowExecution = getFlowExecutionFactory().createFlowExecution(getFlowDefinition());  
+        // Build request context with forced authentication flag set to true
+        flowExecution.getConversationScope().put("opensamlProfileRequestContext", 
+                buildProfileRequestContext(true,true));
+        updateFlowExecution(flowExecution);         
+        flowExecution.start(inputMap, externalContext);         
+        assertFlowExecutionActive();
+        assertCurrentStateEquals("Duo2FAAuthorizationRequest");
+
+    }
+    
     /** 
      * Test the Duo flow up to the passwordless view when using a passwordless integration.
      * 
@@ -679,88 +717,12 @@ public class DuoAuthnFlowTest extends AbstractAuthnXmlFlowExecutionTests {
         
     }
 
-    /** 
-     * Test the Duo flow from the external authorization request. This should fail, as forced authn is
-     * requested but the auth_time is from a previous authentication (to far in the past). And reauthnLifetime is set
-     * to 1 second.
-     * 
-     * @throws DuoClientException if the client can not be created.
-     * @throws ComponentInitializationException on error
-     */
-    @Test 
-    public void testDuoAuthnFlowFromAuthorizationCallbackForceAuthnFailure() 
-            throws DuoClientException, ComponentInitializationException {
-        
-        setFlowPath(FLOW);
-        setFlowModelResources(flowResources);
-        setSubflows(subflows);
-        setClientFactory(new MockDuoOIDCClientFactory_OK_Client());
-        
-        
-        final Map<String,String> mockProperties = Map.of(
-                "idp.duo.oidc.clientFactoryBean","shibboleth.authn.DuoOIDC.test.clientFactory",
-                "idp.duo.oidc.user.config","duo-oidc-authn-config.xml",
-                //these are not used for the test, but required to prevent init exceptions
-                "idp.duo.oidc.redirectURL","http://localhost/authorization-callback",
-                "idp.duo.oidc.apiHost","api-c9f24c5a.duosecurity.com",
-                "idp.duo.oidc.clientId","DIU6GEFWG5LIUBVV2M3P",
-                "idp.duo.oidc.secretKey","rFvDfPul27v3Wew2zb6xRPzAJewJ34MP2w8UitPh",
-                "idp.duo.oidc.jwt.verifier.reauthLifetime", "PT1S");
-        
-        setMockProperties(mockProperties);
-
-        final FlowExecutionImpl flowExecution = (FlowExecutionImpl)getFlowExecutionFactory()
-                .createFlowExecution(getFlowDefinition());
-        //true for forced authn
-        final ProfileRequestContext prc =  buildProfileRequestContext(true,false);
-        //add a DuoContext
-        final DuoOIDCAuthenticationContext duoContext = new DuoOIDCAuthenticationContext();
-        final String nonce = DuoSupport.generateNonce(32);
-        duoContext.setAuthorizationCode("adummycode");
-        duoContext.setRequestState(nonce);
-        duoContext.setResponseState(nonce);
-        duoContext.setUsername("jdoe");
-        duoContext.setAuthnRequestTime(Instant.now());
-        
-        final DefaultDuoOIDCIntegration integ = new DefaultDuoOIDCIntegration();
-        integ.setClientId("DIU6GEFWG5LIUBVV2M3P");
-        integ.setAPIHost("api-c9f24c5a.duosecurity.com");
-        integ.setSecretKey("rFvDfPul27v3Wew2zb6xRPzAJewJ34MP2w8UitPh");
-        integ.setRegisteredRedirectURI("http://localhost/authorization-callback");
-        integ.setAuthorizeEndpoint("/authorize");
-        integ.setHealthCheckEndpoint("/health");
-        integ.setTokenEndpoint("/token");
-        integ.initialize();
-        duoContext.setIntegration(integ);
-        
-        //add the mock client as was not added by the populate stage
-        duoContext.setClient(new MockDuoOIDCClient_OK_OLD_AUTH_TIME(integ));
-        
-        prc.ensureSubcontext(AuthenticationContext.class).addSubcontext(duoContext);
-        prc.ensureSubcontext(AuthenticationContext.class)
-                    .addSubcontext(new ExternalAuthenticationContext(new ExternalAuthenticationImpl(false)));
-        flowExecution.getConversationScope().put("opensamlProfileRequestContext",prc);
-        updateFlowExecution(flowExecution);
-        
-        //set start view and ending event to transition on.
-        externalContext.setEventId("proceed");
-        setCurrentState("Duo2FAAuthorizationRequest");       
-        resumeFlow(externalContext);
-        
-        //assert success conditions
-        assertFlowExecutionEnded();        
-        assertNotNull(prc.getSubcontext(EventContext.class));
-        assertNotNull(prc.ensureSubcontext(EventContext.class).getEvent());
-        assertTrue(prc.ensureSubcontext(EventContext.class).getEvent() instanceof String);
-        assertEquals(AuthnEventIds.NO_CREDENTIALS, prc.ensureSubcontext(EventContext.class).getEvent());
-        
-    }
    
     
     /** 
      * Test for https://shibboleth.atlassian.net/browse/JDUO-103. Check, by default, the authentication time validator
-     * is not run if the idp.duo.oidc.jwt.verifier.authLifetime is null. So we set an old auth_time on the response, to
-     * test it.
+     * is not run if the idp.duo.oidc.jwt.verifier.authLifetime is null, and max_age was not requested. 
+     * So we set an old auth_time on the response, to test it.
      * 
      * @throws DuoClientException on error.
      * @throws ComponentInitializationException on error
@@ -830,16 +792,18 @@ public class DuoAuthnFlowTest extends AbstractAuthnXmlFlowExecutionTests {
         
     }
     
+   
+    
     /** 
      * Test for https://shibboleth.atlassian.net/browse/JDUO-103. Check, by default, the authentication time validator
-     * is not run if the idp.duo.oidc.jwt.verifier.reauthLifetime is null. So we set an old auth_time on the response, 
-     * to test it.
+     * is run because the global idp.duo.oidc.jwt.verifier.authLifetime is 1 second. Set an old auth_time to 
+     * test failure.
      * 
      * @throws DuoClientException on error.
      * @throws ComponentInitializationException on error
      */
     @Test
-    public void testForceAuthnNoReauthenticationTimeValidation() throws DuoClientException, ComponentInitializationException {
+    public void testNoForceAuthnAuthenticationTimeValidation() throws DuoClientException, ComponentInitializationException {
     	setFlowPath(FLOW);
         setFlowModelResources(flowResources);
         setSubflows(subflows);
@@ -853,15 +817,15 @@ public class DuoAuthnFlowTest extends AbstractAuthnXmlFlowExecutionTests {
                 "idp.duo.oidc.apiHost","api-c9f24c5a.duosecurity.com",
                 "idp.duo.oidc.clientId","DIU6GEFWG5LIUBVV2M3P",
                 "idp.duo.oidc.secretKey","rFvDfPul27v3Wew2zb6xRPzAJewJ34MP2w8UitPh",
-                // Configure a null authentication lifetime to turn off the check
-                "idp.duo.oidc.jwt.verifier.reauthLifetime","#{null}");
+                // Configure a 1 second default authentication time
+                "idp.duo.oidc.jwt.verifier.authLifetime","PT1S");
         
         setMockProperties(mockProperties);
 
         final FlowExecutionImpl flowExecution = (FlowExecutionImpl)getFlowExecutionFactory()
                 .createFlowExecution(getFlowDefinition());
-        //true so forced authn, needed for this test
-        final ProfileRequestContext prc =  buildProfileRequestContext(true,false);
+        //false so no forced authn, needed for this test
+        final ProfileRequestContext prc =  buildProfileRequestContext(false,false);
         //add a DuoContext
         final DuoOIDCAuthenticationContext duoContext = new DuoOIDCAuthenticationContext();
         final String nonce = DuoSupport.generateNonce(32);
@@ -898,21 +862,23 @@ public class DuoAuthnFlowTest extends AbstractAuthnXmlFlowExecutionTests {
         
         //assert success conditions
         assertFlowExecutionEnded();
-        assertNotNull(prc.ensureSubcontext(SubjectCanonicalizationContext.class));
-        assertEquals(prc.ensureSubcontext(SubjectCanonicalizationContext.class).getPrincipalName(),"jdoe");
+        assertNotNull(prc.getSubcontext(EventContext.class));
+        assertNotNull(prc.ensureSubcontext(EventContext.class).getEvent());
+        assertTrue(prc.ensureSubcontext(EventContext.class).getEvent() instanceof String);
+        assertEquals(AuthnEventIds.NO_CREDENTIALS, prc.ensureSubcontext(EventContext.class).getEvent());
         
     }
     
     /** 
      * Test for https://shibboleth.atlassian.net/browse/JDUO-103. Check, by default, the authentication time validator
-     * is run if the idp.duo.oidc.jwt.verifier.reauthLifetime is 1 second. So we set an old auth_time on the
-     * response, to test it.
+     * is run because the Duo Context has set a 1 second max_age. Set an old auth_time to test failure.
      * 
      * @throws DuoClientException on error.
      * @throws ComponentInitializationException on error
      */
     @Test
-    public void testForceAuthnReauthenticationTimeValidation() throws DuoClientException, ComponentInitializationException {
+    public void testNoForceAuthnAuthenticationTimeValidation_BasedOnContext() 
+    		throws DuoClientException, ComponentInitializationException {
     	setFlowPath(FLOW);
         setFlowModelResources(flowResources);
         setSubflows(subflows);
@@ -925,16 +891,14 @@ public class DuoAuthnFlowTest extends AbstractAuthnXmlFlowExecutionTests {
                 "idp.duo.oidc.redirectURL","http://localhost/authorization-callback",
                 "idp.duo.oidc.apiHost","api-c9f24c5a.duosecurity.com",
                 "idp.duo.oidc.clientId","DIU6GEFWG5LIUBVV2M3P",
-                "idp.duo.oidc.secretKey","rFvDfPul27v3Wew2zb6xRPzAJewJ34MP2w8UitPh",
-                // Configure a null authentication lifetime to turn off the check
-                "idp.duo.oidc.jwt.verifier.reauthLifetime","PT1S");
+                "idp.duo.oidc.secretKey","rFvDfPul27v3Wew2zb6xRPzAJewJ34MP2w8UitPh");
         
         setMockProperties(mockProperties);
 
         final FlowExecutionImpl flowExecution = (FlowExecutionImpl)getFlowExecutionFactory()
                 .createFlowExecution(getFlowDefinition());
-        //true so forced authn, needed for this test
-        final ProfileRequestContext prc =  buildProfileRequestContext(true,false);
+        //false so no forced authn, needed for this test
+        final ProfileRequestContext prc =  buildProfileRequestContext(false,false);
         //add a DuoContext
         final DuoOIDCAuthenticationContext duoContext = new DuoOIDCAuthenticationContext();
         final String nonce = DuoSupport.generateNonce(32);
@@ -943,6 +907,8 @@ public class DuoAuthnFlowTest extends AbstractAuthnXmlFlowExecutionTests {
         duoContext.setResponseState(nonce);
         duoContext.setUsername("jdoe");
         duoContext.setAuthnRequestTime(Instant.now());
+        // Set max_age here.
+        duoContext.setMaxAge(Duration.ofSeconds(1));
         
         final DefaultDuoOIDCIntegration integ = new DefaultDuoOIDCIntegration();
         integ.setClientId("DIU6GEFWG5LIUBVV2M3P");
@@ -952,6 +918,7 @@ public class DuoAuthnFlowTest extends AbstractAuthnXmlFlowExecutionTests {
         integ.setAuthorizeEndpoint("/authorize");
         integ.setHealthCheckEndpoint("/health");
         integ.setTokenEndpoint("/token");
+
         integ.initialize();
         duoContext.setIntegration(integ);
         
@@ -980,16 +947,14 @@ public class DuoAuthnFlowTest extends AbstractAuthnXmlFlowExecutionTests {
     
     /** 
      * Test for https://shibboleth.atlassian.net/browse/JDUO-103. Check, by default, the authentication time validator
-     * is run if the idp.duo.oidc.jwt.verifier.reauthLifetime is 0 seconds. When 0 seconds, the authentication must 
-     * occur immediately after the authentication. The {@link MockDuoOIDCClient_OK} sets the auth_time of the id token
-     * to the time at which it is created, the validator by default then says it must happen after the time which validation
-     * occurs minus the clockSkew.  
+     * is run because the Duo Context has set a 0 second max_age. Set an old auth_time to test failure.
      * 
      * @throws DuoClientException on error.
      * @throws ComponentInitializationException on error
      */
     @Test
-    public void testForceAuthnImmediateReauthenticationTimeValidation() throws DuoClientException, ComponentInitializationException {
+    public void testForcedAuthentication_NoIntegrationOverride() 
+    		throws DuoClientException, ComponentInitializationException {
     	setFlowPath(FLOW);
         setFlowModelResources(flowResources);
         setSubflows(subflows);
@@ -1003,10 +968,8 @@ public class DuoAuthnFlowTest extends AbstractAuthnXmlFlowExecutionTests {
                 "idp.duo.oidc.apiHost","api-c9f24c5a.duosecurity.com",
                 "idp.duo.oidc.clientId","DIU6GEFWG5LIUBVV2M3P",
                 "idp.duo.oidc.secretKey","rFvDfPul27v3Wew2zb6xRPzAJewJ34MP2w8UitPh",
-                // Configure a null authentication lifetime to turn off the check
-                "idp.duo.oidc.jwt.verifier.reauthLifetime","PT0S",
-                "idp.duo.oidc.jwt.verifier.clockSkew", "PT60S",
-                "idp.duo.oidc.jwt.verifier.authnRequestTimeClockSkew","PT5S");
+                // Set reauth lifetime to ensure validation occurs for forced authentication
+                "idp.duo.oidc.jwt.verifier.reauthLifetime", "PT0S");
         
         setMockProperties(mockProperties);
 
@@ -1022,6 +985,8 @@ public class DuoAuthnFlowTest extends AbstractAuthnXmlFlowExecutionTests {
         duoContext.setResponseState(nonce);
         duoContext.setUsername("jdoe");
         duoContext.setAuthnRequestTime(Instant.now());
+        duoContext.setMaxAge(Duration.ZERO);
+        duoContext.setPrompt("login");
         
         final DefaultDuoOIDCIntegration integ = new DefaultDuoOIDCIntegration();
         integ.setClientId("DIU6GEFWG5LIUBVV2M3P");
@@ -1031,78 +996,7 @@ public class DuoAuthnFlowTest extends AbstractAuthnXmlFlowExecutionTests {
         integ.setAuthorizeEndpoint("/authorize");
         integ.setHealthCheckEndpoint("/health");
         integ.setTokenEndpoint("/token");
-        integ.initialize();
-        duoContext.setIntegration(integ);
-        
-        //add the mock client that sets a very old auth_time to check no validation occurs
-        duoContext.setClient(new MockDuoOIDCClient_OK(integ));
-        
-        prc.ensureSubcontext(AuthenticationContext.class).addSubcontext(duoContext);
-        prc.ensureSubcontext(AuthenticationContext.class)
-                    .addSubcontext(new ExternalAuthenticationContext(new ExternalAuthenticationImpl(false)));
-        flowExecution.getConversationScope().put("opensamlProfileRequestContext",prc);
-        updateFlowExecution(flowExecution);
-        
-        //set start view and ending event to transition on.
-        externalContext.setEventId("proceed");
-        setCurrentState("Duo2FAAuthorizationRequest");       
-        resumeFlow(externalContext);
-        
-        //assert success conditions
-        assertFlowExecutionEnded();
-        assertNotNull(prc.ensureSubcontext(SubjectCanonicalizationContext.class));
-        assertEquals(prc.ensureSubcontext(SubjectCanonicalizationContext.class).getPrincipalName(),"jdoe");
-        
-    }
-    
-    /** 
-     * Test for https://shibboleth.atlassian.net/browse/JDUO-103. Check, by default, the authentication time validator
-     * is run because the idp.duo.oidc.jwt.verifier.authLifetime is 1 second. Set an old auth_time to test failure.
-     * 
-     * @throws DuoClientException on error.
-     * @throws ComponentInitializationException on error
-     */
-    @Test
-    public void testNoForceAuthnAuthenticationTimeValidation() throws DuoClientException, ComponentInitializationException {
-    	setFlowPath(FLOW);
-        setFlowModelResources(flowResources);
-        setSubflows(subflows);
-        setClientFactory(new MockDuoOIDCClientFactory_OK_Client());
-        
-        
-        final Map<String,String> mockProperties = Map.of(
-                "idp.duo.oidc.clientFactoryBean","shibboleth.authn.DuoOIDC.test.clientFactory",
-                //these are not used for the test, but required to prevent init exceptions
-                "idp.duo.oidc.redirectURL","http://localhost/authorization-callback",
-                "idp.duo.oidc.apiHost","api-c9f24c5a.duosecurity.com",
-                "idp.duo.oidc.clientId","DIU6GEFWG5LIUBVV2M3P",
-                "idp.duo.oidc.secretKey","rFvDfPul27v3Wew2zb6xRPzAJewJ34MP2w8UitPh",
-                // Configure a null authentication lifetime to turn off the check
-                "idp.duo.oidc.jwt.verifier.authLifetime","PT1S");
-        
-        setMockProperties(mockProperties);
 
-        final FlowExecutionImpl flowExecution = (FlowExecutionImpl)getFlowExecutionFactory()
-                .createFlowExecution(getFlowDefinition());
-        //false so no forced authn, needed for this test
-        final ProfileRequestContext prc =  buildProfileRequestContext(false,false);
-        //add a DuoContext
-        final DuoOIDCAuthenticationContext duoContext = new DuoOIDCAuthenticationContext();
-        final String nonce = DuoSupport.generateNonce(32);
-        duoContext.setAuthorizationCode("adummycode");
-        duoContext.setRequestState(nonce);
-        duoContext.setResponseState(nonce);
-        duoContext.setUsername("jdoe");
-        duoContext.setAuthnRequestTime(Instant.now());
-        
-        final DefaultDuoOIDCIntegration integ = new DefaultDuoOIDCIntegration();
-        integ.setClientId("DIU6GEFWG5LIUBVV2M3P");
-        integ.setAPIHost("api-c9f24c5a.duosecurity.com");
-        integ.setSecretKey("rFvDfPul27v3Wew2zb6xRPzAJewJ34MP2w8UitPh");
-        integ.setRegisteredRedirectURI("http://localhost/authorization-callback");
-        integ.setAuthorizeEndpoint("/authorize");
-        integ.setHealthCheckEndpoint("/health");
-        integ.setTokenEndpoint("/token");
         integ.initialize();
         duoContext.setIntegration(integ);
         
diff --git a/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/PopulateDuoAuthenticationContextTest.java b/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/PopulateDuoAuthenticationContextTest.java
index f6d2b5e9..b5cbcd83 100644
--- a/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/PopulateDuoAuthenticationContextTest.java
+++ b/idp-duo-impl/src/test/java/net/shibboleth/idp/plugin/authn/duo/impl/PopulateDuoAuthenticationContextTest.java
@@ -179,6 +179,7 @@ public class PopulateDuoAuthenticationContextTest extends AbstractDuoActionTest
         final DuoOIDCClient mockClient = Mockito.mock(DuoOIDCClient.class);
         Mockito.when(mockClientRegistry.getClientOrCreate(any(DuoOIDCIntegration.class))).thenReturn(mockClient);
         
+        integ.initialize();
         action.setClientRegistry(mockClientRegistry);
         
         action.initialize();
@@ -215,6 +216,7 @@ public class PopulateDuoAuthenticationContextTest extends AbstractDuoActionTest
         final DuoOIDCClient mockClient = Mockito.mock(DuoOIDCClient.class);
         Mockito.when(mockClientRegistry.getClientOrCreate(any(DuoOIDCIntegration.class))).thenReturn(mockClient);
 
+        integ.initialize();
       
         action.setClientRegistry(mockClientRegistry);
 
diff --git a/idp-duo-nimbus-client-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/nimbus/impl/NimbusClient.java b/idp-duo-nimbus-client-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/nimbus/impl/NimbusClient.java
index 02e058d4..adcdc2b7 100644
--- a/idp-duo-nimbus-client-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/nimbus/impl/NimbusClient.java
+++ b/idp-duo-nimbus-client-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/nimbus/impl/NimbusClient.java
@@ -43,6 +43,7 @@ import com.nimbusds.jose.util.IOUtils;
 import com.nimbusds.jwt.JWT;
 import com.nimbusds.jwt.SignedJWT;
 
+import net.shibboleth.idp.authn.duo.AuthenticationRequestOptions;
 import net.shibboleth.idp.plugin.authn.duo.AbstractDuoOIDCClient;
 import net.shibboleth.idp.plugin.authn.duo.DuoClientException;
 import net.shibboleth.idp.plugin.authn.duo.DuoOIDCIntegration;
@@ -125,15 +126,28 @@ public final class NimbusClient extends AbstractDuoOIDCClient{
     @Nonnull public String createAuthUrl(@Nonnull @NotEmpty final String username, 
             @Nonnull @NotEmpty final String state, @Nullable final String nonce,
             @Nullable final String redirectURIOverride) throws DuoClientException {
-        Constraint.isNotEmpty(username, "Username can not be null or empty");
-        Constraint.isNotEmpty(state, "State can not be null or empty");
-        Constraint.isNotEmpty(nonce, "Nonce can not be null or empty for this client");
-        Constraint.isGreaterThan(21, state.length(), "State must be at least 22 characters");
-        Constraint.isLessThan(1025, state.length(),"State must be at maximum 1024 characters");
+        
+        final AuthenticationRequestOptions options = new AuthenticationRequestOptions()
+        		.setUsername(username)
+        		.setRedirectURIOverride(redirectURIOverride)
+        		.setState(state)
+        		.setNonce(nonce);
+        
+        return createAuthUrl(options);
+        
+    }
+    
+    @Override
+    @Nonnull public String createAuthUrl(@Nonnull final AuthenticationRequestOptions requestOptions) throws DuoClientException {
+        Constraint.isNotEmpty(requestOptions.getUsername(), "Username can not be null or empty");
+        Constraint.isNotEmpty(requestOptions.getState(), "State can not be null or empty");
+        Constraint.isNotEmpty(requestOptions.getNonce(), "Nonce can not be null or empty for this client");
+        Constraint.isGreaterThan(21, requestOptions.getState().length(), "State must be at least 22 characters");
+        Constraint.isLessThan(1025, requestOptions.getState().length(),"State must be at maximum 1024 characters");
         
         try {
-            final String redirectURI = redirectURIOverride != null ? 
-                    redirectURIOverride : duoIntegration.getRedirectURI();
+            final String redirectURI = requestOptions.getRedirectURIOverride() != null ? 
+            		requestOptions.getRedirectURIOverride() : duoIntegration.getRedirectURI();
             
             if (redirectURI == null) {
                 throw new DuoClientException("A redirect_uri was not supplied but is required "
@@ -141,14 +155,15 @@ public final class NimbusClient extends AbstractDuoOIDCClient{
             }
             
             final String request = NimbusClientSupport.createJWSRequestObject(
-                    duoIntegration.getClientId(), redirectURI, duoIntegration.getSecretKey(), state, username);
+                    duoIntegration.getClientId(), redirectURI, duoIntegration.getSecretKey(), 
+                    requestOptions);
             
             final URI uri = new URIBuilder()
                     .setScheme(HTTPS)
                     .setHost(duoIntegration.getAPIHost())
                     .setPath(duoIntegration.getAuthorizeEndpoint())
                     .setParameter("scope", "openid")
-                    .setParameter("nonce", nonce)
+                    .setParameter("nonce", requestOptions.getNonce())
                     .setParameter("response_type", "code")
                     .setParameter("redirect_uri", redirectURI)
                     .setParameter("client_id", duoIntegration.getClientId())
diff --git a/idp-duo-nimbus-client-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/nimbus/impl/NimbusClientSupport.java b/idp-duo-nimbus-client-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/nimbus/impl/NimbusClientSupport.java
index c711fd2c..07d25c3a 100644
--- a/idp-duo-nimbus-client-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/nimbus/impl/NimbusClientSupport.java
+++ b/idp-duo-nimbus-client-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/nimbus/impl/NimbusClientSupport.java
@@ -26,6 +26,7 @@ import com.nimbusds.jose.JOSEException;
 import com.nimbusds.jose.JWSAlgorithm;
 import com.nimbusds.jwt.JWTClaimsSet;
 
+import net.shibboleth.idp.authn.duo.AuthenticationRequestOptions;
 import net.shibboleth.idp.plugin.authn.duo.DuoClientException;
 import net.shibboleth.oidc.security.JWSAssemblyUtils;
 import net.shibboleth.shared.annotation.constraint.NotEmpty;
@@ -80,24 +81,59 @@ public final class NimbusClientSupport {
             @Nonnull @NotEmpty final String redirectURI, @Nonnull @NotEmpty final String secret,
             @Nonnull @NotEmpty final String state, @Nonnull @NotEmpty final String username) throws DuoClientException{
         
+    	return createJWSRequestObject(clientID,	redirectURI, secret, new AuthenticationRequestOptions()
+    			.setState(state)
+    			.setUsername(username)); 
+    }
+    
+    /**
+     * Create a signed JWT Request object using the given parameters suitable for the Duo token endpoint.
+     * <p>
+     * Only supports the HS512 JWS algorithm.
+     * </p>
+     * 
+     * @param clientID the client identifier
+     * @param redirectURI the redirectURI
+     * @param secret the client secret
+     * @param options the set of authentication request options
+     * 
+     * @throws DuoClientException on error constructing the JWS request object
+     * 
+     * @return a signed JWT
+     * 
+     * @since 2.4.0
+     */ 
+    @Nonnull static String createJWSRequestObject(@Nonnull @NotEmpty final String clientID,
+            @Nonnull @NotEmpty final String redirectURI, @Nonnull @NotEmpty final String secret,
+            @Nonnull final AuthenticationRequestOptions options) throws DuoClientException{
+        
         Constraint.isNotEmpty(clientID, "ClientID can not be null or empty");
         Constraint.isNotEmpty(redirectURI, "RedirectURI can not be null or empty");
-        Constraint.isNotEmpty(state, "State can not be null or empty");
-        Constraint.isNotEmpty(username, "username can not be null or empty");
+        Constraint.isNotEmpty(options.getState(), "State can not be null or empty");
+        Constraint.isNotEmpty(options.getUsername(), "username can not be null or empty");
         
         final Date expiration = new Date();
-        expiration.setTime(expiration.getTime() + Duration.ofHours(1).toMillis());
-
+        expiration.setTime(expiration.getTime() + Duration.ofMinutes(5).toMillis());
+        
         try {            
-            final JWTClaimsSet claimsSet = new JWTClaimsSet.Builder()
+            final JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder()
                     .expirationTime(expiration)
                     .claim("scope", "openid")
                     .claim("client_id", clientID)
                     .claim("redirect_uri", redirectURI)
-                    .claim("state", state)
-                    .claim("duo_uname", username)
-                    .claim("response_type", "code")
-                    .build();
+                    .claim("state", options.getState())
+                    .claim("duo_uname", options.getUsername())
+                    .claim("response_type", "code");
+            
+            if (options.getMaxAge() != null) {
+            	builder.claim("max_age", options.getMaxAge().toSeconds());
+            }
+            if (options.getPrompt() != null) {
+            	// Duo only support 'login'
+            	builder.claim("prompt", options.getPrompt());
+            }
+            
+            final JWTClaimsSet claimsSet = builder.build();
             
             return JWSAssemblyUtils.assembleMacJwsAsString(
                     JWSAlgorithm.HS512,claimsSet,JWSAssemblyUtils.getSecretBytes(secret));      
@@ -150,4 +186,5 @@ public final class NimbusClientSupport {
         }        
         
     }
+    
 }
diff --git a/idp-duo-sdk-client-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/sdk/impl/DuoSDKClientAdaptor.java b/idp-duo-sdk-client-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/sdk/impl/DuoSDKClientAdaptor.java
index 69fc24c8..18ab9009 100644
--- a/idp-duo-sdk-client-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/sdk/impl/DuoSDKClientAdaptor.java
+++ b/idp-duo-sdk-client-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/sdk/impl/DuoSDKClientAdaptor.java
@@ -38,6 +38,7 @@ import com.nimbusds.jose.JWSAlgorithm;
 import com.nimbusds.jwt.JWT;
 import com.nimbusds.jwt.JWTClaimsSet;
 
+import net.shibboleth.idp.authn.duo.AuthenticationRequestOptions;
 import net.shibboleth.idp.plugin.authn.duo.AbstractDuoOIDCClient;
 import net.shibboleth.idp.plugin.authn.duo.DuoClientException;
 import net.shibboleth.idp.plugin.authn.duo.DuoOIDCClient;
@@ -220,6 +221,10 @@ public final class DuoSDKClientAdaptor extends AbstractDuoOIDCClient{
         Constraint.isNotEmpty(username, "Username can not be null or empty");
         Constraint.isNotEmpty(state, "State can not be null or empty");
         //does not support the nonce or redirect_uri override
+        
+        // TODO remove warning when it does.
+        log.debug("Underlying DuoSDK does not support max_age or prompt, do not try to verfiy auth_time");
+
         try {
             final String authUrl = client.createAuthUrl(username, state);
             assert authUrl != null;
@@ -260,7 +265,7 @@ public final class DuoSDKClientAdaptor extends AbstractDuoOIDCClient{
 
     /** Default health check response converter. */
     @ThreadSafe
-    private class DefaultHealthCheckResponseConverter implements Function<HealthCheckResponse,DuoHealthCheck>{
+    private static class DefaultHealthCheckResponseConverter implements Function<HealthCheckResponse,DuoHealthCheck>{
 
         @Override
         public DuoHealthCheck apply(@Nullable final HealthCheckResponse response) {
diff --git a/idp-duo-sdk-client-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/sdk/impl/DuoSDKClientFactory.java b/idp-duo-sdk-client-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/sdk/impl/DuoSDKClientFactory.java
index adbd9435..0f10f813 100644
--- a/idp-duo-sdk-client-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/sdk/impl/DuoSDKClientFactory.java
+++ b/idp-duo-sdk-client-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/sdk/impl/DuoSDKClientFactory.java
@@ -53,8 +53,7 @@ public final class DuoSDKClientFactory extends AbstractInitializableComponent im
      * @param certs the list of certificate pins.
      */
     public synchronized void setCaCerts(@Nullable final List<String> certs) {
-        ifInitializedThrowUnmodifiabledComponentException();
-        ifDestroyedThrowDestroyedComponentException();
+        checkSetterPreconditions();
         //check if null, as the native duo client uses the internal defaults if null
         //but will respect an empty list.
         if (certs != null) {

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


More information about the commits mailing list