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

Codeberg noreply at shibboleth.net
Mon Sep 14 16:35:26 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/55dff6de4212d02caafda0f564d6b419013c6f7e

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

commit 55dff6de4212d02caafda0f564d6b419013c6f7e
Author: Phil Smart <philip.smart at jisc.ac.uk>
AuthorDate: Mon Sep 14 17:35:16 2026 +0100

    JDUO-103 - Forward max_age requirement for forced authentication
    
     - Make max_age determination a plugable strategy.
     - Externalise maxAge setting on default integrations
     - Add properties to WebSDK. Although it can not send maxAge right now,
       the properties are still available.
    
    https://shibboleth.atlassian.net/browse/JDUO-103
---
 .../duo/impl/PopulateDuoAuthenticationContext.java | 127 +++++++++++++++------
 .../flows/authn/DuoOIDC/duo-oidc-authn-beans.xml   |  11 +-
 .../duo/nimbus/conf/authn/duo-oidc.properties      |  10 +-
 .../authn/duo/sdk/conf/authn/duo-oidc.properties   |  16 +++
 4 files changed, 123 insertions(+), 41 deletions(-)

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 0276cf89..e9f08b4a 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
@@ -86,6 +86,12 @@ public class PopulateDuoAuthenticationContext extends AbstractAuthenticationActi
     
     /** Strategy used to compute the redirectURI from the given Duo integration if supported.*/
     @Nullable private BiFunction<HttpServletRequest, DynamicDuoOIDCIntegration, String> redirectURICreationStrategy;
+    
+    /** 
+     * A Strategy used to determine the maximum authentication time to be added to the request. Returning
+     * {@code null} will result in no max_age parameter being sent.
+     */
+    @Nonnull private Function<ProfileRequestContext, Duration> maxAuthenticationAgeLookupStrategy;
 
     /** Parameter name for SSO bypass. */
     @Nonnull @NotEmpty private String ssoBypassFieldName;
@@ -102,7 +108,7 @@ public class PopulateDuoAuthenticationContext extends AbstractAuthenticationActi
         usernameLookupStrategy = new CanonicalUsernameLookupStrategy();
         standardDuoIntegrationLookupStrategy = FunctionSupport.constant(null);
         passwordlessDuoIntegrationLookupStrategy = FunctionSupport.constant(null);
-        
+        maxAuthenticationAgeLookupStrategy = new DefaultMaxAuthenticationAgeLookupStrategy();
         ssoBypassFieldName = "donotcache";
     }
     
@@ -207,6 +213,21 @@ public class PopulateDuoAuthenticationContext extends AbstractAuthenticationActi
         }
 
     }
+    
+    /**
+     * Set the strategy used to determine the maximum authentication request to be added to the request. Returning
+     * {@code null} will result in no max_age parameter being sent.
+     * 
+     * @param strategy the strategy to use
+     * 
+     * @since 2.4.0
+     */
+    public void setMaxAuthenticationAgeLookupStrategy(
+    		@Nonnull final Function<ProfileRequestContext, Duration> strategy) {
+    	checkSetterPreconditions();
+		maxAuthenticationAgeLookupStrategy = Constraint.isNotNull(strategy, "MaxAuthenticationAgeLookupStrategy "
+				+ "can not be null");
+	}
 
     /** {@inheritDoc} */
     @Override protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext,
@@ -247,7 +268,7 @@ public class PopulateDuoAuthenticationContext extends AbstractAuthenticationActi
         duoContext.setRequestState(nonce);
         
         // Determine if there are requirements on the max authentication age
-        determineMaxAuthenticationAgeRequirement(authenticationContext, duoContext, duoIntegration);
+        determineMaxAuthenticationAgeRequirement(profileRequestContext, duoContext);
         // Determine if we want to ensure a fresh, interactive user authentication by setting the prompt param.
         determinePromptRequirement(duoContext);
 
@@ -269,42 +290,17 @@ public class PopulateDuoAuthenticationContext extends AbstractAuthenticationActi
     }
     
     /**
-     * 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>
+     * Determines the effective maximum authentication age requirement from the lookup strategy.
      *
-     * @param authenticationContext the current authentication context
-     * @param duoContext the Duo OIDC authentication context
-     * @param duoIntegration the Duo integration configuration
+     * @param profileRequestContext the profile request context to use
     */
