[java-plugin-shibd-oidc] branch main updated: Add Issue(Correlation)StateCookie variant

Codeberg noreply at shibboleth.net
Thu Feb 5 10:28:35 UTC 2026


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

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

View the commit online:
https://codeberg.org/Shibboleth/java-plugin-shibd-oidc/commit/88935cda05f7c5d7c3c9efc35337d49b1faa7886

The following commit(s) were added to refs/heads/main by this push:
     new 88935cd  Add Issue(Correlation)StateCookie variant
88935cd is described below

commit 88935cda05f7c5d7c3c9efc35337d49b1faa7886
Author: Phil Smart <philip.smart at jisc.ac.uk>
AuthorDate: Thu Feb 5 10:28:24 2026 +0000

    Add Issue(Correlation)StateCookie variant
    
     - This is almost identical to IssueCorrelationCookie other than naming.
    The biggest change was to ProcessStateCookie.
     - Depending on how we manage state, we might not need this class long
    term.
---
 .../idp/flows/sp/initiator/oidc/oidc-beans.xml     |   8 +-
 .../sp/oidc/profile/impl/IssueStateCookie.java     | 199 +++++++++++++++++++++
 .../sp/oidc/profile/impl/IssueStateCookieTest.java | 165 +++++++++++++++++
 3 files changed, 368 insertions(+), 4 deletions(-)

diff --git a/sp-oidc-conf-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/sp/initiator/oidc/oidc-beans.xml b/sp-oidc-conf-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/sp/initiator/oidc/oidc-beans.xml
index 326ac48..a807a85 100644
--- a/sp-oidc-conf-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/sp/initiator/oidc/oidc-beans.xml
+++ b/sp-oidc-conf-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/sp/initiator/oidc/oidc-beans.xml
@@ -316,12 +316,12 @@
     </bean>
     
     <!-- TODO not having a state token to issue a correlation cookie has been set to fatal  -->
-    <bean id="IssueNonceCorrelationCookie" class="net.shibboleth.sp.profile.impl.IssueCorrelationCookie" scope="prototype"
+    <bean id="IssueNonceCorrelationCookie" class="net.shibboleth.sp.oidc.profile.impl.IssueStateCookie" scope="prototype"
         p:cookieManager-ref="shibboleth.sp.RemotedCookieManager"
         p:cookiePrefix="#{'%{sp.correlation.cookiePrefix:__Host-_shibsp_req_}'.trim()}"
         p:createOutputObjects="true"
         p:errorFatal="%{sp.stateToken.errorsFatal:true}" 
-        p:requestIDLookupStrategy-ref="NonceFromStateStrategy" />
+        p:stateValueLookupStrategy-ref="NonceFromStateStrategy" />
         
      <bean id="NonceFromStateStrategy" parent="shibboleth.Functions.Compose">
         <constructor-arg name="g">
@@ -338,12 +338,12 @@
     </bean>
       
    <!-- Issue a cookie with state information that is needs to be recovered to validate the response -->
-   <bean id="IssueAuthnStateCookie" class="net.shibboleth.sp.profile.impl.IssueCorrelationCookie" scope="prototype"
+   <bean id="IssueAuthnStateCookie" class="net.shibboleth.sp.oidc.profile.impl.IssueStateCookie" scope="prototype"
         p:cookieManager-ref="shibboleth.sp.RemotedCookieManager"
         p:cookiePrefix="#{'%{sp.correlation.cookiePrefix:__Host-_shibsp__authnstate_}'.trim()}"
         p:createOutputObjects="true"
         p:errorFatal="%{sp.stateToken.errorsFatal:true}" 
-        p:requestIDLookupStrategy-ref="AuthenticationRequestStateForStorageStrategy" />
+        p:stateValueLookupStrategy-ref="AuthenticationRequestStateForStorageStrategy" />
         
  <!--  <bean id="MapAuthenticationStateToStateToken" class="net.shibboleth.sp.oidc.profile.impl.MapStateToStateToken" scope="prototype"
         p:createOutputObjects="true"
