[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