[java-plugin-shibd-saml] branch main updated: Initial draft and unit tests for LogoutResponse issuance.

Codeberg noreply at shibboleth.net
Thu May 28 13:56:53 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-saml.

View the commit online:
https://codeberg.org/Shibboleth/java-plugin-shibd-saml/commit/eb614b355b834e9688c25629710e127d366c3ce8

The following commit(s) were added to refs/heads/main by this push:
     new eb614b3  Initial draft and unit tests for LogoutResponse issuance.
eb614b3 is described below

commit eb614b355b834e9688c25629710e127d366c3ce8
Author: Scott Cantor <scott at restingparrotsoftware.com>
AuthorDate: Thu May 28 09:56:41 2026 -0400

    Initial draft and unit tests for LogoutResponse issuance.
---
 .../flows/sp/logout/consumer/saml2/saml2-beans.xml |  36 +-
 .../flows/sp/logout/consumer/saml2/saml2-flow.xml  |  19 +-
 .../shibboleth/idp/flows/sp/saml2-common-beans.xml |  27 +-
 .../flows/saml2/SAML2LogoutConsumerFlowTest.java   |   2 +-
 .../saml2/SAML2LogoutConsumerTokenFlowTest.java    | 368 +++++++++++++++++++++
 .../saml/saml2/profile/impl/AddLogoutResponse.java | 272 +++++++++++++++
 6 files changed, 699 insertions(+), 25 deletions(-)

diff --git a/sp-saml-conf-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/sp/logout/consumer/saml2/saml2-beans.xml b/sp-saml-conf-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/sp/logout/consumer/saml2/saml2-beans.xml
index 01af207..3b477e3 100644
--- a/sp-saml-conf-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/sp/logout/consumer/saml2/saml2-beans.xml
+++ b/sp-saml-conf-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/sp/logout/consumer/saml2/saml2-beans.xml
@@ -10,8 +10,22 @@
     <util:constant id="shiibboleth.sp.ProfileId"
         static-field="net.shibboleth.saml.saml2.profile.config.SingleLogoutProfileConfiguration.PROFILE_ID" />
 
+    <util:constant id="shibboleth.EndpointType"
+        static-field="org.opensaml.saml.saml2.metadata.SingleLogoutService.DEFAULT_ELEMENT_NAME" />
+
     <import resource="classpath:/META-INF/net/shibboleth/idp/flows/sp/saml2-common-beans.xml" />
 
+    <!-- Dummy manager used to generate/consume the "token" operation parameter. -->
+    <bean id="PassthroughStateManager"
+        class="net.shibboleth.sp.state.impl.PassthroughStateManager"
+        p:dataSealer-ref="shibboleth.DataSealer"
+        p:objectMapper-ref="shibboleth.JSONObjectMapper"
+        p:expiration="PT30M" />
+
+    <bean id="ProcessLogoutTokenRequest"
+        class="net.shibboleth.sp.saml.saml2.profile.impl.ProcessLogoutTokenRequest" scope="prototype"
+        p:stateManager-ref="PassthroughStateManager" />
+
     <!-- Only applied to LogoutResponse messages. -->
     
     <bean id="CreateServletResponseForLogoutResponse"
@@ -160,14 +174,7 @@
         p:decryptionParametersResolver-ref="shibboleth.DecryptionParametersResolver" />
 
     <bean id="DecryptNameIDs" class="org.opensaml.saml.saml2.profile.impl.DecryptNameIDs" scope="prototype" />
-    
-    <!-- Dummy manager used to generate/consume the "token" operation parameter. -->
-    <bean id="PassthroughStateManager"
-        class="net.shibboleth.sp.state.impl.PassthroughStateManager"
-        p:dataSealer-ref="shibboleth.DataSealer"
-        p:objectMapper-ref="shibboleth.JSONObjectMapper"
-        p:expiration="PT30M" />
-    
+        
     <bean id="ProcessLogoutRequest"
         class="net.shibboleth.sp.saml.saml2.profile.impl.ProcessLogoutRequest" scope="prototype"
         p:parserPool-ref="shibboleth.ParserPool"
@@ -175,5 +182,18 @@
     
     <bean id="ProcessLogoutResponse"
         class="net.shibboleth.sp.saml.saml2.profile.impl.ProcessLogoutResponse" scope="prototype" />
+        
+    <bean id="PopulateResponseSignatureSigningParameters"
+            class="org.opensaml.saml.common.profile.impl.PopulateSignatureSigningParameters" scope="prototype"
+            p:configurationLookupStrategy-ref="shibboleth.SignatureSigningConfigurationLookup"
+            p:signatureSigningParametersResolver-ref="shibboleth.SignatureSigningParametersResolver"
+            p:noResultIsError="false">
+        <property name="activationCondition">
+            <bean class="net.shibboleth.saml.profile.config.logic.SignResponsesPredicate" />
+        </property>
+    </bean>
+    
+    <bean id="AddLogoutResponse"
+        class="net.shibboleth.sp.saml.saml2.profile.impl.AddLogoutResponse" scope="prototype" />
     
 </beans>
