[java-idp-plugin-duo] branch main updated: JDUO-102 - Disable authentication_time validation by default

Codeberg noreply at shibboleth.net
Fri Sep 4 14:53:12 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/9d817d620fefb9c14c07c277592652a5666a36bd

The following commit(s) were added to refs/heads/main by this push:
     new 9d817d62 JDUO-102 - Disable authentication_time validation by default
9d817d62 is described below

commit 9d817d620fefb9c14c07c277592652a5666a36bd
Author: Phil Smart <philip.smart at jisc.ac.uk>
AuthorDate: Fri Sep 4 15:53:03 2026 +0100

    JDUO-102 - Disable authentication_time validation by default
    
     - Removed the default activation condition. That will always return
    true now (unless the deployer specified their own, I left that for
    compatibility).
     - Add a new lifetime lookup strategy that determines maximum
    authentication age from two duration properties. One that applies to a
    forced or fresh authentication, the other than applies a normal
    authentication.
     - By default neither lifetime lookup returns a value, so the check will
    be disabled
     - Add authentication request issued time to the Duo Context so we can
    use that on receipt of the id token to validate the auth_time value.
     - Add unit and flow tests
    
    https://shibboleth.atlassian.net/browse/JDUO-102
---
 .../AuthenticationRequestTimeLookupStrategy.java   |  98 ++++++
 .../DuoAuthenticationLifetimeLookupStrategy.java   | 148 ++++++++
 .../duo/context/DuoOIDCAuthenticationContext.java  |  30 +-
 ...uoAuthenticationLifetimeLookupStrategyTest.java | 159 +++++++++
 .../impl/DuoAuthenticationTimeClaimsValidator.java | 224 ++++++++++++
 .../authn/duo/impl/DuoOIDCAuthnController.java     |   4 +
 .../META-INF/net.shibboleth.idp/postconfig.xml     |   7 +
 .../flows/authn/DuoOIDC/duo-oidc-authn-beans.xml   |  21 +-
 .../impl/AbstractAuthnXmlFlowExecutionTests.java   |   2 -
 .../plugin/authn/duo/impl/DuoAuthnFlowTest.java    | 387 ++++++++++++++++++++-
 idp-duo-impl/src/test/resources/logback-test.xml   |   4 +
 .../duo/nimbus/conf/authn/duo-oidc.properties      |  12 +-
 12 files changed, 1082 insertions(+), 14 deletions(-)

