[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