[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