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