[java-plugin-shibd] branch main updated: JSHIBDSAML-1 - Request/response correlation and passive tracking

Scott Cantor cantor.2 at osu.edu
Mon Apr 14 12:56:57 UTC 2025


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

scantor pushed a commit to branch main
in repository java-plugin-shibd.

View the commit online:
http://git.shibboleth.net/view/?p=java-plugin-shibd.git;a=commit;h=f54bde4f3649f7fe54409e52970d792425f9bf56

The following commit(s) were added to refs/heads/main by this push:
     new f54bde4  JSHIBDSAML-1 - Request/response correlation and passive tracking
f54bde4 is described below

commit f54bde4f3649f7fe54409e52970d792425f9bf56
Author: Scott Cantor <cantor.2 at osu.edu>
AuthorDate: Mon Apr 14 08:56:51 2025 -0400

    JSHIBDSAML-1 - Request/response correlation and passive tracking
    
    https://shibboleth.atlassian.net/browse/JSHIBDSAML-1
    
    Action to issue correlation tracking cookie with tests.
---
 .../sp/profile/impl/IssueCorrelationCookie.java    | 225 +++++++++++++++++++++
 .../profile/impl/IssueCorrelationCookieTest.java   | 173 ++++++++++++++++
 2 files changed, 398 insertions(+)