-    private void determineMaxAuthenticationAgeRequirement(@Nonnull final AuthenticationContext authenticationContext,
-    		@Nonnull final DuoOIDCAuthenticationContext duoContext, @Nonnull final DuoOIDCIntegration duoIntegration) {
+    private void determineMaxAuthenticationAgeRequirement(@Nonnull final ProfileRequestContext profileRequestContext, 
+    		@Nonnull final DuoOIDCAuthenticationContext duoContext) {
     	
-    	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 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");
+    	final Duration maxAge = maxAuthenticationAgeLookupStrategy.apply(profileRequestContext);
+    	if (maxAge != null) {
+    		log.trace("{} Setting max_age to '{}'", getLogPrefix(), maxAge);
+    		duoContext.setMaxAge(maxAge);
     	}
     	
     }
@@ -319,8 +315,8 @@ public class PopulateDuoAuthenticationContext extends AbstractAuthenticationActi
     private void determinePromptRequirement(@Nonnull final DuoOIDCAuthenticationContext duoContext) {
     	
         final Duration maxAge = duoContext.getMaxAge();
-    	if (maxAge != null && maxAge.isZero()) {
-    		log.trace("{} Maximum authentication age is 0, setting prompt to 'login'");
+    	if (maxAge != null && maxAge.isZero()) {    		
+    		log.trace("{} Maximum authentication age is 0, setting prompt to 'login'", getLogPrefix());
     		duoContext.setPrompt("login");
     	}
     }
@@ -450,4 +446,61 @@ public class PopulateDuoAuthenticationContext extends AbstractAuthenticationActi
         }
     }
     
+    /**
+     * Default strategy that 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>
+     */
+    public static class DefaultMaxAuthenticationAgeLookupStrategy implements Function<ProfileRequestContext, Duration>{
+    	
+    	/** Class logger. */
+        @Nonnull private final Logger log = LoggerFactory.getLogger(DefaultMaxAuthenticationAgeLookupStrategy.class);
+        
+		@Override
+		public Duration apply(final ProfileRequestContext prc) {
+			if (prc == null) {
+				return null;
+			}
+			final AuthenticationContext authCtx = prc.getSubcontext(AuthenticationContext.class);
+			if (authCtx == null) {
+				return null;
+			}
+			final DuoOIDCAuthenticationContext duoCtx = authCtx.getSubcontext(DuoOIDCAuthenticationContext.class);
+			if (duoCtx == null) {
+				return null;
+			}
+			final DuoOIDCIntegration duoIntegration = duoCtx.getIntegration();
+			if (duoIntegration == null) {
+				return null;
+			}
+			
+			if (duoIntegration.getMaxAuthenticationAge() != null) {
+	    		log.trace("Using integration max_age override of {}", duoIntegration.getMaxAuthenticationAge());
+	    		return duoIntegration.getMaxAuthenticationAge();
+	    		
+	    	} else if (authCtx.isForceAuthn()) {
+	    		log.trace("Forced authentication has been requested, setting max_age to '0'");
+	    		return Duration.ZERO;
+	    		
+	    	} else if (authCtx.getMaxAge() != null) {
+	    		log.trace("Using requested max_age from authentication context of '{}'", authCtx.getMaxAge());
+	    		return authCtx.getMaxAge();
+	    	} else {
+	    		log.trace("No max_age requirements");
+	    	}
+			return null;
+		}
+    	
+    }
+    
+    
 }
\ No newline at end of file
diff --git a/idp-duo-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/authn/DuoOIDC/duo-oidc-authn-beans.xml b/idp-duo-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/authn/DuoOIDC/duo-oidc-authn-beans.xml
index ee84ef24..8e4be08a 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,8 @@
         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:}"
+        p:maxAuthenticationAge="%{idp.duo.oidc.maxAge:#{null}}"/>
     <bean id="shibboleth.authn.DuoOIDC.DuoIntegrationStrategy" parent="shibboleth.Functions.Constant"
         c:target-ref="shibboleth.authn.DuoOIDC.DuoIntegration" />
 
