[java-identity-provider] branch main updated: IDP-2351 - Allow per-AuthenticationResult lifetime/timeout policies

Scott Cantor cantor.2 at osu.edu
Thu Mar 6 20:24:46 UTC 2025


This is an automated email from the git hooks/post-receive script.

scantor pushed a commit to branch main
in repository java-identity-provider.

View the commit online:
http://git.shibboleth.net/view/?p=java-identity-provider.git;a=commit;h=4a105c40f3571761664f8aa79c21021b60d20ac6

The following commit(s) were added to refs/heads/main by this push:
     new 4a105c40f IDP-2351 - Allow per-AuthenticationResult lifetime/timeout policies
4a105c40f is described below

commit 4a105c40f3571761664f8aa79c21021b60d20ac6
Author: Scott Cantor <cantor.2 at osu.edu>
AuthorDate: Thu Mar 6 15:24:38 2025 -0500

    IDP-2351 - Allow per-AuthenticationResult lifetime/timeout policies
    
    https://shibboleth.atlassian.net/browse/IDP-2351
    
    Implement the basic machinery to allow for this.
---
 .../idp/authn/AuthenticationFlowDescriptor.java    | 42 +++++++---
 .../shibboleth/idp/authn/AuthenticationResult.java | 90 ++++++++++++++++++++++
 .../DefaultAuthenticationResultSerializer.java     | 26 +++++++
 .../DefaultAuthenticationResultSerializerTest.java | 37 +++++++++
 .../simpleAuthenticationResultWithOptional.json    |  1 +
 .../idp/session/impl/StorageBackedIdPSession.java  |  8 +-
 6 files changed, 191 insertions(+), 13 deletions(-)

diff --git a/idp-authn-api/src/main/java/net/shibboleth/idp/authn/AuthenticationFlowDescriptor.java b/idp-authn-api/src/main/java/net/shibboleth/idp/authn/AuthenticationFlowDescriptor.java
index f005edf2f..62c6ec7a8 100644
--- a/idp-authn-api/src/main/java/net/shibboleth/idp/authn/AuthenticationFlowDescriptor.java
+++ b/idp-authn-api/src/main/java/net/shibboleth/idp/authn/AuthenticationFlowDescriptor.java
@@ -370,8 +370,10 @@ public class AuthenticationFlowDescriptor extends AbstractIdentifiableInitializa
     }    
 
     /**
-     * Get the maximum amount of time, since first usage, a flow should be considered active. A null
-     * indicates that there is no upper limit on the lifetime on an active flow.
+     * Get the maximum amount of time, since first usage, a flow result should be considered active, if not
+     * overridden.
+     * 
+     * <p>A null indicates that there is no upper limit on the lifetime.</p>
      * 
      * @return maximum amount of time a flow should be considered active
      */
@@ -380,8 +382,10 @@ public class AuthenticationFlowDescriptor extends AbstractIdentifiableInitializa
     }
 
     /**
-     * Set the maximum amount of time, since first usage, a flow should be considered active. A null value
-     * indicates that there is no upper limit on the lifetime on an active flow.
+     * Set the maximum amount of time, since first usage, a flow result should be considered active, if not
+     * overridden.
+     * 
+     * <p>A null value indicates that there is no upper limit on the lifetime.</p>
      * 
      * @param flowLifetime the lifetime for the flow
      */
@@ -394,7 +398,8 @@ public class AuthenticationFlowDescriptor extends AbstractIdentifiableInitializa
     }
 
     /**
-     * Get the maximum amount of time, since the last usage, a flow should be considered active.
+     * Get the maximum amount of time, since the last usage, a flow result should be considered active,
+     * if not overridden.
      * 
      * <p>
      * Defaults to 30 minutes.
@@ -407,7 +412,8 @@ public class AuthenticationFlowDescriptor extends AbstractIdentifiableInitializa
     }
 
     /**
-     * Set the maximum amount of time, since the last usage, a flow should be considered active.
+     * Set the maximum amount of time, since the last usage, a flow result should be considered active,
+     * if not overridden.
      * 
      * @param timeout the flow inactivity timeout, must be greater than zero
      */
@@ -432,9 +438,15 @@ public class AuthenticationFlowDescriptor extends AbstractIdentifiableInitializa
                 "AuthenticationResult was not produced by this flow");
 
         final Instant now = Instant.now();
