[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