diff --git a/sp-saml-conf-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/sp/logout/consumer/saml2/saml2-flow.xml b/sp-saml-conf-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/sp/logout/consumer/saml2/saml2-flow.xml
index ea8b2e8..d424765 100644
--- a/sp-saml-conf-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/sp/logout/consumer/saml2/saml2-flow.xml
+++ b/sp-saml-conf-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/sp/logout/consumer/saml2/saml2-flow.xml
@@ -67,16 +67,31 @@
     
     <!-- This branch uses the "token" from the Agent to generate a LogoutResponse outbound. -->
     <action-state id="IssueResponse">
-        <!-- TODO -->
+        <evaluate expression="ProcessLogoutTokenRequest" />
+        <evaluate expression="PrepareInboundMessageContext" />
+        <evaluate expression="SAMLProtocolAndRole" />
+        <evaluate expression="SAMLMetadataLookup" />
+        <evaluate expression="InitializeRelyingPartyContextFromSAMLPeer" />
+        <evaluate expression="SelectRelyingPartyConfiguration" />
+        <evaluate expression="SelectProfileConfiguration" />
+        <evaluate expression="InitializeOutboundMessageContext" />
+        <evaluate expression="InitializeMessageChannelSecurityContext" />
+        <evaluate expression="PopulateBindingAndEndpointContexts" />
+        <evaluate expression="PopulateResponseSignatureSigningParameters" />
+        <evaluate expression="AddLogoutResponse" />
+        <evaluate expression="HandleOutboundMessage" />
+        <evaluate expression="EncodeMessage" />
         <evaluate expression="'proceed'" />
         
         <transition on="proceed" to="proceed" />
     </action-state>
     
+    <end-state id="EndpointResolutionFailed" />
+    
     <global-transitions>
+        <transition on="EndpointResolutionFailed" to="EndpointResolutionFailed" />
         <!-- Remap some internal action errors. -->
         <transition on="InvalidCredentials" to="InvalidMessage" />
-        <transition on="UnableToValidateAssertion" to="MessageProcessingError" />
     </global-transitions>
     
     <!-- The file really exists in this directory, but it's referenced from extending flow-directories -->
diff --git a/sp-saml-conf-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/sp/saml2-common-beans.xml b/sp-saml-conf-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/sp/saml2-common-beans.xml
index 9b4378c..bf7d07e 100644
--- a/sp-saml-conf-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/sp/saml2-common-beans.xml
+++ b/sp-saml-conf-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/sp/saml2-common-beans.xml
@@ -54,6 +54,9 @@
         </constructor-arg>
     </bean>
 
+    <bean id="InitializeRelyingPartyContextFromSAMLPeer"
+        class="net.shibboleth.idp.saml.profile.impl.InitializeRelyingPartyContextFromSAMLPeer" scope="prototype" />
+
     <bean id="InitializeOutboundMessageContext"
         class="net.shibboleth.idp.saml.profile.impl.InitializeOutboundMessageContext" scope="prototype"
         p:selfIdentityLookupStrategy-ref="shibboleth.IssuerLookup.Simple" />
@@ -63,15 +66,22 @@
         p:confidentialityActive="false"
         p:integrityActive="false" />
 
+    <bean id="SelectRelyingPartyConfiguration"
+        class="net.shibboleth.sp.profile.impl.SelectRelyingPartyConfiguration" scope="prototype" />
+
+    <bean id="SelectProfileConfiguration"
+        class="net.shibboleth.idp.profile.impl.SelectProfileConfiguration" scope="prototype"
+        p:profileId-ref="shiibboleth.sp.ProfileId" />
+
+    <bean id="OutgoingSAML2SPRequestBindingsStrategy" parent="shibboleth.Functions.Constant"
+        c:_0-ref="OutgoingSAML2SPRequestBindings" />
+
     <util:list id="OutgoingSAML2SPRequestBindings">
         <ref bean="shibboleth.Binding.SAML2Redirect" />
         <ref bean="shibboleth.Binding.SAML2POST" />
         <ref bean="shibboleth.Binding.SAML2POSTSimpleSign" />
     </util:list>
 
-    <bean id="OutgoingSAML2SPRequestBindingsStrategy" parent="shibboleth.Functions.Constant"
-        c:_0-ref="OutgoingSAML2SPRequestBindings" />
-
     <bean id="PopulateBindingAndEndpointContexts"
         class="net.shibboleth.idp.saml.profile.impl.PopulateBindingAndEndpointContexts" scope="prototype"
         p:endpointResolver-ref="shibboleth.EndpointResolver"
