[java-plugin-shibd-saml] 01/02: Implement LogoutResponse handling in logout consumer flow with tests.

Codeberg noreply at shibboleth.net
Tue May 26 18:50:57 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/356165c9b869ffd7a8b19eae86d73423f1fdf7ec

commit 356165c9b869ffd7a8b19eae86d73423f1fdf7ec
Author: Scott Cantor <scott at restingparrotsoftware.com>
AuthorDate: Tue May 26 13:30:28 2026 -0400

    Implement LogoutResponse handling in logout consumer flow with tests.
---
 .../flows/sp/logout/consumer/saml2/saml2-beans.xml |  79 ++++----
 .../flows/sp/logout/consumer/saml2/saml2-flow.xml  |  11 +-
 .../flows/saml2/SAML2LogoutConsumerFlowTest.java   | 141 +++++++++++++-
 .../saml2/profile/impl/ProcessLogoutRequest.java   |   2 +-
 .../saml2/profile/impl/ProcessLogoutResponse.java  | 197 ++++++++++++++++++++
 .../profile/impl/ProcessLogoutRequestTest.java     |  26 +--
 .../profile/impl/ProcessLogoutRespoonseTest.java   | 203 +++++++++++++++++++++
 7 files changed, 597 insertions(+), 62 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 ab04194..01af207 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
@@ -12,34 +12,35 @@
 
     <import resource="classpath:/META-INF/net/shibboleth/idp/flows/sp/saml2-common-beans.xml" />
 
-    <bean id="RecoverStateData"
-        class="net.shibboleth.sp.profile.impl.RecoverStateData" scope="prototype"
-        p:stateTokenLookupStrategy-ref="RelayStateLookup"
-        p:stateDataClass="net.shibboleth.sp.saml.saml2.SAMLStateData" />
+    <!-- Only applied to LogoutResponse messages. -->
+    
+    <bean id="CreateServletResponseForLogoutResponse"
+            class="net.shibboleth.sp.profile.impl.CreateOutputMessage" scope="prototype"
+            p:createServletResponse="true">
+        <property name="activationCondition">
+            <bean parent="shibboleth.Conditions.NOT" c:_0-ref="IsLogoutRequest" />
+        </property>
+    </bean>
+    
+    <bean id="RecoverStateDataForLogoutResponse"
+            class="net.shibboleth.sp.profile.impl.RecoverStateData" scope="prototype"
+            p:stateTokenLookupStrategy-ref="RelayStateLookup"
+            p:stateDataClass="net.shibboleth.sp.saml.saml2.SAMLStateData">
+        <property name="activationCondition">
+            <bean parent="shibboleth.Conditions.NOT" c:_0-ref="IsLogoutRequest" />
+        </property>
+    </bean>
 
     <bean id="RelayStateLookup" parent="shibboleth.Functions.Compose">
         <constructor-arg name="g">
             <bean parent="shibboleth.Functions.Expression"
-                c:_0="#input != null ? T(org.opensaml.saml.common.binding.SAMLBindingSupport).getRelayState(#input) : null" />
+                c:_0="T(org.opensaml.saml.common.binding.SAMLBindingSupport).getRelayState(#input)" />
         </constructor-arg>
         <constructor-arg name="f" ref="shibboleth.MessageContextLookup.Inbound"/>
     </bean>
-
-    <bean id="HandleNoPassive" class="net.shibboleth.sp.profile.impl.WebFlowMessageHandlerAdaptor" scope="prototype"
-            c:executionDirection="INBOUND">
-        <constructor-arg>
-            <bean class="org.opensaml.messaging.handler.impl.BasicMessageHandlerChain">
-                <property name="handlers">
-                    <list>
-                        <bean class="org.opensaml.saml.saml2.binding.impl.CheckNoPassiveHandler" scope="prototype" />
-                    </list>
-                </property>
-             </bean>
-        </constructor-arg>
-        <property name="errorEvent">
-            <util:constant static-field="net.shibboleth.idp.authn.AuthnEventIds.NO_PASSIVE" />
-        </property>
-    </bean>
+    
+    <bean id="IsLogoutRequest" parent="shibboleth.Conditions.Expression"
+        c:_0="#input.ensureInboundMessageContext().getMessage() instanceof T(org.opensaml.saml.saml2.core.LogoutRequest)" />
 
     <bean id="CheckDestinationAndIssuerHandler" class="net.shibboleth.sp.profile.impl.WebFlowMessageHandlerAdaptor" scope="prototype"
             c:executionDirection="INBOUND">
@@ -59,22 +60,6 @@
         </property>
     </bean>
 
-    <bean id="InboundEntityIDLookup" parent="shibboleth.Functions.Compose">
-        <constructor-arg name="g">
-            <bean class="org.opensaml.saml.common.messaging.context.navigate.SAMLEntityIDFunction" />
-        </constructor-arg>
-        <constructor-arg name="f">
-            <bean class="org.opensaml.messaging.context.navigate.ChildContextLookup"
-                  c:type="#{ T(org.opensaml.saml.common.messaging.context.SAMLPeerEntityContext) }" />
-        </constructor-arg>
-    </bean>
-
-    <bean id="NotIgnoreRequestSignaturesPredicate" parent="shibboleth.Conditions.NOT">
-        <constructor-arg>
-            <bean class="net.shibboleth.saml.saml2.profile.config.logic.IgnoreRequestSignaturesPredicate" />
-        </constructor-arg>
-    </bean>
-
     <bean id="HandleMessageEarly" class="net.shibboleth.sp.profile.impl.WebFlowMessageHandlerAdaptor" scope="prototype"
             c:executionDirection="INBOUND">
         <constructor-arg>