diff --git a/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/AuthenticationRequestTimeLookupStrategy.java b/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/AuthenticationRequestTimeLookupStrategy.java
new file mode 100644
index 00000000..6535c91c
--- /dev/null
+++ b/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/AuthenticationRequestTimeLookupStrategy.java
@@ -0,0 +1,98 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.shibboleth.idp.plugin.authn.duo;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.function.Function;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+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.annotation.ParameterName;
+import net.shibboleth.shared.primitive.LoggerFactory;
+
+/**
+ * Strategy used to locate and adjust the authentication request time for a
+ * Duo OIDC authentication transaction.
+ *
+ * <p>The authentication request time is obtained from the
+ * {@link DuoOIDCAuthenticationContext} and adjusted by the configured clock
+ * skew. If the request time or any required context is unavailable, a default
+ * value based on the current time minus the configured clock skew is returned.</p>
+ *
+ */
+public class AuthenticationRequestTimeLookupStrategy implements Function<ProfileRequestContext, Instant> {
+	
+	/** Class logger. */
+    @Nonnull private final Logger log = LoggerFactory.getLogger(AuthenticationRequestTimeLookupStrategy.class);  
+    
+    /** 
+     * Clock skew applied as a negative adjustment to consider when checking returning the authentication request time.  
+     */
+    @Nonnull private final Duration clockSkew;
+    
+    /**
+     * 
+     * Constructor.
+     *
+     * @param skew clockSkew used as a negative adjustment when computing the effective authentication request time
+     */
+    public AuthenticationRequestTimeLookupStrategy(@ParameterName(name="clockSkew") @Nullable final Duration skew){
+    	clockSkew = skew != null ? skew : Duration.ofSeconds(60);
+    }
+
+	@Override
+	public Instant apply(@Nullable final ProfileRequestContext prc) {
+		
+		final Instant defaultRequestTime = Instant.now().minus(clockSkew);
+		
+		if (prc == null) {
+			log.warn("ProfileRequestContext is null, can not determine authentication request time, using "
+					+ "default request time '{}'", defaultRequestTime);
+			return defaultRequestTime;
+		}
+		
+		final AuthenticationContext ac = prc.getSubcontext(AuthenticationContext.class);
+		if (ac == null) {
+			log.warn("AuthenticationContext is null, can not determine authentication request time, using "
+					+ "default request time '{}'", defaultRequestTime);
+			return defaultRequestTime;
+		}
+		
+		final DuoOIDCAuthenticationContext duoContext = ac.getSubcontext(DuoOIDCAuthenticationContext.class);
+		if (duoContext == null) {
+			log.warn("DuoOIDCAuthenticationContext is null, can not determine authentication request time, using "
+					+ "default request time '{}'", defaultRequestTime);
+			return defaultRequestTime;
+		}
+		final Instant authnRequestTime = duoContext.getAuthnRequestTime();
+		if (authnRequestTime == null) {
+			log.warn("Authentication request time was not set prior to authentication redirect, using default "
+					+ "request time '{}'", defaultRequestTime);
+			return defaultRequestTime;
+		}
+		final Instant authnRequestTimeComputed = authnRequestTime.minus(clockSkew); 
+		log.trace("Authentication request was made on '{}', and will be considered, based on clockSkew, as '{}'", 
+				authnRequestTime, authnRequestTimeComputed);
+		return authnRequestTimeComputed;
+	}
+
+}
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
new file mode 100644
index 00000000..ffcbf7ec
--- /dev/null
+++ b/idp-duo-api/src/main/java/net/shibboleth/idp/plugin/authn/duo/DuoAuthenticationLifetimeLookupStrategy.java
@@ -0,0 +1,148 @@
+
+package net.shibboleth.idp.plugin.authn.duo;
+
+import java.time.Duration;
+import java.util.function.Function;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.slf4j.Logger;
+
+import net.shibboleth.idp.authn.context.AuthenticationContext;
+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>
+ */
+public class DuoAuthenticationLifetimeLookupStrategy extends AbstractIdentifiableInitializableComponent 
+		implements Function<ProfileRequestContext, Duration> {
+	
+	/** Class logger. */
+    @Nonnull private final Logger log = LoggerFactory.getLogger(DuoAuthenticationLifetimeLookupStrategy.class);
+
+	/**
+	 * Lookup strategy to find the amount of time for which the auth_time inside a
+	 * token is valid for.
+	 */
+	@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.
+	 * 
+	 */
+	@Nonnull
+	private Function<ProfileRequestContext, Duration> reauthnLifetimeLookupStrategy;
+	
+	/** Constructor.*/
+	public DuoAuthenticationLifetimeLookupStrategy() {
+		authnLifetimeLookupStrategy = FunctionSupport.constant(null);
+		reauthnLifetimeLookupStrategy = FunctionSupport.constant(null);
+	}
+	
+    /**
+     * 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. 
+     * 
+     * @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.
+     */
+    public void setReauthnLifetime(@Nullable final Duration lifetime) {
+        checkSetterPreconditions();
+        
+        reauthnLifetimeLookupStrategy = FunctionSupport.constant(lifetime);
+    }
+    
+    /**
+     * 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.
+     * 
+     * @param strategy the strategy. Can return {@code null} if a lifetime should not be enforced.
+     */
+    public void setReauthnLifetimeLookupStrategy(@Nonnull final Function<ProfileRequestContext, Duration> strategy) {
+        checkSetterPreconditions();
+
+        reauthnLifetimeLookupStrategy = Constraint.isNotNull(strategy,
+                "ReauthnLifetime Lookup Strategy can not be null");
+    }
+    
+    /**
+     * 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. 
+     * 
+     * @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.
+     */
+    public void setAuthnLifetime(@Nullable final Duration lifetime) {
+        checkSetterPreconditions();
+        
+        authnLifetimeLookupStrategy = FunctionSupport.constant(lifetime);
+    }
+    
+    /**
+     * 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. 
+     * 
+     * @param strategy the strategy. Can return {@code null} if a lifetime should not be enforced.
+     */
+    public void setAuthnLifetimeLookupStrategy(@Nonnull final Function<ProfileRequestContext, Duration> strategy) {
+        checkSetterPreconditions();
+
+        authnLifetimeLookupStrategy = Constraint.isNotNull(strategy,
+                "AuthnLifetime Lookup Strategy can not be null");
+    }
+
+
+	@Override
+	public Duration apply(@Nullable final ProfileRequestContext prc) {
+		checkComponentActive();
+		if (prc == null) {
+			return null;
+		}
+
+		final AuthenticationContext authnContext = prc.getSubcontext(AuthenticationContext.class);        
+        if (authnContext == null) {
+            return null;
+        }
+        
+        if (authnContext.isForceAuthn()) {        	
+        	final Duration lifetime = reauthnLifetimeLookupStrategy.apply(prc);
+        	log.trace("Fresh authentication lifetime: {}",
+        			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);
+
+        	return lifetime;
+        }
+	}
+
+}
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 f803be32..e8f96b66 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,8 @@
 
 package net.shibboleth.idp.plugin.authn.duo.context;
 