@@ -79,17 +89,6 @@
         p:bindingDescriptorsLookupStrategy-ref="OutgoingSAML2SPRequestBindingsStrategy"
         p:artifactImpliesSecureChannel="%{sp.artifact.secureChannel:true}" />
 
-
-    <bean id="InitializeRelyingPartyContextFromSAMLPeer"
-        class="net.shibboleth.idp.saml.profile.impl.InitializeRelyingPartyContextFromSAMLPeer" scope="prototype" />
-
-    <bean id="SelectRelyingPartyConfiguration"
-        class="net.shibboleth.sp.profile.impl.SelectRelyingPartyConfiguration" scope="prototype" />
-
-    <bean id="SelectProfileConfiguration"
-        class="net.shibboleth.idp.profile.impl.SelectProfileConfiguration" scope="prototype"
-        p:profileId-ref="shiibboleth.sp.ProfileId" />
-
     <bean id="PopulateInboundMessageContextWithSAMLSelf"
         class="net.shibboleth.idp.saml.profile.impl.PopulateInboundMessageContextWithSAMLSelf" scope="prototype" />
     
diff --git a/sp-saml-conf-impl/src/test/java/net/shibboleth/sp/saml/flows/saml2/SAML2LogoutConsumerFlowTest.java b/sp-saml-conf-impl/src/test/java/net/shibboleth/sp/saml/flows/saml2/SAML2LogoutConsumerFlowTest.java
index 58dfcca..6259564 100644
--- a/sp-saml-conf-impl/src/test/java/net/shibboleth/sp/saml/flows/saml2/SAML2LogoutConsumerFlowTest.java
+++ b/sp-saml-conf-impl/src/test/java/net/shibboleth/sp/saml/flows/saml2/SAML2LogoutConsumerFlowTest.java
@@ -224,6 +224,7 @@ public class SAML2LogoutConsumerFlowTest extends AbstractSPFlowTest {
      */
     @AfterClass
     public void tearDown() {
+        dummyStateManager.destroy();
         stateManager.destroy();
         cookieManager.destroy();
     }
@@ -390,7 +391,6 @@ public class SAML2LogoutConsumerFlowTest extends AbstractSPFlowTest {
         };
     }
     