diff --git a/sp-server-impl/src/main/java/net/shibboleth/sp/profile/impl/IssueCorrelationCookie.java b/sp-server-impl/src/main/java/net/shibboleth/sp/profile/impl/IssueCorrelationCookie.java
new file mode 100644
index 0000000..4e58aa2
--- /dev/null
+++ b/sp-server-impl/src/main/java/net/shibboleth/sp/profile/impl/IssueCorrelationCookie.java
@@ -0,0 +1,225 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.shibboleth.sp.profile.impl;
+
+import java.util.function.Function;
+import java.util.function.Predicate;
+
+import javax.annotation.Nonnull;
+
+import org.opensaml.profile.action.ActionSupport;
+import org.opensaml.profile.action.EventIds;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.slf4j.Logger;
+
+import com.google.common.escape.Escaper;
+import com.google.common.net.UrlEscapers;
+
+import net.shibboleth.shared.annotation.constraint.NonnullAfterInit;
+import net.shibboleth.shared.annotation.constraint.NonnullBeforeExec;
+import net.shibboleth.shared.annotation.constraint.NotEmpty;
+import net.shibboleth.shared.component.ComponentInitializationException;
+import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.logic.PredicateSupport;
+import net.shibboleth.shared.net.CookieManager;
+import net.shibboleth.shared.primitive.LoggerFactory;
+import net.shibboleth.shared.primitive.StringSupport;
+import net.shibboleth.sp.context.AgentRequestContext;
+import net.shibboleth.sp.ddf.DDF;
+import net.shibboleth.sp.messaging.RemotedHttpServletRequestResponseContext;
+import net.shibboleth.sp.profile.AbstractApplicationAction;
+import net.shibboleth.sp.profile.SPConstants;
+
+/**
+ * Action that issues a cookie used to record state about a request for
+ * later enforcement/evaluation.
+ * 
+ * <p>Principally used to capture a request message ID for correlation to a
+ * response, also tracks whether a response was "passive" or not.</p>
+ * 
+ * @event {@link EventIds#PROCEED_EVENT_ID}
+ * @event {@link EventIds#INVALID_MESSAGE}
+ */
+public class IssueCorrelationCookie extends AbstractApplicationAction {
+    
+    /** Default cookie prefix. */
+    @Nonnull @NotEmpty static public String DEFAULT_COOKIE_PREFIX = "_shibsp_req_";
+    
+    /** Class logger. */
+    @Nonnull private Logger log = LoggerFactory.getLogger(IssueCorrelationCookie.class);
+    
+    /** Cookie manager. */
+    @NonnullAfterInit private CookieManager cookieManager;
+    
+    /** Cookie prefix. */
+    @Nonnull private String cookiePrefix;
+    
+    /** Whether an error constructing a correlation cookie is fatal. */
+    private boolean errorFatal;
+    
+    /** Lookup strategy for request message ID. */
+    @NonnullAfterInit private Function<ProfileRequestContext,String> requestIDLookupStrategy;
+
+    /** Condition for deriving passive request status. */
+    @Nonnull private Predicate<ProfileRequestContext> passiveRequestPredicate;
+
+    /** State token vakue used in cookie name. */
+    @NonnullBeforeExec private String stateToken;
+    
+    /** Request ID. */
+    @NonnullBeforeExec private String requestID;
+    
+    /** Passive indicator. */
+    private boolean passive;
+    
+    /** Constructor. */
+    public IssueCorrelationCookie() {
+        cookiePrefix = DEFAULT_COOKIE_PREFIX;
+        passiveRequestPredicate = PredicateSupport.alwaysFalse();
+    }
+    
+    /**
+     * Sets the {@link CookieManager} to use.
+     * 
+     * @param manager cookie manager instance
+     */
+    public void setCookieManager(@Nonnull final CookieManager manager) {
+        checkSetterPreconditions();
+        
+        cookieManager = Constraint.isNotNull(manager, "CookieManager cannot be null");
+    }
+    
+    /**
+     * Sets the cookie prefix.
+     * 
+     * <p>Defaults to {@link #DEFAULT_COOKIE_PREFIX}.</p>
+     * 
+     * @param prefix cookie prefix
+     */
+    public void setCookiePrefix(@Nonnull @NotEmpty final String prefix) {
+        checkSetterPreconditions();
+        
+        cookiePrefix = Constraint.isNotNull(StringSupport.trimOrNull(prefix), "Cookie prefix cannot be null or empty");
+    }
+    
+    /**
+     * Sets whether an error computing a state token should result in a fatal event.
+     * 
+     * <p>Defaults to false.</p>
+     * 
+     * @param flag flag to set
+     */
+    public void setErrorFatal(final boolean flag) {
+        checkSetterPreconditions();
+        
+        errorFatal = flag;
+    }
+    
+    /**
+     * Sets the lookup strategy for obtaining the request message's ID.
+     * 
+     * @param strategy lookup strategy
+     */
+    public void setRequestIDLookupStrategy(@Nonnull final Function<ProfileRequestContext,String> strategy) {
+        checkSetterPreconditions();
+        
+        requestIDLookupStrategy = Constraint.isNotNull(strategy, "Request ID lookup strategy cannot be null");
+    }
+    
+    /**
+     * Sets the condition for determining whether the request contains a passive indicator.
+     * 
+     * <p>Defaults to "false".</p>
+     * 
+     * @param condition
+     */
+    public void setPassiveRequestPredicate(@Nonnull final Predicate<ProfileRequestContext> condition) {
+        checkSetterPreconditions();
+        
+        passiveRequestPredicate = Constraint.isNotNull(condition, "Passive request predicate cannot be null");
+    }
+    
+    /** {@inheritDoc} */
+    @Override
+    protected void doInitialize() throws ComponentInitializationException {
+        super.doInitialize();
+        
+        if (cookieManager == null) {
+            throw new ComponentInitializationException("CookieManager cannot be null");
+        } else if (requestIDLookupStrategy == null) {
+            throw new ComponentInitializationException("Request ID lookup strategy cannot be null");
+        }
+    }    
+
+    /** {@inheritDoc} */
+    @Override
+    protected boolean doPreExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+        if (!super.doPreExecute(profileRequestContext)) {
+            return false;
+        }
+
+        requestID = requestIDLookupStrategy.apply(profileRequestContext);
+        passive = passiveRequestPredicate.test(profileRequestContext);
+        
+        if (requestID == null && !passive) {
+            log.debug("{} No request message ID available, skipping creation of correlation cookie", getLogPrefix());
+            return false;
+        }
+        
+        final DDF input = ensureAgentRequestContext().getInput();
+        if (input != null) {
+            stateToken = input.getmember(SPConstants.STATE).string();
+        }
+        
+        if (stateToken == null) {
+            if (requestID != null) {
+                if (errorFatal) {
+                    log.warn("{} Input was missing {} parameter", getLogPrefix(), SPConstants.STATE);
+                    ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_MESSAGE);
+                } else {
+                    log.debug("{} Input was missing {} parameter, skipping creation of correlation cookie", getLogPrefix(),
+                            SPConstants.STATE);
+                }
+            }
+            return false;
+        }
+        
+        return true;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+        
+        // We do the crazy stuff to accomodate the cookies being set or unset.
+        try {
+            final AgentRequestContext agentRequestContext = ensureAgentRequestContext();
+            RemotedHttpServletRequestResponseContext.loadCurrent(agentRequestContext.getRemotedHttpServletRequest(),
+                    agentRequestContext.getRemotedHttpServletResponse());
+
+            cookieManager.purgeStaleCookies(cookiePrefix);
+            
+            log.debug("{} Tracking {}passive request ID {} against RelayState token {}", getLogPrefix(),
+                    passive ? "" : "non-", requestID, stateToken);
+
+            final Escaper escaper = UrlEscapers.urlFormParameterEscaper();
+            final String value = (passive ? "T:" : "F:") + escaper.escape(requestID);
+            cookieManager.addCookie(cookiePrefix + escaper.escape(stateToken), value);
+        } finally {
+            RemotedHttpServletRequestResponseContext.clearCurrent();
+        }
+    }
+
+}
\ No newline at end of file
diff --git a/sp-server-impl/src/test/java/net/shibboleth/sp/profile/impl/IssueCorrelationCookieTest.java b/sp-server-impl/src/test/java/net/shibboleth/sp/profile/impl/IssueCorrelationCookieTest.java
new file mode 100644
index 0000000..76f7d19
--- /dev/null
+++ b/sp-server-impl/src/test/java/net/shibboleth/sp/profile/impl/IssueCorrelationCookieTest.java
@@ -0,0 +1,173 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.shibboleth.sp.profile.impl;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.Function;
+import java.util.function.Predicate;
+
+import javax.annotation.Nonnull;
+
+import org.opensaml.profile.action.EventIds;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+import org.springframework.webflow.execution.Event;
+import org.testng.Assert;
+import org.testng.annotations.AfterMethod;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Test;
+
+import jakarta.servlet.http.Cookie;
+import net.shibboleth.idp.profile.testing.ActionTestingSupport;
+import net.shibboleth.shared.annotation.constraint.NotEmpty;
+import net.shibboleth.shared.codec.DecodingException;
+import net.shibboleth.shared.component.ComponentInitializationException;
+import net.shibboleth.shared.net.CookieManager;
+import net.shibboleth.shared.net.CookieManager.SameSiteValue;
+import net.shibboleth.shared.primitive.NonnullSupplier;
+import net.shibboleth.sp.ddf.DDF;
+import net.shibboleth.sp.profile.SPConstants;
+
+/**
+ * Unit test for {@link IssueCorrelationCookie} action.
+ */
+ at SuppressWarnings("javadoc")
+public class IssueCorrelationCookieTest extends BaseAgplicationActionTest {
+
+    @Nonnull @NotEmpty private final static String TEST_STATE = "foo";
+    @Nonnull @NotEmpty private final static String TEST_ID = "123456789";
+
+    private String requestId;
+    private boolean passive;
+    
+    private DDF input;
+    private MockHttpServletRequest request;
+    private MockHttpServletResponse response;
+    
+    private CookieManager cookieManager;
+    private IssueCorrelationCookie action;
+        
+    /**
+     * Set up test.
+     * 
+     * @throws ComponentInitializationException
+     */
+    @BeforeMethod
+    public void setUp() throws ComponentInitializationException {
+        super.beforeMethod();
+        
+        passive = false;
+        requestId = TEST_STATE;
+        request = new MockHttpServletRequest();
+        response = new MockHttpServletResponse();
+        
+        cookieManager = new CookieManager();
+        cookieManager.setHttpServletRequestSupplier(NonnullSupplier.of(request));
+        cookieManager.setHttpServletResponseSupplier(NonnullSupplier.of(response));
+        cookieManager.setCookieLimit(10);
+        cookieManager.setSameSite(SameSiteValue.None);
+        cookieManager.setMaxAge(-1);
+        cookieManager.initialize();
+        
+        action = new IssueCorrelationCookie();
+        action.setCookieManager(cookieManager);
+        action.setPassiveRequestPredicate(new Predicate<>() {
+            public boolean test(ProfileRequestContext t) {
+                return passive;
+            }});
+        action.setRequestIDLookupStrategy(new Function<>() {
+            public String apply(ProfileRequestContext t) {
+                return requestId;
+            }
+        });
+        
+        action.setErrorFatal(true);
+        action.initialize();
+
+        input = new DDF(null).structure();
+        arc.setInput(input);
+    }
+    
+    /**
+     * Tear down test.
+     */
+    @AfterMethod
+    public void tearDown() {
+        action.destroy();
+        cookieManager.destroy();
+    }
+
+    @DataProvider
+    Object[][] correlationData() {
+        return new Object[][] {
+            new Object[] { null, TEST_ID, false},
+            new Object[] { TEST_STATE, null, false},
+            new Object[] { TEST_STATE, TEST_ID, false},
+            new Object[] { TEST_STATE, TEST_ID, true},
+        };
+    }
+        
+    @Test(dataProvider="correlationData")
+    public void testAction(final String state, final String id, final Boolean passiveFlag) {
+        evaluateAction(state, id, passiveFlag);
+    }
+    
+    private void evaluateAction(final String state, final String id, final Boolean passiveFlag) {
+        passive = passiveFlag;
+        requestId = id;
+        if (state != null) {
+            input.addmember(SPConstants.STATE).string(state);
+        }
+        
+        final Event event = action.execute(src);
+        
+        if (state != null) {
+            ActionTestingSupport.assertProceedEvent(event);
+        } else {
+            ActionTestingSupport.assertEvent(event, EventIds.INVALID_MESSAGE);
+            return;
+        }
+        
+        if (id == null) {
+            Assert.assertEquals(response.getCookies().length, 0);
+            return;
+        }
+        
+        final Cookie cookie = response.getCookie(IssueCorrelationCookie.DEFAULT_COOKIE_PREFIX + state);
+        assert cookie != null;
+        Assert.assertEquals(cookie.getValue(), (passive ? "T:" : "F:") + requestId);
+        Assert.assertEquals(cookie.getMaxAge(), -1);
+        Assert.assertEquals(cookie.getAttribute("SameSite"), SameSiteValue.None.getValue());
+    }
+    
+    @Test
+    public void testPurge() throws ComponentInitializationException, DecodingException, InterruptedException {
+        
+        final List<Cookie> cookies = new ArrayList<>(12);
+        for (int i = 0; i < 12; ++i) {
+            cookies.add(new Cookie(IssueCorrelationCookie.DEFAULT_COOKIE_PREFIX + i, "foo" + i));
+            Thread.sleep(250);
+        }
+        request.setCookies(cookies.toArray(new Cookie[12]));
+        
+        evaluateAction(TEST_STATE, TEST_ID, false);
+        
+        Assert.assertEquals(response.getCookies().length, 3);
+    }
+
+}
\ No newline at end of file

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


More information about the commits mailing list