@@ -81,7 +82,8 @@
             p:healthCheckEndpoint="%{idp.duo.oidc.passwordless.endpoint.health:%{idp.duo.oidc.endpoint.health:/oauth/v1/health_check}}"
             p:tokenEndpoint="%{idp.duo.oidc.passwordless.endpoint.token:%{idp.duo.oidc.endpoint.token:/oauth/v1/token}}"
             p:authorizeEndpoint="%{idp.duo.oidc.passwordless.endpoint.authorize:%{idp.duo.oidc.endpoint.authorize:/oauth/v1/authorize}}"
-            p:allowedOrigins="%{idp.duo.oidc.passwordless.redirecturl.allowedOrigins:%{idp.duo.oidc.redirecturl.allowedOrigins:}}">
+            p:allowedOrigins="%{idp.duo.oidc.passwordless.redirecturl.allowedOrigins:%{idp.duo.oidc.redirecturl.allowedOrigins:}}"
+            p:maxAuthenticationAge="%{idp.duo.oidc.passwordless.maxAge:#{null}}">
         <property name="allowedFactors">
             <bean parent="shibboleth.CommaDelimStringArray"
                 c:_0="#{'%{idp.duo.oidc.passwordless.allowedFactors:Platform authenticator (2fa)}'.trim()}" />
@@ -187,8 +189,13 @@
         p:passwordlessDuoIntegrationLookupStrategy-ref="shibboleth.authn.DuoOIDC.Passwordless.DuoIntegrationStrategy"
         p:redirectURICreationStrategy-ref="shibboleth.authn.DuoOIDC.RedirectURICreationStrategy"
         p:usernameLookupStrategy-ref="shibboleth.authn.DuoOIDC.UsernameLookupStrategy"
+        p:maxAuthenticationAgeLookupStrategy="#{getObject('shibboleth.authn.DuoOIDC.MaxAgeLookupStrategy') 
+            ?: getObject('shibboleth.authn.DuoOIDC.DefaultMaxAgeLookupStrategy')}"
         p:clientRegistry-ref="shibboleth.authn.DuoOIDC.clientRegistry" />
         
+    <bean id="shibboleth.authn.DuoOIDC.DefaultMaxAgeLookupStrategy" 
+        class="net.shibboleth.idp.plugin.authn.duo.impl.PopulateDuoAuthenticationContext.DefaultMaxAuthenticationAgeLookupStrategy"/>
+        
     <bean id="shibboleth.authn.DuoOIDC.RedirectURICreationStrategy"
         class="net.shibboleth.idp.plugin.authn.duo.impl.DefaultRedirectURICreationStrategy"
         c:callbackPath="#{getObject('shibboleth.authn.DuoOIDC.externalServletPath')}#{T(net.shibboleth.idp.plugin.authn.duo.DuoOIDCAuthAPI).CALLBACK_PATH_SEGMENT}"
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 9af8bf36..bb6e8f85 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
@@ -20,6 +20,8 @@ idp.duo.oidc.clientId = clientid
 idp.duo.oidc.redirectURL = https://<hostname>:<port>/idp/profile/Authn/Duo/2FA/duo-callback
 # We suggest defining this in credentials/secrets.properties
 #idp.duo.oidc.secretKey = key
+# Statically define a maximum authentication age in the request to Duo. 
+#idp.duo.oidc.maxAge = 
 
 ## Enable the Duo health check for every 2FA request. Defaults to true to tightly follow 
 ## the Duo described workflow. However, it is not *strictly* required. 
@@ -48,6 +50,8 @@ idp.duo.oidc.redirectURL = https://<hostname>:<port>/idp/profile/Authn/Duo/2FA/d
 #idp.duo.oidc.passwordless.guardCookieName = __Host-shib_idp_duo_passwordless
 # Override to plug in your own condition bean governing eligibility.
 #idp.duo.oidc.passwordless.guardCondition = (internal default)