+import java.time.Instant;
+
 import javax.annotation.Nonnull;
 import javax.annotation.Nullable;
 import javax.annotation.concurrent.NotThreadSafe;
@@ -75,6 +77,9 @@ public final class DuoOIDCAuthenticationContext extends BaseContext {
      * e.g. useful if one IdP instance is fronted by different virtual hosts.
      */
     @Nullable private String redirectURIOverride;
+    
+    /** The time at which the IdP made the authentication request to Duo.*/
+    @Nullable private Instant authnRequestTime;
 
     /** Public no-arg constructor to allow auto-creation. */
     public DuoOIDCAuthenticationContext() {
@@ -320,5 +325,28 @@ public final class DuoOIDCAuthenticationContext extends BaseContext {
     @Nullable public DuoOIDCIntegration getIntegration() {
         return integration;
     }
-
+    
+    /**
+     * Set the time at which the IdP sent this authentication request to Duo.
+     * 
+     * @param time the time the request was made
+     * 
+     * @since 2.3.1
+     */
+    @Nonnull public DuoOIDCAuthenticationContext setAuthnRequestTime(@Nullable final Instant time) {
+    	authnRequestTime = time;
+    	return this;
+    }
+    
+    /**
+     * Get the time at which this RP sent this authentication request to the OP.
+     * 
+     * @return the time the request was made
+     * 
+     * @since 2.3.1
+     */
+    @Nullable public Instant getAuthnRequestTime() {
+        return authnRequestTime;
+    }
+    
 }
\ 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
new file mode 100644
index 00000000..230965b2
--- /dev/null
+++ b/idp-duo-api/src/test/java/net/shibboleth/idp/plugin/authn/duo/DuoAuthenticationLifetimeLookupStrategyTest.java
@@ -0,0 +1,159 @@
+
+package net.shibboleth.idp.plugin.authn.duo;
+
+import java.time.Duration;
+
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.testng.Assert;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+import net.shibboleth.idp.authn.context.AuthenticationContext;
+import net.shibboleth.shared.component.ComponentInitializationException;
+
+/**
+ * Tests for the {@link DuoAuthenticationLifetimeLookupStrategy}.
+ */
+public class DuoAuthenticationLifetimeLookupStrategyTest {
+
+	private DuoAuthenticationLifetimeLookupStrategy strategy;
+
+	private ProfileRequestContext prc;
+
+	private AuthenticationContext authnContext;
+
+	@BeforeMethod
+	public void setup() {
+		strategy = new DuoAuthenticationLifetimeLookupStrategy();
+		strategy.setId("strategy");
+
+		prc = new ProfileRequestContext();
+		authnContext = new AuthenticationContext();
+		prc.addSubcontext(authnContext);
+	}
+
+	@Test
+	public void testNullProfileRequestContext() throws ComponentInitializationException {
+		strategy.initialize();
+
+		Assert.assertNull(strategy.apply(null));
+	}
+
+	@Test
+	public void testNullAuthenticationContext() throws ComponentInitializationException {
+		strategy.initialize();
+
+		prc.removeSubcontext(AuthenticationContext.class);
+
+		Assert.assertNull(strategy.apply(prc));
+	}
+
+	@Test
+	public void testDefaultAuthnLifetimeNotEnforced() throws ComponentInitializationException {
+
+		strategy.initialize();
+		Assert.assertNull(strategy.apply(prc));
+	}
+
+	@Test
+	public void testAuthnLifetime() throws ComponentInitializationException {
+
+		final Duration expected = Duration.ofMinutes(5);	
+		strategy.setAuthnLifetime(expected);
+		strategy.initialize();
+	
+		Assert.assertEquals(strategy.apply(prc), expected);
+	}
+	
+	@Test
+	public void testAuthnLifetime_Null() throws ComponentInitializationException {
+
+		final Duration expected = null;	
+		strategy.setAuthnLifetime(expected);
+		strategy.initialize();
+	
+		Assert.assertEquals(strategy.apply(prc), expected);
+	}
+
+	@Test
+	public void testReauthnLifetime() throws ComponentInitializationException {
+
+		final Duration expected = Duration.ofSeconds(30);
+		
+		strategy.setReauthnLifetime(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);
+		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);
+		strategy.initialize();
+	
+		authnContext.setForceAuthn(false);
+	
+		Assert.assertEquals(strategy.apply(prc), expected);
+	}
+	
+	@Test
+	public void testAuthnStrategyUsedForNonForceAuthn_Null() throws ComponentInitializationException {
+		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 {
+
+		final Duration expected = Duration.ofMinutes(1);
+	
+		strategy.setReauthnLifetimeLookupStrategy(prfctx -> 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;
+	
+		strategy.setReauthnLifetimeLookupStrategy(prfctx -> expected);
+		strategy.initialize();
+
+		authnContext.setForceAuthn(true);
+	
+		prc.addSubcontext(authnContext);
+	
+		Assert.assertEquals(strategy.apply(prc), expected);
+	}
+
+
+}
diff --git a/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/impl/DuoAuthenticationTimeClaimsValidator.java b/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/impl/DuoAuthenticationTimeClaimsValidator.java
new file mode 100644
index 00000000..bfd0df9a
--- /dev/null
+++ b/idp-duo-impl/src/main/java/net/shibboleth/idp/plugin/authn/duo/impl/DuoAuthenticationTimeClaimsValidator.java
@@ -0,0 +1,224 @@
+
+package net.shibboleth.idp.plugin.authn.duo.impl;
+
+import java.text.ParseException;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Date;
+import java.util.function.Function;
+
+import javax.annotation.Nonnull;
+
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.nimbusds.jwt.JWTClaimsSet;
+
+import net.shibboleth.oidc.jwt.claims.AbstractClaimsValidator;
+import net.shibboleth.oidc.jwt.claims.JWTValidationException;
+import net.shibboleth.oidc.security.jwt.claims.impl.AuthenticationTimeClaimsValidator;
+import net.shibboleth.oidc.security.jwt.claims.impl.IDTokenClaims;
+import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.logic.FunctionSupport;
+
+/**
+ * A Duo specific version of the {@link AuthenticationTimeClaimsValidator} that performs the same logic but 
+ * does <b>not</b> throw an error if the {@link #setAuthnLifetimeLookupStrategy(java.util.function.Function)} 
+ * returns {@code null}, instead it ignores the check.
+ * 
+ *
+* <p>The logic defined by this validator depends on the result of the authnLifetimeLookupStrategy. That is:</p>
+* <ul>
+* 	<li>
+* 	A {@code null} lifetime indicates that authentication time validation
+* 	should not be enforced.
+* 	</li>
+* 	<li>
+* 	A lifetime of {@code Duration.ZERO} indicates that fresh authentication
+* 	is required. In this case, the {@code auth_time} value must be later than
+* 	the time at which the authentication request was made.
+* 	</li>
+* 	<li>
+* 	A positive lifetime indicates the maximum permitted age of the
+* 	authentication. In this case, the {@code auth_time} value must fall
+* 	within the specified lifetime window, subject to any configured clock
+* 	skew allowance.
+* 	</li>
+* </ul>
+* 
+* TODO: remove the Duo specific version if oidc-common supports null authnLifetimeLookupStrategy outcomes
+ */
+public class DuoAuthenticationTimeClaimsValidator extends AbstractClaimsValidator {
+	
+	/** Class logger. */
+    @Nonnull private final Logger log = LoggerFactory.getLogger(DuoAuthenticationTimeClaimsValidator.class);
+    
+    /** 
+     * Lookup strategy to find the amount of time for which a token is valid 
+     * after if it was first issued. (Default value: 60 seconds) 
+     */
+    @Nonnull private Function<ProfileRequestContext, Duration> authnLifetimeLookupStrategy;
+    
+    /** 
+     * Lookup strategy to find the time at which the authentication request was made.
+     * Defaults to now minus the clockskew.
+     */
+    @Nonnull private Function<ProfileRequestContext, Instant> authnRequestTimeLookupStrategy;
+    
+    /** 
+     * Positive clock skew adjustment to consider when checking auth_time is not in the future 
+     * or has expired. (Default value: 60 seconds). 
+     */
+    @Nonnull private Duration clockSkew;
+    
+    /** Constructor.*/
+    public DuoAuthenticationTimeClaimsValidator() {
+        authnLifetimeLookupStrategy = prc ->  Duration.ofSeconds(60);
+        clockSkew = Duration.ofSeconds(60);
+        authnRequestTimeLookupStrategy = prc -> Instant.now().minus(clockSkew);
+    }
+    
+    /**
+     * Set the clock skew.
+     * 
+     * @param skew clock skew to set
+     */
+	public void setClockSkew(@Nonnull final Duration skew) {  
+        checkSetterPreconditions();
+        
+        clockSkew = Constraint.isNotNull(skew, "Clock skew cannot be null");
+    }
+    
+    /**
+     * Set the lookup strategy to find out when the authentication request (if any) was made.
+     * 
+     * @param strategy the strategy
+     */
+	public void setAuthnRequestTimeLookupStrategy(
+            final Function<ProfileRequestContext, Instant> strategy) {
+        ifInitializedThrowUnmodifiabledComponentException();
+        ifDestroyedThrowDestroyedComponentException();
+
+        authnRequestTimeLookupStrategy = Constraint.isNotNull(strategy,
+                "authnRequestTimeLookupStrategy can not be null");
+    }
+
+    
+    /**
+     * Sets the amount of time for which a token is valid from when the original authentication took place.
+     * 
+     * @param lifetime amount of time for which a token is valid
+     */
+	public void setAuthnLifetime(@Nonnull final Duration lifetime) {
+        ifInitializedThrowUnmodifiabledComponentException();
+        
+        Constraint.isNotNull(lifetime, "Token authentication lifetime cannot be null");
+        Constraint.isFalse(lifetime.isNegative(), "Token authentication lifetime cannot be negative");
+        
+        authnLifetimeLookupStrategy = FunctionSupport.constant(lifetime);
+    }
+    
+    /**
+     * Set the lookup strategy used to locate the amount of time for which a token is valid from when the original 
+     * authentication took place.
+     * 
+     * @param strategy the strategy
+     * 
+     * @since 2.2.0
+     */
+	public void setAuthnLifetimeLookupStrategy(@Nonnull final Function<ProfileRequestContext, Duration> strategy) {
+        ifInitializedThrowUnmodifiabledComponentException();
+        ifDestroyedThrowDestroyedComponentException();
+
+        authnLifetimeLookupStrategy = Constraint.isNotNull(strategy,
+                "AuthnLifetime Lookup Strategy can not be null");
+    }
+	
+	// Checkstyle: CyclomaticComplexity|MethodLength OFF
+    @Override
+    public void doValidate(@Nonnull final JWTClaimsSet claimsSet, @Nonnull final ProfileRequestContext context) 
+            throws JWTValidationException {
+       
+            
+        try { 
+            final Duration authnLifetime = authnLifetimeLookupStrategy.apply(context);
+            // Perform this check before we throw any failures. 
+            if (authnLifetime == null) {
+                log.trace("{}: Authentication lifetime lookup was disabled, no value was set", getId());
+                return;
+            }            
+            
+            final Date authTimeDate = claimsSet.getDateClaim(IDTokenClaims.AUTHENTICATION_TIME.getClaimName());         
+            if (authTimeDate == null) {
+                throw new JWTValidationException("No authentication time found in token");
+            }            
+            
+            if (authnLifetime.equals(Duration.ofSeconds(0))) {
+                // Now assume forced authentication semantics, the authentication must have happened after the 
+                // authentication request was made to the OP
+                if (authnRequestTimeLookupStrategy == null) {
+                    log.warn("""
+						Maximum authentication age of 0 seconds requested, but no \
+						authentication request time lookup strategy set, can not check for a fresh \
+						authentication""");
+                    throw new JWTValidationException("""
+						Maximum authentication age of 0 seconds requested, but no \
+						authentication request time lookup strategy set, can not check for a fresh \
+						authentication""");
+                }
+                final Instant authnRequestTime = authnRequestTimeLookupStrategy.apply(context);
+                
+                if (authnRequestTime == null) {
+                    log.warn("""
+						Maximum authentication age of 0 seconds requested, but no \
+						authentication request time could be found, can not check for a fresh \
+						authentication""");
+                    throw new JWTValidationException("""
+						Maximum authentication age of 0 seconds requested, but no \
+						authentication request time could be found, can not check for a fresh \
+						authentication""");
+                }
+                
+                // Check authTime is after the time which the authentication request was made
+                if (authTimeDate.toInstant().isBefore(authnRequestTime)) {
+                    log.warn("JWT token authentication time is not valid. Authentication is not fresh but max_age=0"
+                            + " was requested, re-authentication did not occur");
+                    throw new JWTValidationException("JWT token authentication time is not valid. Authentication "
+                            + "is not fresh but max_age=0 was requested, re-authentication did not occur");
+                }
+                
+            } else {             
+                final Instant authTime = authTimeDate.toInstant();
+                final Instant now = Instant.now();
+                final Instant latestValid = now.plus(clockSkew);
+                final Instant expiration = authTime.plus(clockSkew).plus(authnLifetime);
+                
+                // Check time of authentication wasn't in the future
+                if (authTime.isAfter(latestValid)) {
+                    log.warn("Authentication is not yet valid: auth_time was {}, latest valid is: {}",
+                            authTime, latestValid);
+                    throw new JWTValidationException("JWT token authentication time is not yet valid");
+                }
+
+                // Check time of authentication has not expired
+                if (expiration.isBefore(now)) {
+                    log.warn(
+                            "Authentication has expired: auth_time was '{}', "
+                            + "expired at: '{}', current time: '{}'",
+                            authTime, expiration, now);
+                    throw new JWTValidationException("JWT token authentication time has expired");
+                }
+                //is OK.
+            }
+            
+        } catch (final ParseException e) {
+            throw new JWTValidationException("Autentication forced, but no authentication time found in token",e);
+        }
+        
+        
+    }
+
+ // Checkstyle: CyclomaticComplexity|MethodLength ON
+
+}
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 dd83b2a4..e94529f4 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
@@ -15,6 +15,7 @@
 package net.shibboleth.idp.plugin.authn.duo.impl;
 
 import java.io.IOException;
+import java.time.Instant;
 import java.util.function.Function;
 
 import javax.annotation.Nonnull;
@@ -160,6 +161,9 @@ public class DuoOIDCAuthnController extends AbstractInitializableComponent{
                 authURL = client.createAuthUrl(username, state, null, 
                         duoContext.getRedirectURIOverride());
             }
+            
+            // Set the time at which we are making this authentication request
+            duoContext.setAuthnRequestTime(Instant.now());
 
             httpResponse.sendRedirect(authURL);
 
diff --git a/idp-duo-impl/src/main/resources/META-INF/net.shibboleth.idp/postconfig.xml b/idp-duo-impl/src/main/resources/META-INF/net.shibboleth.idp/postconfig.xml
index 3788d19a..2b5fa3de 100644
--- a/idp-duo-impl/src/main/resources/META-INF/net.shibboleth.idp/postconfig.xml
+++ b/idp-duo-impl/src/main/resources/META-INF/net.shibboleth.idp/postconfig.xml
@@ -102,4 +102,11 @@
     <!-- Abstract beans to isolate impl classes from user config  -->
     <bean id="shibboleth.authn.DuoOIDC.AuthnMethodReferencePrincipalMappingStrategy" abstract="true"
         class="net.shibboleth.idp.plugin.authn.duo.impl.AuthnMethodReferenceToPrincipalMappingStrategy"/>
+    
+    <!-- TODO not needed from IdP > 5.0 onward, these exist in the IdP -->
+    <bean id="shibboleth.BiConditions.FALSE"
+       parent="shibboleth.BiConditions.Expression" c:expression="false"/>
+       
+    <bean id="shibboleth.BiConditions.TRUE"
+       parent="shibboleth.BiConditions.Expression" c:expression="true"/>
 </beans>
\ 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 3a6b2107..f218613f 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
@@ -256,11 +256,14 @@
                                 getObject('shibboleth.authn.DuoOIDC.jwt.DefaultUsernameLookupStrategy')}"/>
               <!-- is always returned by Duo, so no need to evaluate if it was requested -->
              <bean id="authenticationTimeClaimValidator" 
-                class="net.shibboleth.oidc.security.jwt.claims.impl.AuthenticationTimeClaimsValidator"
-                p:authnLifetime="%{idp.duo.oidc.jwt.verifier.authLifetime:PT60S}"
+                class="net.shibboleth.idp.plugin.authn.duo.impl.DuoAuthenticationTimeClaimsValidator"
                 p:clockSkew="%{idp.duo.oidc.jwt.verifier.clockSkew:PT60S}"
+                p:authnLifetimeLookupStrategy="#{getObject('shibboleth.authn.DuoOIDC.jwt.AuthenticationLifetimeLookupStrategy') ?: 
+                                getObject('shibboleth.authn.DuoOIDC.jwt.DefaultAuthenticationLifetimeLookupStrategy')}"
                 p:activationCondition="#{getObject('shibboleth.authn.DuoOIDC.jwt.AuthTimeActivationCondition') ?: 
-                                getObject('shibboleth.authn.DuoOIDC.jwt.DefaultAuthTimeActivationCondition')}"/>
+                                getObject('shibboleth.BiConditions.TRUE')}"
+                p:authnRequestTimeLookupStrategy="#{getObject('shibboleth.authn.DuoOIDC.jwt.AuthenticationRequestTimeLookupStrategy') ?: 
+                                getObject('shibboleth.authn.DuoOIDC.jwt.DefaultAuthenticationRequestTimeLookupStrategy')}"/>
              <bean id="nonceClaimValidator" 
                 class="net.shibboleth.oidc.security.jwt.claims.impl.ExactMatchClaimsValidator"
                 p:claimName="nonce"