-    
     /**
      * Examine a flow response to a valid LogoutRequest for accuracy.
      * 
diff --git a/sp-saml-conf-impl/src/test/java/net/shibboleth/sp/saml/flows/saml2/SAML2LogoutConsumerTokenFlowTest.java b/sp-saml-conf-impl/src/test/java/net/shibboleth/sp/saml/flows/saml2/SAML2LogoutConsumerTokenFlowTest.java
new file mode 100644
index 0000000..24402a3
--- /dev/null
+++ b/sp-saml-conf-impl/src/test/java/net/shibboleth/sp/saml/flows/saml2/SAML2LogoutConsumerTokenFlowTest.java
@@ -0,0 +1,368 @@
+/*
+ * 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.saml.flows.saml2;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.time.Instant;
+import java.util.List;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.opensaml.messaging.context.MessageContext;
+import org.opensaml.messaging.decoder.MessageDecodingException;
+import org.opensaml.profile.action.EventIds;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.opensaml.saml.common.SAMLObject;
+import org.opensaml.saml.common.profile.SAMLEventIds;
+import org.opensaml.saml.saml2.binding.decoding.impl.HTTPRedirectDeflateDecoder;
+import org.opensaml.saml.saml2.core.Issuer;
+import org.opensaml.saml.saml2.core.LogoutResponse;
+import org.opensaml.saml.saml2.core.Status;
+import org.opensaml.saml.saml2.core.StatusCode;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.test.context.ContextConfiguration;
+import org.springframework.test.context.web.WebAppConfiguration;
+import org.springframework.webflow.executor.FlowExecutionResult;
+import org.testng.Assert;
+import org.testng.annotations.AfterClass;
+import org.testng.annotations.BeforeClass;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
+
+import net.shibboleth.idp.authn.AuthnEventIds;
+import net.shibboleth.idp.profile.IdPEventIds;
+import net.shibboleth.idp.spring.IdPPropertiesApplicationContextInitializer;
+import net.shibboleth.idp.test.PreferFileSystemApplicationContextInitializer;
+import net.shibboleth.shared.collection.Pair;
+import net.shibboleth.shared.component.ComponentInitializationException;
+import net.shibboleth.shared.net.URISupport;
+import net.shibboleth.shared.primitive.NonnullSupplier;
+import net.shibboleth.shared.resolver.CriteriaSet;
+import net.shibboleth.shared.resolver.ResolverException;
+import net.shibboleth.shared.security.DataSealer;
+import net.shibboleth.shared.service.ReloadableService;
+import net.shibboleth.shared.service.ServiceableComponent;
+import net.shibboleth.sp.Agent;
+import net.shibboleth.sp.AgentCriterion;
+import net.shibboleth.sp.AgentResolver;
+import net.shibboleth.sp.Application;
+import net.shibboleth.sp.context.AgentRequestContext;
+import net.shibboleth.sp.ddf.DDF;
+import net.shibboleth.sp.flows.AbstractSPFlowTest;
+import net.shibboleth.sp.messaging.RemotedHttpServletResponse;
+import net.shibboleth.sp.profile.ConsumerConstants;
+import net.shibboleth.sp.saml.saml2.SAMLStateData;
+import net.shibboleth.sp.saml.saml2.profile.impl.ProcessLogoutRequest;
+import net.shibboleth.sp.state.impl.PassthroughStateManager;
+
+/**
+ * Unit test for the SP logout-consumer flow in "token" mode, which issues responses.
+ */
+ at ContextConfiguration(
+        locations = {
+                "classpath:/net/shibboleth/sp/saml-test-beans.xml",
+                },
+        initializers = {
+                TestSPSAMLEnvironmentApplicationContextInitializer.class,
+                PreferFileSystemApplicationContextInitializer.class,
+                IdPPropertiesApplicationContextInitializer.class
+                },
+        inheritInitializers = false
+        )
+ at WebAppConfiguration
+public class SAML2LogoutConsumerTokenFlowTest extends AbstractSPFlowTest {
+    
+    /** Flow ID. */
+    @Nonnull public static final String FLOW_ID = "sp/logout-consumer";
+
+    /** Issuer value. */
+    @Nonnull public static final String ISSUER = "https://testsp.example.org";
+
+    /** No endpoint Issuer value. */
+    @Nonnull public static final String NO_ENDPOINT_ISSUER = "https://noendpoint.example.org";
+    
+    @Autowired
+    @Qualifier("shibboleth.DataSealer")
+    protected DataSealer dataSealer;
+    
+    @Autowired
+    @Qualifier("shibboleth.sp.AgentResolver")
+    protected ReloadableService<AgentResolver> agentResolver;
+    
+    // Used to produce "token" expected by flow.
+    private PassthroughStateManager dummyStateManager;
+        
+    /** Constructor. */
+    protected SAML2LogoutConsumerTokenFlowTest() {
+        super(FLOW_ID);
+    }
+
+    /**
+     * Set up state manager.
+     * 
+     * @throws ComponentInitializationException
+     */
+    @BeforeClass
+    public void beforeClass() throws ComponentInitializationException {        
+        final ObjectMapper mapper = new ObjectMapper();
+        mapper.registerModule(new JavaTimeModule());
+
+        dummyStateManager = new PassthroughStateManager();
+        dummyStateManager.setId("test");
+        dummyStateManager.setObjectMapper(mapper);
+        dummyStateManager.setDataSealer(dataSealer);
+        dummyStateManager.initialize();
+    }
+    
+    
+    /**
+     * Tear down state manager.
+     */
+    @AfterClass
+    public void tearDown() {
+        dummyStateManager.destroy();
+    }
+
+    /** Pre-test work. */
+    @BeforeMethod
+    public void beforeMethod() {
+        setDefaultAuth();
+    }
+        
+    /**
+     * Test flow with unverified (no metadata) IdP specified.
+     * 
+     * @throws IOException 
+     */
+    @Test
+    public void testNoToken() throws IOException {
+        final DDF input = new DDF(null).structure();
+        setApplicationRequest("default", input);
+
+        final FlowExecutionResult result = flowExecutor.launchExecution(FLOW_ID, null, externalContext);
+        assertFlowExecutionResult(result, FLOW_ID);
+        assertFlowExecutionOutcome(result.getOutcome());
+        assertOutputMessageEvent(result, AuthnEventIds.NO_POTENTIAL_FLOW);
+    }
+
+    /**
+     * Test flow with token without proper prefix.
+     * 
+     * @throws IOException 
+     */
+    @Test
+    public void testUnprefixedToken() throws IOException {
+        final DDF input = new DDF(null).structure();
+        input.addmember(ConsumerConstants.TOKEN_PARAM).string("foo");
+        setApplicationRequest("default", input);
+
+        final FlowExecutionResult result = flowExecutor.launchExecution(FLOW_ID, null, externalContext);
+        assertFlowExecutionResult(result, FLOW_ID);
+        assertFlowExecutionOutcome(result.getOutcome());
+        assertOutputMessageEvent(result, AuthnEventIds.NO_POTENTIAL_FLOW);
+    }
+
+    /**
+     * Test flow with bad token.
+     * 
+     * @throws IOException 
+     */
+    @Test
+    public void testBadToken() throws IOException {
+        final DDF input = new DDF(null).structure();
+        input.addmember(ConsumerConstants.TOKEN_PARAM).string(ProcessLogoutRequest.TOKEN_PREFIX + "foo");
+        setApplicationRequest("default", input);
+
+        final FlowExecutionResult result = flowExecutor.launchExecution(FLOW_ID, null, externalContext);
+        assertFlowExecutionResult(result, FLOW_ID);
+        assertFlowExecutionOutcome(result.getOutcome());
+        assertOutputMessageEvent(result, EventIds.UNABLE_TO_DECODE);
+    }
+    
+    /**
+     * Test flow with profile disallowed.
+     * 
+     * @throws Exception 
+     */
+    @Test
+    public void testProfileDisabled() throws Exception {
+        final DDF input = new DDF(null).structure();
+        input.addmember(ConsumerConstants.TOKEN_PARAM).string(buildToken(ISSUER, "12345", null));
+        setApplicationRequest("no-profile", input);
+
+        final FlowExecutionResult result = flowExecutor.launchExecution(FLOW_ID, null, externalContext);
+        assertFlowExecutionResult(result, FLOW_ID);
+        assertFlowExecutionOutcome(result.getOutcome());
+        assertOutputMessageEvent(result, IdPEventIds.INVALID_PROFILE_CONFIG);
+    }
+
+    /**
+     * Test flow with no valid endpoint
+     * 
+     * @throws Exception 
+     */
+    @Test
+    public void testNoEndpoint() throws Exception {
+        final DDF input = new DDF(null).structure();
+        input.addmember(ConsumerConstants.TOKEN_PARAM).string(buildToken(NO_ENDPOINT_ISSUER, "12345", null));
+        setApplicationRequest("default", input);
+
+        final FlowExecutionResult result = flowExecutor.launchExecution(FLOW_ID, null, externalContext);
+        assertFlowExecutionResult(result, FLOW_ID);
+        assertFlowExecutionOutcome(result.getOutcome());
+        assertOutputMessageEvent(result, SAMLEventIds.ENDPOINT_RESOLUTION_FAILED);
+    }
+
+    /**
+     * Decode an encoded response and run sanity checks against it.
+     * 
+     * @param result flow execution result
+     * @param statusCode status code to check for
+     * 
+     * @return the response object
+     * 
+     * @throws MessageDecodingException
+     */
+    @Nonnull private LogoutResponse validateOutputMessage(@Nonnull final FlowExecutionResult result,
+            @Nullable final String statusCode) throws MessageDecodingException {
+        
+        final ProfileRequestContext prc = retrieveProfileRequestContext(result);
+        assert prc != null;
+        final AgentRequestContext arc = prc.ensureSubcontext(AgentRequestContext.class);
+        final DDF input = arc.getInput();
+        final DDF output = arc.getOutput();
+        assert input != null;
+        assert output != null;
+        Assert.assertTrue(output.isstruct());
+        final DDF http = output.getmember(RemotedHttpServletResponse.STRUCTURE_NAME);
+        Assert.assertTrue(http.isstruct());
+        
+        final LogoutResponse logoutResponse;
+        final byte[] redirect = http.getmember(RemotedHttpServletResponse.REDIRECT).unsafe_string();
+        if (redirect != null) {
+            final String redirectURL = new String(redirect, StandardCharsets.UTF_8);
+            final SAMLObject saml = decodeRedirect(redirectURL);
+            assert saml instanceof LogoutResponse;
+            logoutResponse = (LogoutResponse) saml;
+            Assert.assertTrue(redirectURL.startsWith(logoutResponse.getDestination()));
+        } else {
+            final byte[] body = http.getmember("response.data").unsafe_string();
+            Assert.assertNotNull(body);
+            // Not trivial to consider parsing the form, so just bypass that step.
+            final Object saml = prc.ensureOutboundMessageContext().ensureMessage();
+            assert saml instanceof LogoutResponse;
+            logoutResponse = (LogoutResponse) saml;
+        }
+        
+        assert logoutResponse != null;
+        Assert.assertNotNull(logoutResponse.getID());
+        Assert.assertTrue(Instant.now().isAfter(logoutResponse.getIssueInstant()));
+        final Issuer issuer = logoutResponse.getIssuer();
+        assert issuer != null;
+        Assert.assertEquals(issuer.getValue(), ISSUER);
+        
+        final Status status = logoutResponse.getStatus();
+        assert status != null;
+        
+        final StatusCode code = status.getStatusCode();
+        assert code != null;
+        Assert.assertEquals(code.getValue(), statusCode);
+        
+        return logoutResponse;
+    }
+    
+    /**
+     * Decodes a SAML message encoded via HTTP-Redirect binding.
+     * 
+     * @param url the encoded redirect
+     * 
+     * @return decoded message
+     * @throws MessageDecodingException 
+     */
+    @Nonnull protected SAMLObject decodeRedirect(@Nullable final String url)
+            throws MessageDecodingException {
+        final MockHttpServletRequest mock = new MockHttpServletRequest("GET", url);
+        final int index = url != null ? url.indexOf('?') : -1;
+        if (url == null || index < 0) {
+            throw new MessageDecodingException("No query string");
+        }
+        final List<Pair<String,String>> params = URISupport.parseQueryString(url.substring(index + 1));
+        for (final var param : params) {
+            final String name = param.getFirst();
+            if (name != null) {
+                mock.addParameter(name, param.getSecond());
+            }
+        }
+        
+        final HTTPRedirectDeflateDecoder decoder = new HTTPRedirectDeflateDecoder();
+        decoder.setHttpServletRequestSupplier(NonnullSupplier.of(mock));
+        decoder.setParserPool(parserPool);
+        
+        try {
+            decoder.initialize();
+        } catch (final ComponentInitializationException e) {
+            throw new MessageDecodingException(e);
+        }
+        
+        decoder.decode();
+        
+        final MessageContext mc = decoder.getMessageContext();
+        decoder.destroy();
+        
+        if (mc != null && mc.getMessage() instanceof SAMLObject saml) {
+            return saml;
+        }
+        throw new MessageDecodingException("No message, or incorrect type.");
+    }
+
+
+    /**
+     * Create token parameter from state fields.
+     * 
+     * @param entityID issuer
+     * @param requestID request ID
+     * @param relayState relay state
+     * 
+     * @return token string
+     * 
+     * @throws ResolverException 
+     * @throws IOException 
+     */
+    @Nonnull private String buildToken(@Nullable final String entityID, @Nullable final String requestID,
+            @Nullable final String relayState) throws ResolverException, IOException {
+        
+        final SAMLStateData data = new SAMLStateData();
+        data.setAuthenticationAuthority(entityID);
+        data.setRequestID(requestID);
+        data.setResource(relayState);
+        
+        try (final ServiceableComponent<AgentResolver> resolver = agentResolver.getServiceableComponent()) {
+            final Agent agent = resolver.getComponent().resolveSingle(
+                    new CriteriaSet(new AgentCriterion(AGENT_ID)));
+            assert agent != null;
+            final Application app = agent.getApplication(APPLICATION_ID);
+            assert app != null;
+            return ProcessLogoutRequest.TOKEN_PREFIX + dummyStateManager.preserveToStateToken(agent, app, data);
+        }
+    }
+    
+}
\ No newline at end of file
diff --git a/sp-saml-impl/src/main/java/net/shibboleth/sp/saml/saml2/profile/impl/AddLogoutResponse.java b/sp-saml-impl/src/main/java/net/shibboleth/sp/saml/saml2/profile/impl/AddLogoutResponse.java
new file mode 100644
index 0000000..d63c925
--- /dev/null
+++ b/sp-saml-impl/src/main/java/net/shibboleth/sp/saml/saml2/profile/impl/AddLogoutResponse.java
@@ -0,0 +1,272 @@
+/*
+ * 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.saml.saml2.profile.impl;
+
+import java.time.Instant;
+import java.util.function.Function;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import net.shibboleth.idp.profile.IdPEventIds;
+import net.shibboleth.profile.config.navigate.IdentifierGenerationStrategyLookupFunction;
+import net.shibboleth.profile.context.RelyingPartyContext;
+import net.shibboleth.profile.context.navigate.IssuerLookupFunction;
+import net.shibboleth.shared.annotation.constraint.NonnullBeforeExec;
+import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.security.IdentifierGenerationStrategy;
+import net.shibboleth.sp.context.StateDataContext;
+import net.shibboleth.sp.ddf.DDF;
+import net.shibboleth.sp.profile.AbstractApplicationAction;
+import net.shibboleth.sp.profile.ConsumerConstants;
+import net.shibboleth.sp.profile.SPConstants;
+import net.shibboleth.sp.saml.saml2.SAMLStateData;
+import net.shibboleth.sp.saml.saml2.context.SAMLLogoutContext;
+import net.shibboleth.sp.saml.saml2.profile.config.SingleLogoutProfileConfiguration;
+import net.shibboleth.sp.state.StateData;
+
+import org.opensaml.core.xml.XMLObjectBuilderFactory;
+import org.opensaml.core.xml.config.XMLObjectProviderRegistrySupport;
+import org.opensaml.core.xml.io.MarshallingException;
+import org.opensaml.core.xml.io.UnmarshallingException;
+import org.opensaml.core.xml.util.XMLObjectSupport;
+import org.opensaml.messaging.context.MessageContext;
+import org.opensaml.messaging.context.navigate.ChildContextLookup;
+import org.opensaml.profile.action.ActionSupport;
+import org.opensaml.profile.action.EventIds;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.opensaml.saml.common.SAMLObjectBuilder;
+import org.opensaml.saml.common.SAMLVersion;
+import org.opensaml.saml.common.binding.SAMLBindingSupport;
+import org.opensaml.saml.ext.saml2aslo.Asynchronous;
+import org.opensaml.saml.saml2.core.LogoutRequest;
+import org.opensaml.saml.saml2.core.LogoutResponse;
+import org.opensaml.saml.saml2.core.Extensions;
+import org.opensaml.saml.saml2.core.Issuer;
+import org.opensaml.saml.saml2.core.NameID;
+import org.opensaml.saml.saml2.core.SessionIndex;
+import org.opensaml.saml.saml2.core.Status;
+import org.opensaml.saml.saml2.core.StatusCode;
+import org.slf4j.Logger;
+import net.shibboleth.shared.primitive.LoggerFactory;
+
+/**
+ * Action that creates an {@link LogoutResponse} and sets it as the message returned by
+ * {@link ProfileRequestContext#getOutboundMessageContext()}.
+ * 
+ * <p>If an issuer value is returned via a lookup strategy, then it's set as the Issuer of the message.</p>
+ * 
+ * <p>A {@link StateDataContext} containing a {@link SAMLStateData} objeect must be present to provide the
+ * necessary information to populate into the response.</p>
+ * 
+ * @event {@link EventIds#PROCEED_EVENT_ID}
+ * @event {@link EventIds#IO_ERROR}
+ * @event {@link EventIds#INVALID_MSG_CTX}
+ * @event {@link EventIds#INVALID_PROFILE_CTX}
+ * @event {@link IdPEventIds#INVALID_PROFILE_CONFIG}
+ * 
+ * @pre <pre>ProfileRequestContext.getSubcontext(StateDataContext.class).getStateData() instanceof SAMLStateData</pre>
+ * @post <pre>ProfileRequestContext.getOutboundMessageContext().getMessage() != null</pre>
+ */
+public class AddLogoutResponse extends AbstractApplicationAction {
+
+    /** Class logger. */
+    @Nonnull private Logger log = LoggerFactory.getLogger(AddLogoutResponse.class);
+    
+    /** Overwrite an existing message? */
+    private boolean overwriteExisting;
+
+    /** Strategy used to locate the {@link IdentifierGenerationStrategy} to use. */
+    @Nonnull private Function<ProfileRequestContext,IdentifierGenerationStrategy> idGeneratorLookupStrategy;
+    
+    /** Strategy used to locate the {@link StateDataContext} to read from. */
+    @Nonnull private Function<ProfileRequestContext,StateDataContext> stateDataContextLookupStrategy;
+    
+    /** Strategy used to obtain the request issuer value. */
+    @Nullable private Function<ProfileRequestContext,String> issuerLookupStrategy;
+    
+    /** The generator to use. */
+    @NonnullBeforeExec private IdentifierGenerationStrategy idGenerator;
+    
+    /** Applicable profile configuration. */
+    @NonnullBeforeExec private SingleLogoutProfileConfiguration profileConfiguration;
+    
+    /** Cached state data object to read from. */
+    @NonnullBeforeExec private SAMLStateData stateData;
+    
+    /** EntityID to populate into Issuer element. */
+    @Nullable private String issuerId;
+    
+    /** Constructor. */
+    public AddLogoutResponse() {
+        // Default strategy is a 16-byte secure random source.
+        idGeneratorLookupStrategy = new IdentifierGenerationStrategyLookupFunction();
+        stateDataContextLookupStrategy = new ChildContextLookup<>(StateDataContext.class);
+        issuerLookupStrategy = new IssuerLookupFunction();
+    }
+        
+    /**
+     * Set whether to overwrite an existing message.
+     * 
+     * @param flag flag to set
+     */
+    public void setOverwriteExisting(final boolean flag) {
+        checkSetterPreconditions();
+        overwriteExisting = flag;
+    }
+
+    /**
+     * Set the strategy used to locate the {@link IdentifierGenerationStrategy} to use.
+     * 
+     * @param strategy lookup strategy
+     */
+    public void setIdentifierGeneratorLookupStrategy(
+            @Nonnull final Function<ProfileRequestContext,IdentifierGenerationStrategy> strategy) {
+        checkSetterPreconditions();
+        idGeneratorLookupStrategy =
+                Constraint.isNotNull(strategy, "IdentifierGenerationStrategy lookup strategy cannot be null");
+    }
+
+    /**
+     * Sets the strategy used to locate the {@link StateDataContext}.
+     * 
+     * @param strategy lookup strategy
+     */
+    public void setStateDataContextCreationStrategy(
+            @Nonnull final Function<ProfileRequestContext,StateDataContext> strategy) {
+        checkSetterPreconditions();
+        stateDataContextLookupStrategy =
+                Constraint.isNotNull(strategy, "StateDataContext lookup strategy cannot be null");
+    }
+    
+    /**
+     * Set the strategy used to locate the issuer value to use.
+     * 
+     * @param strategy lookup strategy
+     */
+    public void setIssuerLookupStrategy(@Nullable final Function<ProfileRequestContext,String> strategy) {
+        checkSetterPreconditions();
+        issuerLookupStrategy = strategy;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected boolean doPreExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+
+        if (!super.doPreExecute(profileRequestContext)) {
+            return false;
+        }
+                
+        final RelyingPartyContext rpCtx = profileRequestContext.getSubcontext(RelyingPartyContext.class);
+        if (rpCtx != null && rpCtx.getProfileConfig() instanceof SingleLogoutProfileConfiguration slo) {
+            profileConfiguration = slo;
+        }
+        if (profileConfiguration == null) {
+            log.error("{} SingleLogoutProfileConfiguration not found", getLogPrefix());
+            ActionSupport.buildEvent(profileRequestContext, IdPEventIds.INVALID_PROFILE_CONFIG);
+            return false;
+        }
+        
+        final MessageContext outboundMessageCtx = profileRequestContext.getOutboundMessageContext();
+        if (outboundMessageCtx == null) {
+            log.debug("{} No outbound message context", getLogPrefix());
+            ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_MSG_CTX);
+            return false;
+        } else if (!overwriteExisting && outboundMessageCtx.getMessage() != null) {
+            log.debug("{} Outbound message context already contains a message", getLogPrefix());
+            ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_MSG_CTX);
+            return false;
+        }
+
+        idGenerator = idGeneratorLookupStrategy.apply(profileRequestContext);
+        if (idGenerator == null) {
+            log.debug("{} No identifier generation strategy", getLogPrefix());
+            ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_PROFILE_CTX);
+            return false;
+        }
+        
+        final StateDataContext stateDataContext = stateDataContextLookupStrategy.apply(profileRequestContext);
+        if (stateDataContext != null && stateDataContext.getStateData() instanceof SAMLStateData samlState) {
+            stateData = samlState;
+        } else {
+            log.error("{} SAMLStateData could not be located", getLogPrefix());
+            ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_PROFILE_CTX);
+            return false;
+        }
+        
+        if (issuerLookupStrategy != null) {
+            issuerId = issuerLookupStrategy.apply(profileRequestContext);
+        }
+
+        outboundMessageCtx.setMessage(null);
+        
+        return true;
+    }
+
+// Checkstyle: MethodLength OFF
+    /** {@inheritDoc} */
+    @Override
+    protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+        
+        final XMLObjectBuilderFactory bf = XMLObjectProviderRegistrySupport.getBuilderFactory();
+        final SAMLObjectBuilder<LogoutResponse> responseBuilder =
+                (SAMLObjectBuilder<LogoutResponse>) bf.<LogoutResponse>ensureBuilder(
+                        LogoutResponse.DEFAULT_ELEMENT_NAME);
+        final SAMLObjectBuilder<Status> statusBuilder =
+                (SAMLObjectBuilder<Status>) bf.<Status>ensureBuilder(
+                        Status.DEFAULT_ELEMENT_NAME);
+        final SAMLObjectBuilder<StatusCode> codeBuilder =
+                (SAMLObjectBuilder<StatusCode>) bf.<StatusCode>ensureBuilder(
+                        StatusCode.DEFAULT_ELEMENT_NAME);
+
+        final LogoutResponse object = responseBuilder.buildObject();
+        object.setID(idGenerator.generateIdentifier());
+        object.setIssueInstant(Instant.now());
+        object.setVersion(SAMLVersion.VERSION_20);
+        object.setInResponseTo(stateData.getRequestID());
+        
+        if (issuerId != null) {
+            log.debug("{} Setting Issuer to {}", getLogPrefix(), issuerId);
+            final SAMLObjectBuilder<Issuer> issuerBuilder =
+                    (SAMLObjectBuilder<Issuer>) bf.<Issuer>ensureBuilder(Issuer.DEFAULT_ELEMENT_NAME);
+            final Issuer issuer = issuerBuilder.buildObject();
+            issuer.setValue(issuerId);
+            object.setIssuer(issuer);
+        } else {
+            log.debug("{} No issuer value available, leaving Issuer unset", getLogPrefix());
+        }
+        
+        final Status status = statusBuilder.buildObject();
+        final StatusCode code = codeBuilder.buildObject();
+        
+        final DDF input = ensureAgentRequestContext().getInput();
+        if (input != null && input.getmember(ConsumerConstants.SUCCESS_PARAM).integer() == 1) {
+            code.setValue(StatusCode.SUCCESS);
+        } else {
+            code.setValue(StatusCode.RESPONDER);
+        }
+        
+        status.setStatusCode(code);
+        object.setStatus(status);
+        
+        profileRequestContext.ensureOutboundMessageContext().setMessage(object);
+        
+        SAMLBindingSupport.setRelayState(profileRequestContext.ensureOutboundMessageContext(), stateData.getResource());
+        
+        log.info("{} Generated LogoutResponse with ID {} from {} in response to LogoutRequest with ID {}",
+                getLogPrefix(), object.getID(), issuerId, stateData.getRequestID());
+    }
+    
+}
\ 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