+# Statically define a maximum authentication age in the request to Duo. 
+#idp.duo.oidc.passwordles.maxAge =
 
 # Settings for Passwordless cookie management flow
 #idp.duo.oidc.admin.resolveAttributes = false
@@ -87,11 +91,13 @@ idp.duo.oidc.admin.landingPage = https://example.org
 #idp.duo.oidc.jwt.verifier.iatWindow = PT60S
 #idp.duo.oidc.jwt.verifier.issuerPath = /oauth/v1/token 
 #idp.duo.oidc.jwt.verifier.preferredUsername = preferred_username
+
+# Controls for authentication age validation
 # Maximum permitted age of an authentication event. If unset, no validation is performed. If set to zero, the 
-# authentication MUST occur after the authentication request was issued.
+# authentication MUST occur after the authentication request was issued, subject to authnRequestTimeClockSkew.
 #idp.duo.oidc.jwt.verifier.authLifetime = 
 # Maximum permitted age of a reauthentication event when forceAuthn is requested. If unset, no validation is 
-# performed.  If set to zero, the authentication MUST occur after the authentication request was issued.
+# performed.  If set to zero, the authentication MUST occur after the authentication request was issued, subject to authnRequestTimeClockSkew.
 #idp.duo.oidc.jwt.verifier.reauthLifetime =
 # Negative adjustment applied to the authentication request time when validating that authentication occurred after 
 # the request was issued. Only applies if the authLifetime or reauthLifetime is set to a zero duration. Defaults to 
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 39aa0725..9b9ac8ec 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
@@ -20,6 +20,8 @@ idp.duo.oidc.clientId = clientid
 idp.duo.oidc.redirectURL = https://<hostname>:<port>/idp/profile/Authn/Duo/2FA/duo-callback
 # We suggest defining this in credentials/secrets.properties
 #idp.duo.oidc.secretKey = key
+# Statically define a maximum authentication age in the request to Duo. 
+#idp.duo.oidc.maxAge = 
 
 # HTTP Proxy settings for Duo's HTTP client
 #idp.duo.oidc.http.proxy.port = 
@@ -52,6 +54,8 @@ idp.duo.oidc.redirectURL = https://<hostname>:<port>/idp/profile/Authn/Duo/2FA/d
 #idp.duo.oidc.passwordless.guardCookieName = __Host-shib_idp_duo_passwordless
 # Override to plug in your own condition bean governing eligibility.
 #idp.duo.oidc.passwordless.guardCondition = (internal default)
+# Statically define a maximum authentication age in the request to Duo. 
+#idp.duo.oidc.passwordles.maxAge =
 
 # Settings for Passwordless cookie management flow
 #idp.duo.oidc.admin.resolveAttributes = false
@@ -81,6 +85,18 @@ idp.duo.oidc.admin.landingPage = https://example.org
 # Applies only to forced authentication
 #idp.duo.oidc.jwt.verifier.authLifetime = PT60S
 
+# Controls for authentication age validation
+# Maximum permitted age of an authentication event. If unset, no validation is performed. If set to zero, the 
+# authentication MUST occur after the authentication request was issued, subject to authnRequestTimeClockSkew.
+#idp.duo.oidc.jwt.verifier.authLifetime = 
+# Maximum permitted age of a reauthentication event when forceAuthn is requested. If unset, no validation is 
+# performed.  If set to zero, the authentication MUST occur after the authentication request was issued, subject to authnRequestTimeClockSkew.
+#idp.duo.oidc.jwt.verifier.reauthLifetime =
+# Negative adjustment applied to the authentication request time when validating that authentication occurred after 
+# the request was issued. Only applies if the authLifetime or reauthLifetime is set to a zero duration. Defaults to 
+# the value supplied by idp.duo.oidc.jwt.verifier.clockSkew, or PT60S if not set.
+#idp.duo.oidc.jwt.verifier.authnRequestTimeClockSkew = %{idp.duo.oidc.jwt.verifier.clockSkew:PT60S}
+
 ## Write audit entries before the Duo redirect and after response validation
 #idp.duo.oidc.audit.enabled = false
 ## The audit format to use

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


More information about the commits mailing list