@@ -273,10 +276,16 @@
                 p:validator="#{getObject('shibboleth.authn.DuoOIDC.ExtendedClaimsValidator')}"/>
             </list>
         </property>    
-    </bean> 
+    </bean>
+    
+    <bean id="shibboleth.authn.DuoOIDC.jwt.DefaultAuthenticationRequestTimeLookupStrategy" 
+        class="net.shibboleth.idp.plugin.authn.duo.AuthenticationRequestTimeLookupStrategy"
+        c:clockSkew="%{idp.duo.oidc.jwt.verifier.authnRequesTimeClockSkew:%{idp.duo.oidc.jwt.verifier.clockSkew:PT60S}}"/>
     
-    <bean id="shibboleth.authn.DuoOIDC.jwt.DefaultAuthTimeActivationCondition"
-        class="net.shibboleth.oidc.security.jwt.claims.impl.ForcedAuthenticationActivationCondition"/> 
+    <bean id="shibboleth.authn.DuoOIDC.jwt.DefaultAuthenticationLifetimeLookupStrategy"
+        class="net.shibboleth.idp.plugin.authn.duo.DuoAuthenticationLifetimeLookupStrategy"
+        p:authnLifetime="%{idp.duo.oidc.jwt.verifier.authLifetime:#{null}}"
+        p:reauthnLifetime="%{idp.duo.oidc.jwt.verifier.reauthLifetime:#{null}}"/>
     
     <bean id="shibboleth.authn.DuoOIDC.jwt.DefaultNonceActivationCondition"
         class="net.shibboleth.oidc.security.jwt.claims.impl.NonceValidationActivationCondition"/> 
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 33d8c2e3..672a94bc 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
@@ -38,7 +38,6 @@ import org.apache.hc.core5.http.HttpHost;
 import org.apache.hc.core5.ssl.SSLContexts;
 import org.mockito.Mockito;
 import org.opensaml.profile.context.ProfileRequestContext;