-        if (getLifetime() != null && now.isAfter(result.getAuthenticationInstant().plus(getLifetime()))) {
+
+        // Lifetime comes from result, falling back to flow default.
+        final Duration effectiveLifetime = result.getResultLifetime(this);
+        if (effectiveLifetime != null && now.isAfter(result.getAuthenticationInstant().plus(effectiveLifetime))) {
             return false;
-        } else if (now.isAfter(result.getLastActivityInstant().plus(getInactivityTimeout()))) {
+        }
+        
+        // Timeout comes from result, falling back to flow default.
+        if (now.isAfter(result.getLastActivityInstant().plus(result.getResultTimeout(this)))) {
             return false;
         }
 
@@ -614,6 +626,16 @@ public class AuthenticationFlowDescriptor extends AbstractIdentifiableInitializa
                 (expiration != null) ?
                         expiration - inactivityTimeout.toMillis() - STORAGE_EXPIRATION_OFFSET.toMillis() :
                             null);
+        
+        if (expiration != null) {
+            // Check for a result-specific timeout to possibly recompute the last activity timestamp based on it.
+            final Duration overriddenTimeout = result.getResultTimeout();
+            if (overriddenTimeout != null) {
+                result.setLastActivityInstant(Instant.ofEpochMilli(
+                        expiration - overriddenTimeout.toMillis() - STORAGE_EXPIRATION_OFFSET.toMillis()));
+            }
+        }
+        
         if (proxyRestrictionsEnforced) {
             result.setReuseCondition(PredicateSupport.and(reuseCondition, result.new ProxyRestrictionReusePredicate()));
         } else {
@@ -681,7 +703,7 @@ public class AuthenticationFlowDescriptor extends AbstractIdentifiableInitializa
      * 
      * @param <T> object type
      */
-    private class WeightedComparator<T> implements Comparator<T> {
+    private final class WeightedComparator<T> implements Comparator<T> {
 
         /** {@inheritDoc} */
         public int compare(final T o1, final T o2) {
@@ -703,7 +725,7 @@ public class AuthenticationFlowDescriptor extends AbstractIdentifiableInitializa
      * A {@link Predicate} that implements a cross-check between an effective proxy count of zero and
      * whether a descriptor is honoring the limit.
      */
-    private class ProxyCountPredicate implements Predicate<ProfileRequestContext> {
+    private final class ProxyCountPredicate implements Predicate<ProfileRequestContext> {
 
         /** {@inheritDoc} */
         public boolean test(@Nullable final ProfileRequestContext input) {
diff --git a/idp-authn-api/src/main/java/net/shibboleth/idp/authn/AuthenticationResult.java b/idp-authn-api/src/main/java/net/shibboleth/idp/authn/AuthenticationResult.java
index f62953163..51f164277 100644
--- a/idp-authn-api/src/main/java/net/shibboleth/idp/authn/AuthenticationResult.java
+++ b/idp-authn-api/src/main/java/net/shibboleth/idp/authn/AuthenticationResult.java
@@ -15,6 +15,7 @@
 package net.shibboleth.idp.authn;
 
 import java.security.Principal;
+import java.time.Duration;
 import java.time.Instant;
 import java.util.HashMap;
 import java.util.Map;
@@ -65,6 +66,12 @@ public class AuthenticationResult implements PrincipalSupportingComponent, Predi
     /** The last time this result was used to bypass authentication. */
     @Nonnull private Instant lastActivityInstant;
     
+    /** Optional override of flow's lifetime policy for this result. */
+    @Nullable private Duration resultLifetime;
+
+    /** Optional override of flow's inactivity timeout policy for this result. */
+    @Nullable private Duration resultTimeout;
+
     /** Tracks whether a result was loaded from a previous session or created as part of the current request. */
     private boolean previousResult;
     
@@ -228,6 +235,89 @@ public class AuthenticationResult implements PrincipalSupportingComponent, Predi
         assert now != null;
         lastActivityInstant = now;
     }
+
+    /**
+     * Get the effective lifetime policy for the result based on the provided flow descriptor.
+     * 
+     * @param flow correspnding flow descriptor
+     * 
+     * @return effective lifetime policy combining result and flow
+     * 
+     * @since 5.2.0
+     */
+    @Nullable public Duration getResultLifetime(@Nonnull final AuthenticationFlowDescriptor flow) {
+        return resultLifetime != null ? resultLifetime : flow.getLifetime(); 
+    }
+
+    /**
+     * Get the per-result lifetime policy if it exists.
+     * 
+     * <p>If set, this overrides the more typical flow-based lifetime setting for the result.</p>
+     * 
+     * @return per-result lifetime policy
+     * 
+     * @since 5.2.0
+     */
+    @Nullable public Duration getResultLifetime() {
+        return resultLifetime;
+    }
+    
+    /**
+     * Set the per-result lifetime policy.
+     * 
+     * <p>If null, the underlying flow definition's lifetime applies.</p>
+     * 
+     * @param lifetime lifetime to set
+     * 
+     * @since 5.2.0
+     */
+    public void setResultLifetime(@Nullable final Duration lifetime) {
+        Constraint.isFalse(lifetime != null && (lifetime.isNegative() || lifetime.isZero()),
+                "Lifetime must be null or greater than 0");
+        resultLifetime = lifetime;
+    }
+
+    /**
+     * Get the effective timeout policy for the result based on the provided flow descriptor.
+     * 
+     * @param flow correspnding flow descriptor
+     * 
+     * @return effective timeout policy combining result and flow
+     * 
+     * @since 5.2.0
+     */
+    @Nonnull public Duration getResultTimeout(@Nonnull final AuthenticationFlowDescriptor flow) {
+        return resultTimeout != null ? resultTimeout : flow.getInactivityTimeout(); 
+    }
+    
+    /**
+     * Get the per-result inactivity timeout policy if it exists.
+     * 
+     * <p>If set, this overrides the more typical flow-based timeout setting for the result.</p>
+     * 
+     * @return per-result inactivity timeout policy
+     * 
+     * @since 5.2.0
+     */
+    @Nullable public Duration getResultTimeout() {
+        return resultTimeout;
+    }
+    
+    /**
+     * Set the per-result inactivity timeout policy.
+     * 
+     * <p>If null, the underlying flow definition's inactivity timeout applies.</p>
+     * 
+     * @param timeout timeout to set
+     * 
+     * @since 5.2.0
+     */
+    public void setResultTimeout(@Nullable final Duration timeout) {
+        if (timeout != null) {
+            Constraint.isFalse(timeout.isNegative() || timeout.isZero(), "Inactivity timeout must be greater than 0");
+        }
+        resultTimeout = timeout;
+    }
     
     /**
      * Get whether this result was loaded from a session as the product of a previous request.
diff --git a/idp-authn-impl/src/main/java/net/shibboleth/idp/authn/impl/DefaultAuthenticationResultSerializer.java b/idp-authn-impl/src/main/java/net/shibboleth/idp/authn/impl/DefaultAuthenticationResultSerializer.java
index 63c5d08c2..49ae07cbd 100644
--- a/idp-authn-impl/src/main/java/net/shibboleth/idp/authn/impl/DefaultAuthenticationResultSerializer.java
+++ b/idp-authn-impl/src/main/java/net/shibboleth/idp/authn/impl/DefaultAuthenticationResultSerializer.java
@@ -21,6 +21,7 @@ import java.security.Principal;
 import java.security.cert.CertificateEncodingException;
 import java.security.cert.CertificateException;
 import java.security.cert.X509Certificate;
+import java.time.Duration;
 import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Collection;
@@ -34,6 +35,7 @@ import javax.annotation.Nullable;
 import jakarta.json.Json;
 import jakarta.json.JsonArray;
 import jakarta.json.JsonException;
+import jakarta.json.JsonNumber;
 import jakarta.json.JsonObject;
 import jakarta.json.JsonReader;
 import jakarta.json.JsonReaderFactory;
@@ -77,6 +79,12 @@ public class DefaultAuthenticationResultSerializer extends AbstractInitializable
     /** Field name of authentication instant. */
     @Nonnull @NotEmpty private static final String AUTHN_INSTANT_FIELD = "ts";
 
+    /** Field name of optional result-specific lifetime. */
+    @Nonnull @NotEmpty private static final String RESULT_LIFETIME_FIELD = "rl";
+
+    /** Field name of optional result-specific inactivity timeout. */
+    @Nonnull @NotEmpty private static final String RESULT_TIMEOUT_FIELD = "rt";
+
     /** Field name of principal array. */
     @Nonnull @NotEmpty private static final String PRINCIPAL_ARRAY_FIELD = "princ";
 
@@ -196,6 +204,16 @@ public class DefaultAuthenticationResultSerializer extends AbstractInitializable
             gen.writeStartObject().write(FLOW_ID_FIELD, instance.getAuthenticationFlowId())
                     .write(AUTHN_INSTANT_FIELD, instance.getAuthenticationInstant().toEpochMilli());
             
+            Duration dur = instance.getResultLifetime();
+            if (dur != null) {
+                gen.write(RESULT_LIFETIME_FIELD, dur.getSeconds());
+            }
+            
+            dur = instance.getResultTimeout();
+            if (dur != null) {
+                gen.write(RESULT_TIMEOUT_FIELD, dur.getSeconds());
+            }
+            
             final Map<String,String> addtlData = instance.getAdditionalData();
             if (!addtlData.isEmpty()) {
                 gen.writeStartObject(ADDTL_DATA_FIELD);
@@ -277,6 +295,14 @@ public class DefaultAuthenticationResultSerializer extends AbstractInitializable
             result.setLastActivityInstant(Instant.ofEpochMilli(expiration != null ? expiration : authnInstant));
             result.setPreviousResult(true);
 
+            if (obj.get(RESULT_LIFETIME_FIELD) instanceof JsonNumber lifetime) {
+                result.setResultLifetime(Duration.ofSeconds(lifetime.longValueExact()));
+            }
+
+            if (obj.get(RESULT_TIMEOUT_FIELD) instanceof JsonNumber timeout) {
+                result.setResultTimeout(Duration.ofSeconds(timeout.longValueExact()));
+            }
+
             final JsonObject addtlData = obj.getJsonObject(ADDTL_DATA_FIELD);
             if (addtlData != null) {
                 final Map<String,String> dataMap = result.getAdditionalData();
diff --git a/idp-authn-impl/src/test/java/net/shibboleth/idp/authn/impl/DefaultAuthenticationResultSerializerTest.java b/idp-authn-impl/src/test/java/net/shibboleth/idp/authn/impl/DefaultAuthenticationResultSerializerTest.java
index 6f55cd9fc..0ff45e119 100644
--- a/idp-authn-impl/src/test/java/net/shibboleth/idp/authn/impl/DefaultAuthenticationResultSerializerTest.java
+++ b/idp-authn-impl/src/test/java/net/shibboleth/idp/authn/impl/DefaultAuthenticationResultSerializerTest.java
@@ -22,6 +22,7 @@ import java.io.File;
 import java.io.FileInputStream;
 import java.io.IOException;
 import java.net.URISyntaxException;
+import java.time.Duration;
 import java.time.Instant;
 import java.util.List;
 
@@ -236,6 +237,42 @@ public class DefaultAuthenticationResultSerializerTest {
         
         assertEquals(result.getAuthenticationFlowId(), result2.getAuthenticationFlowId());
         assertEquals(result.getAuthenticationInstant(), result2.getAuthenticationInstant());
+        assertEquals(result.getResultLifetime(), result2.getResultLifetime());
+        assertEquals(result.getResultTimeout(), result2.getResultTimeout());
+        assertEquals(result.getLastActivityInstant(), result2.getLastActivityInstant());
+        assertEquals(result.getSubject(), result2.getSubject());
+        assertEquals(result.getAdditionalData(), result2.getAdditionalData());
+        assertTrue(result2.getReuseCondition().test(prc));
+    }
+
+    @Test public void testSimpleWithOptionalFields() throws Exception {
+        serializer.initialize();
+        flowDescriptor.initialize();
+        
+        final AuthenticationResult result = createResult(flowDescriptor, new Subject());
+        result.getAdditionalData().put("foo", "bar");
+        result.getAdditionalData().put("frobnitz", "zorkmid");
+        result.getSubject().getPrincipals().add(new UsernamePrincipal("bob"));
+        result.setResultLifetime(Duration.ofHours(1));
+        result.setResultTimeout(Duration.ofMinutes(5));
+        
+        final ProfileRequestContext prc = getProfileRequestContext(CollectionSupport.singletonList(flowDescriptor));
+        assertTrue(result.getReuseCondition().test(prc));
+        
+        flowDescriptor.serialize(result);
+        final String s2 = fileToString(DATAPATH + "simpleAuthenticationResultWithOptional.json");
+        // assertEquals(s, s2); 
+        
+        final AuthenticationResult result2 = flowDescriptor.deserialize(1, CONTEXT, KEY, s2,
+                Instant.ofEpochMilli(ACTIVITY)
+                    .plus(result.getResultTimeout())
+                    .plus(AuthenticationFlowDescriptor.STORAGE_EXPIRATION_OFFSET)
+                    .toEpochMilli());
+        
+        assertEquals(result.getAuthenticationFlowId(), result2.getAuthenticationFlowId());
+        assertEquals(result.getAuthenticationInstant(), result2.getAuthenticationInstant());
+        assertEquals(result.getResultLifetime(), result2.getResultLifetime());
+        assertEquals(result.getResultTimeout(), result2.getResultTimeout());
         assertEquals(result.getLastActivityInstant(), result2.getLastActivityInstant());
         assertEquals(result.getSubject(), result2.getSubject());
         assertEquals(result.getAdditionalData(), result2.getAdditionalData());
diff --git a/idp-authn-impl/src/test/resources/net/shibboleth/idp/authn/impl/simpleAuthenticationResultWithOptional.json b/idp-authn-impl/src/test/resources/net/shibboleth/idp/authn/impl/simpleAuthenticationResultWithOptional.json
new file mode 100644
index 000000000..bbbc854ff
--- /dev/null
+++ b/idp-authn-impl/src/test/resources/net/shibboleth/idp/authn/impl/simpleAuthenticationResultWithOptional.json
@@ -0,0 +1 @@
+{"id":"test","ts":1378827849463,"rl":3600,"rt":300,"props":{"foo":"bar","frobnitz":"zorkmid"},"princ":[{"U":"bob"}]}
\ No newline at end of file
diff --git a/idp-session-impl/src/main/java/net/shibboleth/idp/session/impl/StorageBackedIdPSession.java b/idp-session-impl/src/main/java/net/shibboleth/idp/session/impl/StorageBackedIdPSession.java
index fd75f916a..b9d546148 100644
--- a/idp-session-impl/src/main/java/net/shibboleth/idp/session/impl/StorageBackedIdPSession.java
+++ b/idp-session-impl/src/main/java/net/shibboleth/idp/session/impl/StorageBackedIdPSession.java
@@ -15,6 +15,7 @@
 package net.shibboleth.idp.session.impl;
 
 import java.io.IOException;
+import java.time.Duration;
 import java.time.Instant;
 import java.util.Iterator;
 import java.util.Map;
@@ -262,7 +263,7 @@ public class StorageBackedIdPSession extends AbstractIdPSession {
         if (flow != null) {
             try {
                 if (!sessionManager.getStorageService().updateExpiration(getId(), result.getAuthenticationFlowId(),
-                        result.getLastActivityInstant().plus(flow.getInactivityTimeout()).plus(
+                        result.getLastActivityInstant().plus(result.getResultTimeout(flow)).plus(
                             AuthenticationFlowDescriptor.STORAGE_EXPIRATION_OFFSET).toEpochMilli())) {
                     log.warn("Skipping update, AuthenticationResult for flow {} in session {} not found in storage",
                             flowId, getId());
@@ -584,13 +585,14 @@ public class StorageBackedIdPSession extends AbstractIdPSession {
             int attempts = 10;
             boolean success = false;
             do {
+                final Duration effectiveTimeout = result.getResultTimeout(flow);
                 success = sessionManager.getStorageService().create(getId(), flowId, result, flow,
-                        result.getLastActivityInstant().plus(flow.getInactivityTimeout()).plus(
+                        result.getLastActivityInstant().plus(effectiveTimeout).plus(
                             AuthenticationFlowDescriptor.STORAGE_EXPIRATION_OFFSET).toEpochMilli());
                 if (!success) {
                     // The record already exists, so we need to overwrite via an update.
                     success = sessionManager.getStorageService().update(getId(), flowId, result, flow,
-                            result.getLastActivityInstant().plus(flow.getInactivityTimeout()).plus(
+                            result.getLastActivityInstant().plus(effectiveTimeout).plus(
                                 AuthenticationFlowDescriptor.STORAGE_EXPIRATION_OFFSET).toEpochMilli());
                 }
             } while (!success && attempts-- > 0);

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


More information about the commits mailing list