@@ -104,6 +89,12 @@
         </property>
     </bean>
 
+    <bean id="NotIgnoreRequestSignaturesPredicate" parent="shibboleth.Conditions.NOT">
+        <constructor-arg>
+            <bean class="net.shibboleth.saml.saml2.profile.config.logic.IgnoreRequestSignaturesPredicate" />
+        </constructor-arg>
+    </bean>
+
     <bean id="CheckSignature" class="net.shibboleth.sp.profile.impl.WebFlowMessageHandlerAdaptor" scope="prototype"
             c:executionDirection="INBOUND"
             p:activationCondition-ref="NotIgnoreRequestSignaturesPredicate">
@@ -129,6 +120,16 @@
         </property>
     </bean>
 
+    <bean id="InboundEntityIDLookup" parent="shibboleth.Functions.Compose">
+        <constructor-arg name="g">
+            <bean class="org.opensaml.saml.common.messaging.context.navigate.SAMLEntityIDFunction" />
+        </constructor-arg>
+        <constructor-arg name="f">
+            <bean class="org.opensaml.messaging.context.navigate.ChildContextLookup"
+                  c:type="#{ T(org.opensaml.saml.common.messaging.context.SAMLPeerEntityContext) }" />
+        </constructor-arg>
+    </bean>
+
     <bean id="HandleMessageLate" class="net.shibboleth.sp.profile.impl.WebFlowMessageHandlerAdaptor" scope="prototype"
             c:executionDirection="INBOUND">
         <constructor-arg>
@@ -160,6 +161,7 @@
 
     <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"
@@ -171,4 +173,7 @@
         p:parserPool-ref="shibboleth.ParserPool"
         p:stateManager-ref="PassthroughStateManager" />
     
+    <bean id="ProcessLogoutResponse"
+        class="net.shibboleth.sp.saml.saml2.profile.impl.ProcessLogoutResponse" 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 5561eac..ea8b2e8 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
@@ -14,6 +14,10 @@
         <evaluate expression="ValidateHttpServletRequest" />
         <evaluate expression="DecodeMessage" />
 <!--        <evaluate expression="PostDecodePopulateAuditContext" />-->
+
+        <!-- Only runs for LogoutResponse messages but early enough to head off errors. -->
+        <evaluate expression="CreateServletResponseForLogoutResponse" />
+        <evaluate expression="RecoverStateDataForLogoutResponse" />
         
         <evaluate expression="CheckMessageVersion" />
         
@@ -40,9 +44,7 @@
     
     <!-- Dispatch by message type. -->
     <decision-state id="PreProcessLogoutMessage">
-        <if test="opensamlProfileRequestContext.ensureInboundMessageContext().getMessage() instanceof T(org.opensaml.saml.saml2.core.LogoutRequest)"
-            then="DoLogoutRequest"
-            else="DoLogoutResponse"/>
+        <if test="IsLogoutRequest.test(opensamlProfileRequestContext)" then="DoLogoutRequest" else="DoLogoutResponse"/>
     </decision-state>
     
     <!-- For a LogoutRequest, we process against the "session.opaque" input and produce a "token" for the Agent. -->
@@ -57,8 +59,7 @@
 
     <!-- For a LogoutResponse, we just extract some info for the Agent, simplest case by far. -->
     <action-state id="DoLogoutResponse">
-        <!-- TODO -->
-        <evaluate expression="RecoverStateData" />
+        <evaluate expression="ProcessLogoutResponse" />
         <evaluate expression="'proceed'" />
         
         <transition on="proceed" to="proceed" />
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 c1a3510..31f106e 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
@@ -23,15 +23,20 @@ import java.util.List;
 import javax.annotation.Nonnull;
 import javax.annotation.Nullable;
 
+import org.opensaml.core.xml.config.XMLObjectProviderRegistrySupport;
 import org.opensaml.core.xml.io.MarshallingException;
 import org.opensaml.core.xml.util.XMLObjectSupport;
 import org.opensaml.profile.context.ProfileRequestContext;
 import org.opensaml.saml.common.SAMLObject;
+import org.opensaml.saml.common.SAMLObjectBuilder;
 import org.opensaml.saml.common.binding.SAMLBindingSupport;
 import org.opensaml.saml.saml2.core.Issuer;
 import org.opensaml.saml.saml2.core.LogoutRequest;
+import org.opensaml.saml.saml2.core.LogoutResponse;
 import org.opensaml.saml.saml2.core.NameID;
 import org.opensaml.saml.saml2.core.NameIDType;
+import org.opensaml.saml.saml2.core.Status;
+import org.opensaml.saml.saml2.core.StatusCode;
 import org.opensaml.saml.saml2.testing.SAML2ActionTestingSupport;
 import org.opensaml.security.SecurityException;
 import org.opensaml.security.credential.Credential;
@@ -51,6 +56,7 @@ import org.testng.Assert;
 import org.testng.annotations.AfterClass;
 import org.testng.annotations.BeforeClass;
 import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.DataProvider;
 import org.testng.annotations.Test;
 
 import com.fasterxml.jackson.databind.ObjectMapper;