-import org.opensaml.storage.impl.MemoryStorageService;
 import org.slf4j.Logger;
 import org.springframework.beans.factory.BeanFactory;
 import org.springframework.beans.factory.config.BeanDefinition;
@@ -74,7 +73,6 @@ import com.google.common.net.HttpHeaders;
 import net.shibboleth.idp.authn.AuthenticationFlowDescriptor;
 import net.shibboleth.idp.authn.context.AuthenticationContext;
 import net.shibboleth.idp.authn.context.SubjectCanonicalizationContext;
-import net.shibboleth.idp.authn.impl.StorageBackedAccountLockoutManager;
 import net.shibboleth.idp.plugin.authn.duo.DuoOIDCClientFactory;
 import net.shibboleth.idp.plugin.authn.spring.CustomAbstractXmlFlowExecutionTests;
 import net.shibboleth.idp.plugin.authn.spring.CustomFlowModelFlowBuilder;
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 8c60bd12..f0df185b 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.Instant;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
@@ -420,6 +421,7 @@ public class DuoAuthnFlowTest extends AbstractAuthnXmlFlowExecutionTests {
 
     }
     
+   
     /** 
      * Test for https://issues.shibboleth.net/jira/browse/JDUO-47. A mapping strategy should
      * correctly add the ACR to the set of principals.
@@ -459,6 +461,7 @@ public class DuoAuthnFlowTest extends AbstractAuthnXmlFlowExecutionTests {
         duoContext.setRequestState(nonce);
         duoContext.setResponseState(nonce);
         duoContext.setUsername("jdoe");
+        duoContext.setAuthnRequestTime(Instant.now());
         
         final DefaultDuoOIDCIntegration integ = new DefaultDuoOIDCIntegration();
         integ.setClientId("DIU6GEFWG5LIUBVV2M3P");
@@ -500,7 +503,6 @@ public class DuoAuthnFlowTest extends AbstractAuthnXmlFlowExecutionTests {
     }
     
       
-   
     /** 
      * 
      * Test the Duo flow from the external authorization request to the end of the flow.
@@ -539,6 +541,7 @@ public class DuoAuthnFlowTest extends AbstractAuthnXmlFlowExecutionTests {
         duoContext.setRequestState(nonce);
         duoContext.setResponseState(nonce);
         duoContext.setUsername("jdoe");
+        duoContext.setAuthnRequestTime(Instant.now());
         
         final DefaultDuoOIDCIntegration integ = new DefaultDuoOIDCIntegration();
         integ.setClientId("DIU6GEFWG5LIUBVV2M3P");
@@ -620,6 +623,7 @@ public class DuoAuthnFlowTest extends AbstractAuthnXmlFlowExecutionTests {
         duoContext.setRequestState(nonce);
         duoContext.setResponseState(nonce);
         duoContext.setUsername("jdoe");
+        duoContext.setAuthnRequestTime(Instant.now());
         
         final DefaultDuoOIDCIntegration integ = new DefaultDuoOIDCIntegration();
         integ.setClientId("DIU6GEFWG5LIUBVV2M3P");
@@ -677,7 +681,8 @@ 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).
+     * 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
@@ -699,7 +704,8 @@ 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");
+                "idp.duo.oidc.secretKey","rFvDfPul27v3Wew2zb6xRPzAJewJ34MP2w8UitPh",
+                "idp.duo.oidc.jwt.verifier.reauthLifetime", "PT1S");
         
         setMockProperties(mockProperties);
 
@@ -714,6 +720,7 @@ public class DuoAuthnFlowTest extends AbstractAuthnXmlFlowExecutionTests {
         duoContext.setRequestState(nonce);
         duoContext.setResponseState(nonce);
         duoContext.setUsername("jdoe");
+        duoContext.setAuthnRequestTime(Instant.now());
         
         final DefaultDuoOIDCIntegration integ = new DefaultDuoOIDCIntegration();
         integ.setClientId("DIU6GEFWG5LIUBVV2M3P");
@@ -749,4 +756,378 @@ 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.authLifetime is null. So we set an old auth_time on the response, to
+     * test it.
+     * 
+     * @throws DuoClientException on error.
+     * @throws ComponentInitializationException on error
+     */
+    @Test
+    public void testNoForceAuthnNoAuthenticationTimeValidation() 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","#{null}");
+        
+        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);
+        
+        //add the mock client that sets a very old auth_time to check no validation occurs
+        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.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 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.
+     * 
+     * @throws DuoClientException on error.
+     * @throws ComponentInitializationException on error
+     */
+    @Test
+    public void testForceAuthnNoReauthenticationTimeValidation() 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.reauthLifetime","#{null}");
+        
+        setMockProperties(mockProperties);
+
+        final FlowExecutionImpl flowExecution = (FlowExecutionImpl)getFlowExecutionFactory()
+                .createFlowExecution(getFlowDefinition());
+        //true so forced authn, needed for this test
+        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 that sets a very old auth_time to check no validation occurs
+        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.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 if the idp.duo.oidc.jwt.verifier.reauthLifetime is 1 second. So we set an old auth_time on the
+     * response, to test it.
+     * 
+     * @throws DuoClientException on error.
+     * @throws ComponentInitializationException on error
+     */
+    @Test
+    public void testForceAuthnReauthenticationTimeValidation() 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.reauthLifetime","PT1S");
+        
+        setMockProperties(mockProperties);
+
+        final FlowExecutionImpl flowExecution = (FlowExecutionImpl)getFlowExecutionFactory()
+                .createFlowExecution(getFlowDefinition());
+        //true so forced authn, needed for this test
+        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 that sets a very old auth_time to check no validation occurs
+        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 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.  
+     * 
+     * @throws DuoClientException on error.
+     * @throws ComponentInitializationException on error
+     */
+    @Test
+    public void testForceAuthnImmediateReauthenticationTimeValidation() 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.reauthLifetime","PT0S",
+                "idp.duo.oidc.jwt.verifier.clockSkew", "PT60S",
+                "idp.duo.oidc.jwt.verifier.authnRequesTimeClockSkew","PT5S");
+        
+        setMockProperties(mockProperties);
+
+        final FlowExecutionImpl flowExecution = (FlowExecutionImpl)getFlowExecutionFactory()
+                .createFlowExecution(getFlowDefinition());
+        //true so forced authn, needed for this test
+        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 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);
+        
+        //add the mock client that sets a very old auth_time to check no validation occurs
+        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());
+        
+    }
+    
+   
 }
\ No newline at end of file
diff --git a/idp-duo-impl/src/test/resources/logback-test.xml b/idp-duo-impl/src/test/resources/logback-test.xml
index 365c416c..546de72b 100644
--- a/idp-duo-impl/src/test/resources/logback-test.xml
+++ b/idp-duo-impl/src/test/resources/logback-test.xml
@@ -14,6 +14,10 @@
         <appender-ref ref="STDOUT" />
     </logger>
     
+    <logger name="net.shibboleth.oidc" level="TRACE" additivity="false">
+        <appender-ref ref="STDOUT" />
+    </logger>
+    
      <logger name="net.shibboleth.idp.plugin.authn.duo" level="TRACE" additivity="false">
         <appender-ref ref="STDOUT" />
     </logger>
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 baf7949e..6e51cdca 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
@@ -87,8 +87,16 @@ 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
-# Applies only to forced authentication
-#idp.duo.oidc.jwt.verifier.authLifetime = PT60S
+# 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.
+#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.
+#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.authnRequesTimeClockSkew = %{idp.duo.oidc.jwt.verifier.clockSkew:PT60S}
 
 # Write audit entries before the Duo redirect and after response validation
 #idp.duo.oidc.audit.enabled = false

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


More information about the commits mailing list