diff --git a/sp-oidc-impl/src/main/java/net/shibboleth/sp/oidc/profile/impl/IssueStateCookie.java b/sp-oidc-impl/src/main/java/net/shibboleth/sp/oidc/profile/impl/IssueStateCookie.java
new file mode 100644
index 0000000..bf6661c
--- /dev/null
+++ b/sp-oidc-impl/src/main/java/net/shibboleth/sp/oidc/profile/impl/IssueStateCookie.java
@@ -0,0 +1,199 @@
+/*
+ * 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.oidc.profile.impl;
+
+import java.util.function.Function;
+
+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.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.
+ * 
+ * @event {@link EventIds#PROCEED_EVENT_ID}
+ * @event {@link EventIds#INVALID_MESSAGE}
+ */
+public class IssueStateCookie extends AbstractApplicationAction {
+    
+    /** Default cookie prefix. */
+    @Nonnull @NotEmpty public static final String DEFAULT_COOKIE_PREFIX = "shibsp_req_";
+    
+    /** Class logger. */
+    @Nonnull private final Logger log = LoggerFactory.getLogger(IssueStateCookie.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 the contents of the state cookie. */
+    @NonnullAfterInit private Function<ProfileRequestContext,String> stateValueLookupStrategy;
+
+    /** State token value used in cookie name. */
+    @NonnullBeforeExec private String stateToken;
+    
+    /** The value of the state cookie. */
+    @NonnullBeforeExec private String stateValue;
+    
+    /** Constructor. */
+    public IssueStateCookie() {
+        cookiePrefix = DEFAULT_COOKIE_PREFIX;
+    }
+    
+    /**
+     * 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 contents of the state cookie.
+     * 
+     * @param strategy lookup strategy
+     */
+    public void setStateValueLookupStrategy(@Nonnull final Function<ProfileRequestContext,String> strategy) {
+        checkSetterPreconditions();
+        
+        stateValueLookupStrategy = Constraint.isNotNull(strategy, "State value lookup strategy cannot be null");
+    }
+    
+    /** {@inheritDoc} */
+    @Override
+    protected void doInitialize() throws ComponentInitializationException {
+        super.doInitialize();
+        
+        if (cookieManager == null) {
+            throw new ComponentInitializationException("CookieManager cannot be null");
+        } else if (stateValueLookupStrategy == 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;
+        }
+
+        stateValue = stateValueLookupStrategy.apply(profileRequestContext);
+        
+        if (stateValue == null) {
+            log.debug("{} No cookie contents available, skipping creation of state cookie", getLogPrefix());
+            return false;
+        }
+        
+        final DDF input = ensureAgentRequestContext().getInput();
+        if (input != null) {
+            stateToken = input.getmember(SPConstants.STATE).string();
+        }
+        
+        if (stateToken == null) {
+            if (stateValue != 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 state cookie", getLogPrefix(),
+                            SPConstants.STATE);
+                }
+            }
+            return false;
+        }
+        
+        return true;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+
+        ensureOutputObjects();
+        final AgentRequestContext agentRequestContext = ensureAgentRequestContext();
+
+        // We do the crazy stuff to accomodate the cookies being set or unset.
+        try {
+            RemotedHttpServletRequestResponseContext.loadCurrent(agentRequestContext.getRemotedHttpServletRequest(),
+                    agentRequestContext.getRemotedHttpServletResponse());
+
+            cookieManager.purgeStaleCookies(cookiePrefix);
+            
+            log.debug("{} Tracking state {} against state token {}", getLogPrefix(), stateValue, stateToken);
+
+            final Escaper escaper = UrlEscapers.urlFormParameterEscaper();
+            cookieManager.addCookie(cookiePrefix + escaper.escape(stateToken), escaper.escape(stateValue));
+        } finally {
+            RemotedHttpServletRequestResponseContext.clearCurrent();
+        }
+    }
+
+}
\ No newline at end of file
diff --git a/sp-oidc-impl/src/test/java/net/shibboleth/sp/oidc/profile/impl/IssueStateCookieTest.java b/sp-oidc-impl/src/test/java/net/shibboleth/sp/oidc/profile/impl/IssueStateCookieTest.java
new file mode 100644
index 0000000..0face37
--- /dev/null
+++ b/sp-oidc-impl/src/test/java/net/shibboleth/sp/oidc/profile/impl/IssueStateCookieTest.java
@@ -0,0 +1,165 @@
+/*
+ * 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.oidc.profile.impl;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.Function;
+
+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;
+import net.shibboleth.sp.profile.impl.BaseAgplicationActionTest;
+import net.shibboleth.sp.profile.impl.IssueCorrelationCookie;
+
+/**
+ * Unit test for {@link IssueStateCookie} action.
+ */
+ at SuppressWarnings("javadoc")
+public class IssueStateCookieTest extends BaseAgplicationActionTest {
+
+    @Nonnull @NotEmpty private final static String TEST_STATE = "foo";
+    @Nonnull @NotEmpty private final static String TEST_VALUE = "123456789";
+
+    private String stateValue;
+    
+    private DDF input;
+    private MockHttpServletRequest request;
+    private MockHttpServletResponse response;
+    
+    private CookieManager cookieManager;
+    private IssueStateCookie action;
+        
+    /**
+     * Set up test.
+     * 
+     * @throws ComponentInitializationException
+     */
+    @BeforeMethod
+    public void setUp() throws ComponentInitializationException {
+        super.beforeMethod();
+        
+        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 IssueStateCookie();
+        action.setCookieManager(cookieManager);
+        action.setStateValueLookupStrategy(new Function<>() {
+            public String apply(final ProfileRequestContext t) {
+                return stateValue;
+            }
+        });
+        
+        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_VALUE},
+            new Object[] { TEST_STATE, null},
+            new Object[] { TEST_STATE, TEST_VALUE},
+        };
+    }
+        
+    @Test(dataProvider="correlationData")
+    public void testAction(final String state, final String id) {
+        evaluateAction(state, id);
+    }
+    
+    private void evaluateAction(final String state, final String id) {
+        stateValue = 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(), stateValue);
+        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_VALUE);
+        
+        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