@@ -87,10 +93,12 @@ import net.shibboleth.sp.Application;
 import net.shibboleth.sp.ddf.DDF;
 import net.shibboleth.sp.flows.AbstractSPFlowTest;
 import net.shibboleth.sp.messaging.RemotedHttpServletRequest;
+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.PrepareAgentResponse;
 import net.shibboleth.sp.saml.saml2.profile.impl.ProcessLogoutRequest;
+import net.shibboleth.sp.saml.saml2.profile.impl.ProcessLogoutResponse;
 import net.shibboleth.sp.state.StateData;
 import net.shibboleth.sp.state.impl.CookieStateManager;
 import net.shibboleth.sp.state.impl.PassthroughStateManager;
@@ -118,11 +126,11 @@ public class SAML2LogoutConsumerFlowTest extends AbstractSPFlowTest {
     /** Issuer. */
     @Nonnull @NotEmpty public static final String ISSUER = "https://idp.example.org";
 
-    /** Audience. */
-    @Nonnull @NotEmpty public static final String AUDIENCE = "https://testsp.example.org";
-
-    /** ACS URL. */
+    /** Logout endpoint. */
     @Nonnull @NotEmpty public static final String RESPONSE_URL = "https://sp.example.org/Shibboleth.sso/SAML2/SLO/POST";
+
+    /** Resource URL. */
+    @Nonnull @NotEmpty public static final byte[] RESOURCE_URL = "https://sp.example.org/logout".getBytes(StandardCharsets.UTF_8);
     
     /** Relay state. */
     @Nonnull @NotEmpty public static final String RELAY_STATE = "dummy";
@@ -301,6 +309,56 @@ public class SAML2LogoutConsumerFlowTest extends AbstractSPFlowTest {
         validateLogoutRequestResult(true);
     }
     
+    /**
+     * Test flow with response with unverified (no metadata) IdP specified.
+     * 
+     * @throws IOException 
+     */
+    @Test
+    public void testResponseUnverified() throws IOException {
+        final DDF input = buildRemotedPOSTMessage(buildLogoutResponse(ISSUER + "/bad", StatusCode.SUCCESS, null), null, null);
+        setApplicationRequest(APPLICATION_ID, input);
+
+        validateError(IdPEventIds.INVALID_PROFILE_CONFIG);
+    }
+    
+    /**
+     * Test LogoutResponse flow with a successful status.
+     * 
+     * @param code status code for response
+     * @param subcode substatus code for response
+     * @param status  status in output from flow
+     * 
+     * @throws Exception
+     */
+    @Test(dataProvider="statuses")
+    public void testResponseSuccess(@Nonnull final String code, @Nullable final String subcode, int status) throws Exception {
+        final SAMLStateData state = buildStateData("12345");
+        final String token = getStateToken(state);
+        final LogoutResponse response = buildLogoutResponse(ISSUER, code, subcode);
+        sign(response);
+        final DDF input = buildRemotedPOSTMessage(response, token, response2.getCookies()[0]);
+        setApplicationRequest(APPLICATION_ID, input);
+        
+        validateLogoutResponseResult(status);
+    }
+    
+    @DataProvider(name = "statuses")
+    public Object[][] getStatuses() throws Exception {
+        return new Object[][] {
+            new Object[] {
+                    StatusCode.SUCCESS, null, Integer.valueOf(1)
+            },
+            new Object[] {
+                    StatusCode.SUCCESS, StatusCode.PARTIAL_LOGOUT, Integer.valueOf(0)
+            },
+            new Object[] {
+                    StatusCode.RESPONDER, null, Integer.valueOf(-1)
+            },
+        };
+    }
+    
+    
     /**
      * Examine a flow response to a valid LogoutRequest for accuracy.
      * 
@@ -354,6 +412,25 @@ public class SAML2LogoutConsumerFlowTest extends AbstractSPFlowTest {
         return output;
     }
     
+    @Nonnull private DDF validateLogoutResponseResult(final int status) {
+        final FlowExecutionResult result = flowExecutor.launchExecution(FLOW_ID, null, externalContext);
+        assertFlowExecutionResult(result, FLOW_ID);
+        assertFlowExecutionOutcome(result.getOutcome());
+        final DDF output = assertOutputMessageSuccess(result);
+        assert output != null;
+        System.out.println("testSuccess output: " + output.toString());
+        
+        Assert.assertTrue(output.isstruct());
+        
+        Assert.assertEquals(output.getmember(ProcessLogoutResponse.STATUS_PARAM).integer(), status);
+        
+        final DDF http = output.getmember(RemotedHttpServletRequest.STRUCTURE_NAME);
+        Assert.assertTrue(http.isstruct());
+        Assert.assertEquals(http.getmember(RemotedHttpServletResponse.REDIRECT).unsafe_string(), RESOURCE_URL);
+        
+        return output;
+    }
+    
     /**
      * Run the flow and verify an event is signalled.
      * 
@@ -383,10 +460,62 @@ public class SAML2LogoutConsumerFlowTest extends AbstractSPFlowTest {
         request.setID(idGenerator.generateIdentifier());
         request.setIssueInstant(Instant.now());
         request.setDestination(RESPONSE_URL);
-        request.setIssuer(SAML2ActionTestingSupport.buildIssuer(issuer));
+
+        final Issuer iss = SAML2ActionTestingSupport.buildIssuer(issuer);
+        iss.setFormat(NameIDType.ENTITY);
+        request.setIssuer(iss);
         
         return request;
     }
+
+    /**
+     * Builds a SAML logout response with some tailored data.
+     * 
+     * @param issuer issuer value
+     * @param code status code
+     * @param subcode sub status code
+     * 
+     * @return input message suitable for logout consumer flow
+     */
+    @Nonnull private LogoutResponse buildLogoutResponse(@Nonnull final String issuer, @Nonnull final String code,
+            @Nullable final String subcode) {
+        
+        final NameID nameID = SAML2ActionTestingSupport.buildNameID("jdoe at example.org");
+        assert nameID != null;
+        nameID.setFormat(NameIDType.EMAIL);
+
+        final LogoutResponse response = SAML2ActionTestingSupport.buildLogoutResponse();
+        response.setID(idGenerator.generateIdentifier());
+        response.setInResponseTo("12345");
+        response.setIssueInstant(Instant.now());
+        response.setDestination(RESPONSE_URL);
+        
+        final Issuer iss = SAML2ActionTestingSupport.buildIssuer(issuer);
+        iss.setFormat(NameIDType.ENTITY);
+        response.setIssuer(iss);
+
+        final SAMLObjectBuilder<Status> statusBuilder = (SAMLObjectBuilder<Status>)
+                XMLObjectProviderRegistrySupport.getBuilderFactory().<Status>ensureBuilder(
+                        Status.DEFAULT_ELEMENT_NAME);
+        final SAMLObjectBuilder<StatusCode> codeBuilder = (SAMLObjectBuilder<StatusCode>)
+                XMLObjectProviderRegistrySupport.getBuilderFactory().<StatusCode>ensureBuilder(
+                        StatusCode.DEFAULT_ELEMENT_NAME);
+
+        final Status status = statusBuilder.buildObject();
+        response.setStatus(status);
+        
+        final StatusCode sc = codeBuilder.buildObject();
+        sc.setValue(code);
+        status.setStatusCode(sc);
+        
+        if (subcode != null) {
+            final StatusCode sc2 = codeBuilder.buildObject();
+            sc2.setValue(subcode);
+            sc.setStatusCode(sc2);
+        }
+        
+        return response;
+    }
     
     /**
      * Encodes a SAML message into a form POST embedded in a remoted message.
@@ -469,7 +598,7 @@ public class SAML2LogoutConsumerFlowTest extends AbstractSPFlowTest {
         final SAMLStateData data = new SAMLStateData();
         data.setRequestID(requestID);
         data.setAuthenticationAuthority(ISSUER);
-        data.setResponseLocation(RESPONSE_URL);
+        data.setRawResource(RESOURCE_URL);
         return data;
     }
 
diff --git a/sp-saml-impl/src/main/java/net/shibboleth/sp/saml/saml2/profile/impl/ProcessLogoutRequest.java b/sp-saml-impl/src/main/java/net/shibboleth/sp/saml/saml2/profile/impl/ProcessLogoutRequest.java
index b18eff6..8f40c6e 100644
--- a/sp-saml-impl/src/main/java/net/shibboleth/sp/saml/saml2/profile/impl/ProcessLogoutRequest.java
+++ b/sp-saml-impl/src/main/java/net/shibboleth/sp/saml/saml2/profile/impl/ProcessLogoutRequest.java
@@ -221,7 +221,7 @@ public class ProcessLogoutRequest extends AbstractApplicationAction {
         
         // Establish request issuer if it's legitimate.
         Issuer issuer = logoutRequest.getIssuer();
-        if (issuer != null && issuer.getFormat() != null && !NameIDType.ENTITY.equals(issuer.getFormat())) {
+        if (issuer != null && !NameIDType.ENTITY.equals(issuer.getFormat())) {
             issuer = null;
         }
         
diff --git a/sp-saml-impl/src/main/java/net/shibboleth/sp/saml/saml2/profile/impl/ProcessLogoutResponse.java b/sp-saml-impl/src/main/java/net/shibboleth/sp/saml/saml2/profile/impl/ProcessLogoutResponse.java
new file mode 100644
index 0000000..57fc842
--- /dev/null
+++ b/sp-saml-impl/src/main/java/net/shibboleth/sp/saml/saml2/profile/impl/ProcessLogoutResponse.java
@@ -0,0 +1,197 @@
+/*
+ * 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.nio.charset.StandardCharsets;
+import java.util.Objects;
+import java.util.function.Function;
+
+import javax.annotation.Nonnull;
+
+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.saml2.core.Issuer;
+import org.opensaml.saml.saml2.core.LogoutRequest;
+import org.opensaml.saml.saml2.core.LogoutResponse;
+import org.opensaml.saml.saml2.core.NameIDType;
+import org.opensaml.saml.saml2.core.Status;
+import org.opensaml.saml.saml2.core.StatusCode;
+import org.slf4j.Logger;
+
+import net.shibboleth.shared.primitive.LoggerFactory;
+import net.shibboleth.sp.context.StateDataContext;
+import net.shibboleth.sp.ddf.DDF;
+import net.shibboleth.sp.messaging.RemotedHttpServletResponse;
+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.shared.annotation.constraint.NonnullBeforeExec;
+import net.shibboleth.shared.annotation.constraint.NotEmpty;
+import net.shibboleth.shared.logic.Constraint;
+
+/**
+ * Processes an inbound {@link LogoutRequest} from an IdP against the information supplied by
+ * the Agent.
+ * 
+ * <p>Based on the results, we encode a state token for the Agent to return later to get a response issued
+ * to the IdP.</p>
+ * 
+ * @pre <pre>profileRequestContext.ensureInboundMessageContext().getMessage() instanceof org.opensaml.saml.saml2.core.LogoutRequest</pre>
+ * @event {@link EventIds#PROCEED_EVENT_ID}
+ * @event {@link EventIds#INVALID_PROFILE_CTX}
+ * @event {@link EventIds#UNABLE_TO_DECODE}
+ */
+public class ProcessLogoutResponse extends AbstractApplicationAction {
+    
+    /** "status" parameter name. */
+    @Nonnull @NotEmpty public static final String STATUS_PARAM = "status";
+    
+    /** Class logger. */
+    @Nonnull private final Logger log = LoggerFactory.getLogger(ProcessLogoutResponse.class);
+    
+    /** Strategy used to locate the {@link StateDataContext} to check. */
+    @Nonnull private Function<ProfileRequestContext,StateDataContext> stateDataContextLookupStrategy;
+    
+    /** Logout request message. */
+    @NonnullBeforeExec private LogoutResponse logoutResponse;
+    
+    /** State data to validate. */
+    @NonnullBeforeExec private SAMLStateData stateData;
+    
+    /** Output message. */
+    @NonnullBeforeExec private DDF output;
+    
+    /** Constructor. */
+    public ProcessLogoutResponse() {
+        stateDataContextLookupStrategy = new ChildContextLookup<>(StateDataContext.class);
+    }
+    
+    /**
+     * Sets the strategy used to lookup the {@link StateDataContext}.
+     * 
+     * @param strategy lookup strategy
+     */
+    public void setStateDataContextLookupStrategy(
+            @Nonnull final Function<ProfileRequestContext,StateDataContext> strategy) {
+        checkSetterPreconditions();
+        stateDataContextLookupStrategy =
+                Constraint.isNotNull(strategy, "StateDataContext creation strategy cannot be null");
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected boolean doPreExecute(@Nonnull ProfileRequestContext profileRequestContext) {
+        if (!super.doPreExecute(profileRequestContext)) {
+            return false;
+        }
+        
+        if (profileRequestContext.ensureInboundMessageContext().getMessage() instanceof LogoutResponse resp) {
+            logoutResponse = resp;
+        } else {
+            log.error("{} Input message was missing or wrong type", getLogPrefix());
+            ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_MSG_CTX);
+            return false;
+        }
+        
+        output = ensureAgentRequestContext().getOutput();
+        if (output == null) {
+            log.error("{} Output message was missing", 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;
+        }
+        
+        return true;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+
+        if (!Objects.equals(logoutResponse.getInResponseTo(), stateData != null ? stateData.getRequestID() : null)) {
+            log.warn("{} LogoutResponse InResponseTo ({}) does not match recovered request ID ({})", getLogPrefix(),
+                    logoutResponse.getInResponseTo(), stateData != null ? stateData.getRequestID() : null);
+            output.addmember(STATUS_PARAM).integer(-1);
+        }
+        
+        // Establish request issuer if it's legitimate.
+        final Issuer issuer = logoutResponse.getIssuer();
+        if (issuer == null || !NameIDType.ENTITY.equals(issuer.getFormat())) {
+            log.warn("{} LogoutResponse did not contain a valid Issuer to cross-check", getLogPrefix());
+            output.addmember(STATUS_PARAM).integer(-1);
+        } else if (!Objects.equals(issuer.getValue(), stateData != null ? stateData.getAuthenticationAuthority() : null)) {
+            log.warn("{} LogoutResponse issuer ({}) does not match recovered IdP ({})", getLogPrefix(),
+                    issuer.getValue(), stateData != null ? stateData.getAuthenticationAuthority() : null);
+            output.addmember(STATUS_PARAM).integer(-1);
+        }
+        
+        // We examine the status only in the case that we haven't already recorded a failure.
+        if (output.getmember(STATUS_PARAM).isnull()) {
+            final Status status = logoutResponse.getStatus();
+            if (status != null) {
+                final StatusCode code = status.getStatusCode();
+                if (code != null && StatusCode.SUCCESS.equals(code.getValue())) {
+                    final StatusCode subcode = code.getStatusCode();
+                    if (subcode != null && StatusCode.PARTIAL_LOGOUT.equals(subcode.getValue())) {
+                        output.addmember(STATUS_PARAM).integer(0);
+                        log.debug("{} LogoutResponse signaled partial success", getLogPrefix());
+                    } else {
+                        output.addmember(STATUS_PARAM).integer(1);
+                        log.debug("{} LogoutResponse signaled success", getLogPrefix());
+                    }
+                } else {
+                    log.warn("{} LogoutResponse StatusCode was absent or unsuccessful", getLogPrefix());
+                    output.addmember(STATUS_PARAM).integer(-1);
+                }
+            } else {
+                log.warn("{} LogoutResponse did not contain a Status element", getLogPrefix());
+                output.addmember(STATUS_PARAM).integer(-1);
+            }
+        }
+        
+        final RemotedHttpServletResponse remotedResponse = ensureAgentRequestContext().getRemotedHttpServletResponse();
+        assert remotedResponse != null;
+        
+        if (!remotedResponse.isCommitted()) {
+            // Issue redirect to resource URL. Either recovered from protocol state earlier
+            // or falling back to an input parameter from the agent. Final backstop is a relative
+            // redirect to the site root.
+                        
+            byte[] resource = stateData != null ? stateData.getRawResource() : null;
+            if (resource == null || resource.length == 0) {
+                final DDF in = ensureAgentRequestContext().getInput();
+                if (in != null) {
+                    resource = in.getmember(ConsumerConstants.HOME_URL).unsafe_string();
+                }
+                if (resource == null || resource.length == 0) {
+                    resource = "/".getBytes(StandardCharsets.UTF_8);
+                }
+            }
+            
+            output.addmember(SPConstants.TARGET).unsafe_string(resource);
+            remotedResponse.sendRedirect(resource);
+        }
+        
+        
+    }
+        
+}
\ No newline at end of file
diff --git a/sp-saml-impl/src/test/java/net/shibboleth/sp/saml/saml2/profile/impl/ProcessLogoutRequestTest.java b/sp-saml-impl/src/test/java/net/shibboleth/sp/saml/saml2/profile/impl/ProcessLogoutRequestTest.java
index 3c124ba..e70a2bb 100644
--- a/sp-saml-impl/src/test/java/net/shibboleth/sp/saml/saml2/profile/impl/ProcessLogoutRequestTest.java
+++ b/sp-saml-impl/src/test/java/net/shibboleth/sp/saml/saml2/profile/impl/ProcessLogoutRequestTest.java
@@ -154,28 +154,28 @@ public class ProcessLogoutRequestTest extends BaseApplicationActionTest {
     }
     
     @Test
-    public void testNoInputMessage() throws ComponentInitializationException {
+    public void testNoInputMessage() {
         prc.ensureInboundMessageContext().setMessage(null);
         final Event event = action.execute(src);
         ActionTestingSupport.assertEvent(event, EventIds.INVALID_MSG_CTX);
     }
 
     @Test
-    public void testWrongMessageType() throws ComponentInitializationException {
+    public void testWrongMessageType() {
         buildLogoutResponse();
         final Event event = action.execute(src);
         ActionTestingSupport.assertEvent(event, EventIds.INVALID_MSG_CTX);
     }
 
     @Test
-    public void testNoOutputMessage() throws ComponentInitializationException {
+    public void testNoOutputMessage() {
         arc.setOutput(null);
         final Event event = action.execute(src);
         ActionTestingSupport.assertEvent(event, EventIds.INVALID_PROFILE_CTX);
     }
 
     @Test
-    public void testNoNameID() throws ComponentInitializationException, IOException {
+    public void testNoNameID() throws IOException {
         ((LogoutRequest) prc.ensureInboundMessageContext().ensureMessage()).setNameID(null);
         final Event event = action.execute(src);
         ActionTestingSupport.assertProceedEvent(event);
@@ -187,7 +187,7 @@ public class ProcessLogoutRequestTest extends BaseApplicationActionTest {
     }
     
     @Test
-    public void testExpired() throws ComponentInitializationException, IOException {
+    public void testExpired() throws IOException {
         ((LogoutRequest) prc.ensureInboundMessageContext().ensureMessage()).setNotOnOrAfter(Instant.now().minusSeconds(300));
 
         final DDF input = new DDF(null).structure();
@@ -206,7 +206,7 @@ public class ProcessLogoutRequestTest extends BaseApplicationActionTest {
     }
     
     @Test
-    public void testNoSessionData() throws ComponentInitializationException, IOException {
+    public void testNoSessionData() throws IOException {
         final Event event = action.execute(src);
         ActionTestingSupport.assertProceedEvent(event);
         
@@ -217,7 +217,7 @@ public class ProcessLogoutRequestTest extends BaseApplicationActionTest {
     }
 
     @Test
-    public void testMisnamedSessionData() throws ComponentInitializationException, IOException {
+    public void testMisnamedSessionData() throws IOException {
         final DDF input = new DDF(null).structure();
         input.addmember(ConsumerConstants.SESSION_OPAQUE).addmember("foo").string("bar");
         arc.setInput(input);
@@ -232,7 +232,7 @@ public class ProcessLogoutRequestTest extends BaseApplicationActionTest {
     }
     
     @Test
-    public void testInvalidSessionData() throws ComponentInitializationException, IOException {
+    public void testInvalidSessionData() throws IOException {
         final DDF input = new DDF(null).structure();
         input.addmember(ConsumerConstants.SESSION_OPAQUE).addmember(PrepareAgentResponse.NAMEID_PARAM).string("bar");
         arc.setInput(input);
@@ -247,7 +247,7 @@ public class ProcessLogoutRequestTest extends BaseApplicationActionTest {
     }
 
     @Test
-    public void testIncompleteSessionData() throws ComponentInitializationException, IOException {
+    public void testIncompleteSessionData() throws IOException {
         final DDF input = new DDF(null).structure();
         input.addmember(ConsumerConstants.SESSION_OPAQUE).addmember(PrepareAgentResponse.NAMEID_PARAM).string(
                 "<NameID xmlns='urn:oasis:names:tc:SAML:2.0:assertion' Format='bar'>foo</NameID>");
@@ -263,7 +263,7 @@ public class ProcessLogoutRequestTest extends BaseApplicationActionTest {
     }
 
     @Test
-    public void testMatchNoIndex() throws ComponentInitializationException, IOException {
+    public void testMatchNoIndex() throws IOException {
         final DDF input = new DDF(null).structure();
         input.addmember(ConsumerConstants.SESSION_OPAQUE).addmember(PrepareAgentResponse.NAMEID_PARAM).string(
                 "<NameID xmlns='urn:oasis:names:tc:SAML:2.0:assertion' SPProvidedID='"
@@ -280,7 +280,7 @@ public class ProcessLogoutRequestTest extends BaseApplicationActionTest {
     }
 
     @Test
-    public void testNoMatchWithIndex() throws ComponentInitializationException, IOException {
+    public void testNoMatchWithIndex() throws IOException {
         final DDF input = new DDF(null).structure();
         input.addmember(ConsumerConstants.SESSION_OPAQUE).addmember(PrepareAgentResponse.NAMEID_PARAM).string(
                 "<NameID xmlns='urn:oasis:names:tc:SAML:2.0:assertion' SPProvidedID='"
@@ -308,7 +308,7 @@ public class ProcessLogoutRequestTest extends BaseApplicationActionTest {
     }
 
     @Test
-    public void testMatchWithIndex() throws ComponentInitializationException, IOException {
+    public void testMatchWithIndex() throws IOException {
         final DDF input = new DDF(null).structure();
         input.addmember(ConsumerConstants.SESSION_OPAQUE).addmember(PrepareAgentResponse.NAMEID_PARAM).string(
                 "<NameID xmlns='urn:oasis:names:tc:SAML:2.0:assertion' SPProvidedID='"
@@ -332,7 +332,7 @@ public class ProcessLogoutRequestTest extends BaseApplicationActionTest {
     }
     
     @Test
-    public void testMatchAsynch() throws ComponentInitializationException, IOException {
+    public void testMatchAsynch() throws IOException {
         final DDF input = new DDF(null).structure();
         input.addmember(ConsumerConstants.SESSION_OPAQUE).addmember(PrepareAgentResponse.NAMEID_PARAM).string(
                 "<NameID xmlns='urn:oasis:names:tc:SAML:2.0:assertion' SPProvidedID='"
diff --git a/sp-saml-impl/src/test/java/net/shibboleth/sp/saml/saml2/profile/impl/ProcessLogoutRespoonseTest.java b/sp-saml-impl/src/test/java/net/shibboleth/sp/saml/saml2/profile/impl/ProcessLogoutRespoonseTest.java
new file mode 100644
index 0000000..e0bad51
--- /dev/null
+++ b/sp-saml-impl/src/test/java/net/shibboleth/sp/saml/saml2/profile/impl/ProcessLogoutRespoonseTest.java
@@ -0,0 +1,203 @@
+/*
+ * 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.nio.charset.StandardCharsets;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.opensaml.core.xml.config.XMLObjectProviderRegistrySupport;
+import org.opensaml.profile.action.EventIds;
+import org.opensaml.saml.common.SAMLObjectBuilder;
+import org.opensaml.saml.saml2.core.Issuer;
+import org.opensaml.saml.saml2.core.LogoutRequest;
+import org.opensaml.saml.saml2.core.LogoutResponse;
+import org.opensaml.saml.saml2.core.NameIDType;
+import org.opensaml.saml.saml2.core.Status;
+import org.opensaml.saml.saml2.core.StatusCode;
+import org.opensaml.saml.saml2.testing.SAML2ActionTestingSupport;
+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 net.shibboleth.idp.profile.testing.ActionTestingSupport;
+import net.shibboleth.profile.context.RelyingPartyContext;
+import net.shibboleth.shared.annotation.constraint.NotEmpty;
+import net.shibboleth.shared.component.ComponentInitializationException;
+import net.shibboleth.sp.context.StateDataContext;
+import net.shibboleth.sp.ddf.DDF;
+import net.shibboleth.sp.messaging.RemotedHttpServletResponse;
+import net.shibboleth.sp.profile.ConsumerConstants;
+import net.shibboleth.sp.profile.impl.BaseApplicationActionTest;
+import net.shibboleth.sp.saml.saml2.SAMLStateData;
+
+/**
+ * Unit test for {@link ProcessLogoutResponse} action.
+ */
+ at SuppressWarnings("javadoc")
+public class ProcessLogoutRespoonseTest extends BaseApplicationActionTest {
+    
+    @Nonnull @NotEmpty public static final byte[] RESOURCE_URL = "https://sp.example.org/logout?logout=1".getBytes(StandardCharsets.UTF_8);
+    @Nonnull @NotEmpty public static final byte[] RESOURCE_URL_2 = "https://sp.example.org/logout?logout=2".getBytes(StandardCharsets.UTF_8);
+    
+    private ProcessLogoutResponse action;
+    
+    /**
+     * Set up test.
+     * 
+     * @throws ComponentInitializationException
+     */
+    @BeforeMethod
+    public void setUp() throws ComponentInitializationException {
+        super.beforeMethod();
+        prc.removeSubcontext(RelyingPartyContext.class);
+        
+        action = new ProcessLogoutResponse();
+        action.initialize();
+
+        final DDF output = new DDF().structure();
+        output.addmember(RemotedHttpServletResponse.STRUCTURE_NAME).structure();
+        arc.setOutput(output);
+        arc.setRemotedHttpServletResponse(new RemotedHttpServletResponse(output.getmember(RemotedHttpServletResponse.STRUCTURE_NAME)));
+    }
+        
+    /**
+     * Tear down test.
+     */
+    @AfterMethod
+    public void tearDown() {
+        action.destroy();
+    }
+    
+    @Test
+    public void testNoInputMessage() {
+        prc.ensureInboundMessageContext().setMessage(null);
+        final Event event = action.execute(src);
+        ActionTestingSupport.assertEvent(event, EventIds.INVALID_MSG_CTX);
+    }
+
+    @Test
+    public void testWrongMessageType() {
+        buildLogoutRequest();
+        final Event event = action.execute(src);
+        ActionTestingSupport.assertEvent(event, EventIds.INVALID_MSG_CTX);
+    }
+
+    @Test
+    public void testNoOutputMessage() {
+        buildLogoutResponse(StatusCode.SUCCESS, null);
+        arc.setOutput(null);
+        final Event event = action.execute(src);
+        ActionTestingSupport.assertEvent(event, EventIds.INVALID_PROFILE_CTX);
+    }
+        
+    @DataProvider(name = "statuses")
+    public Object[][] getStatuses() throws Exception {
+        return new Object[][] {
+            new Object[] {
+                    Boolean.FALSE, StatusCode.SUCCESS, null, Integer.valueOf(-1)
+            },
+            new Object[] {
+                    Boolean.TRUE, StatusCode.SUCCESS, null, Integer.valueOf(1)
+            },
+            new Object[] {
+                    Boolean.TRUE, StatusCode.SUCCESS, StatusCode.PARTIAL_LOGOUT, Integer.valueOf(0)
+            },
+            new Object[] {
+                    Boolean.TRUE, StatusCode.RESPONDER, null, Integer.valueOf(-1)
+            },
+        };
+    }
+    
+    @Test(dataProvider = "statuses")
+    public void testStatuses(final boolean createState, @Nonnull final String code, @Nullable final String subcode, int status) {
+        final DDF input = new DDF(null).structure();
+        input.addmember(ConsumerConstants.HOME_URL).unsafe_string(RESOURCE_URL);
+        arc.setInput(input);
+        
+        if (createState) {
+            final SAMLStateData state = new SAMLStateData();
+            state.setRequestID("12345");
+            state.setAuthenticationAuthority(ActionTestingSupport.OUTBOUND_MSG_ISSUER);
+            state.setRawResource(RESOURCE_URL_2);
+            prc.ensureSubcontext(StateDataContext.class).setStateData(state);
+        }
+        
+        buildLogoutResponse(code, subcode);
+        
+        final Event event = action.execute(src);
+        ActionTestingSupport.assertProceedEvent(event);
+        
+        final DDF output = arc.getOutput();
+        assert output !=null;
+        Assert.assertEquals(output.getmember(ProcessLogoutResponse.STATUS_PARAM).integer(), status);
+        
+        final DDF http = output.getmember(RemotedHttpServletResponse.STRUCTURE_NAME);
+        assert http != null;
+        Assert.assertEquals(http.getmember(RemotedHttpServletResponse.REDIRECT).unsafe_string(),
+                createState ? RESOURCE_URL_2 : RESOURCE_URL);
+    }
+    
+    /**
+     * Adds mock request content to the inbound {@link MessageContext).
+     */
+    private void buildLogoutRequest() {
+        final LogoutRequest request = SAML2ActionTestingSupport.buildLogoutRequest(
+                SAML2ActionTestingSupport.buildNameID("jdoe"));
+        prc.ensureInboundMessageContext().setMessage(request);
+    }
+        
+    /**
+     * Adds mock response content to the inbound {@link MessageContext).
+     * 
+     * @param code primary status
+     * @param subcode secondary status
+     */
+    private void buildLogoutResponse(@Nonnull final String code, @Nullable final String subcode) {
+        final LogoutResponse response = SAML2ActionTestingSupport.buildLogoutResponse();
+        prc.ensureInboundMessageContext().setMessage(response);
+
+        final Issuer issuer = SAML2ActionTestingSupport.buildIssuer(ActionTestingSupport.OUTBOUND_MSG_ISSUER);
+        issuer.setFormat(NameIDType.ENTITY);
+        response.setIssuer(issuer);
+        response.setInResponseTo("12345");
+        
+        final SAMLObjectBuilder<Status> statusBuilder = (SAMLObjectBuilder<Status>)
+                XMLObjectProviderRegistrySupport.getBuilderFactory().<Status>ensureBuilder(
+                        Status.DEFAULT_ELEMENT_NAME);
+        final SAMLObjectBuilder<StatusCode> codeBuilder = (SAMLObjectBuilder<StatusCode>)
+                XMLObjectProviderRegistrySupport.getBuilderFactory().<StatusCode>ensureBuilder(
+                        StatusCode.DEFAULT_ELEMENT_NAME);
+
+        final Status status = statusBuilder.buildObject();
+        response.setStatus(status);
+        
+        final StatusCode sc = codeBuilder.buildObject();
+        sc.setValue(code);
+        status.setStatusCode(sc);
+        
+        if (subcode != null) {
+            final StatusCode sc2 = codeBuilder.buildObject();
+            sc2.setValue(subcode);
+            sc.setStatusCode(sc2);
+        }
+    }
+
+}
\ 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