Explorar o código

Add Single Logout Support

Closes gh-8731
Josh Cummings %!s(int64=4) %!d(string=hai) anos
pai
achega
c63d618b26
Modificáronse 49 ficheiros con 5738 adicións e 34 borrados
  1. 262 16
      docs/manual/src/docs/asciidoc/_includes/servlet/saml2/saml2-login.adoc
  2. 7 0
      saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/core/Saml2ErrorCodes.java
  3. 175 0
      saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/OpenSamlLogoutRequestValidator.java
  4. 187 0
      saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/OpenSamlLogoutResponseValidator.java
  5. 247 0
      saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/OpenSamlVerificationUtils.java
  6. 248 0
      saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/Saml2LogoutRequest.java
  7. 38 0
      saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/Saml2LogoutRequestValidator.java
  8. 73 0
      saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/Saml2LogoutRequestValidatorParameters.java
  9. 207 0
      saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/Saml2LogoutResponse.java
  10. 38 0
      saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/Saml2LogoutResponseValidator.java
  11. 72 0
      saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/Saml2LogoutResponseValidatorParameters.java
  12. 106 0
      saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/Saml2LogoutValidatorResult.java
  13. 76 0
      saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/Saml2Utils.java
  14. 10 0
      saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/metadata/OpenSamlMetadataResolver.java
  15. 25 3
      saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/registration/OpenSamlAssertingPartyMetadataConverter.java
  16. 250 6
      saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/registration/RelyingPartyRegistration.java
  17. 10 2
      saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/DefaultRelyingPartyRegistrationResolver.java
  18. 105 0
      saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/HttpSessionLogoutRequestRepository.java
  19. 167 0
      saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSamlLogoutRequestResolver.java
  20. 218 0
      saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSamlLogoutResponseResolver.java
  21. 173 0
      saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSamlSigningUtils.java
  22. 250 0
      saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutRequestFilter.java
  23. 68 0
      saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutRequestRepository.java
  24. 49 0
      saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutRequestResolver.java
  25. 169 0
      saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutResponseFilter.java
  26. 47 0
      saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutResponseResolver.java
  27. 171 0
      saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2RelyingPartyInitiatedLogoutSuccessHandler.java
  28. 76 0
      saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2Utils.java
  29. 125 0
      saml2/saml2-service-provider/src/opensaml3Main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSaml3LogoutRequestResolver.java
  30. 121 0
      saml2/saml2-service-provider/src/opensaml3Main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSaml3LogoutResponseResolver.java
  31. 65 0
      saml2/saml2-service-provider/src/opensaml3Test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSaml3LogoutRequestResolverTests.java
  32. 74 0
      saml2/saml2-service-provider/src/opensaml3Test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSaml3LogoutResponseResolverTests.java
  33. 123 0
      saml2/saml2-service-provider/src/opensaml4Main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSaml4LogoutRequestResolver.java
  34. 119 0
      saml2/saml2-service-provider/src/opensaml4Main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSaml4LogoutResponseResolver.java
  35. 65 0
      saml2/saml2-service-provider/src/opensaml4Test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSaml4LogoutRequestResolverTests.java
  36. 74 0
      saml2/saml2-service-provider/src/opensaml4Test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSaml4LogoutResponseResolverTests.java
  37. 44 2
      saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/authentication/TestOpenSamlObjects.java
  38. 173 0
      saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/authentication/logout/OpenSamlLogoutRequestValidatorTests.java
  39. 158 0
      saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/authentication/logout/OpenSamlLogoutResponseValidatorTests.java
  40. 173 0
      saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/authentication/logout/OpenSamlSigningUtils.java
  41. 4 2
      saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/metadata/OpenSamlMetadataResolverTests.java
  42. 9 3
      saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/registration/TestRelyingPartyRegistrations.java
  43. 3 0
      saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/web/DefaultRelyingPartyRegistrationResolverTests.java
  44. 229 0
      saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/HttpSessionLogoutRequestRepositoryTests.java
  45. 115 0
      saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSamlLogoutRequestResolverTests.java
  46. 125 0
      saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSamlLogoutResponseResolverTests.java
  47. 155 0
      saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutRequestFilterTests.java
  48. 153 0
      saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutResponseFilterTests.java
  49. 107 0
      saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2RelyingPartyInitiatedLogoutSuccessHandlerTests.java

+ 262 - 16
docs/manual/src/docs/asciidoc/_includes/servlet/saml2/saml2-login.adoc

@@ -1618,35 +1618,281 @@ filter.setRequestMatcher(AntPathRequestMatcher("/saml2/metadata", "GET"))
 [[servlet-saml2login-logout]]
 === Performing Single Logout
 
-Spring Security does not yet support single logout.
+Spring Security ships with support for RP- and AP-initiated SAML 2.0 Single Logout.
 
-Generally speaking, though, you can achieve this by creating and registering a custom `LogoutSuccessHandler` and `RequestMatcher`:
+Briefly, there are two use cases Spring Security supports:
+
+* **RP-Initiated** - Your application has an endpoint that, when POSTed to, will logout the user and send a `saml2:LogoutRequest` to the asserting party.
+Thereafter, the asserting party will send back a `saml2:LogoutResponse` and allow your application to respond
+* **AP-Initiated** - Your application has an endpoint that will receive a `saml2:LogoutRequest` from the asserting party.
+Your application will complete its logout at that point and then send a `saml2:LogoutResponse` to the asserting party.
+
+[NOTE]
+In the **AP-Initiated** scenario, any local redirection that your application would do post-logout is rendered moot.
+Once your application sends a `saml2:LogoutResponse`, it no longer has control of the browser.
+
+=== Minimal Configuration for Single Logout
+
+To use Spring Security's SAML 2.0 Single Logout feature, you will need the following things:
+
+* First, the asserting party must support SAML 2.0 Single Logout
+* Second, the asserting party should be configured to sign and POST `saml2:LogoutRequest` s and `saml2:LogoutResponse` s your application's `/logout/saml2/slo` endpoint
+* Third, your application must have a PKCS#8 private key and X.509 certificate for signing `saml2:LogoutRequest` s and `saml2:LogoutResponse` s
+
+==== RP-Initiated Single Logout
+
+Given those, then for RP-initiated Single Logout, you can begin from the initial minimal example and add the following configuration:
+
+[source,java]
+----
+@Value("${private.key}") RSAPrivateKey key;
+@Value("${public.certificate}") X509Certificate certificate;
+
+@Bean
+RelyingPartyRegistrationRepository registrations() {
+    RelyingPartyRegistration relyingPartyRegistration = RelyingPartyRegistrations
+            .fromMetadataLocation("https://ap.example.org/metadata")
+            .registrationId("id")
+            .singleLogoutServiceLocation("{baseUrl}/logout/saml2/slo")
+            .signingX509Credentials((signing) -> signing.add(Saml2X509Credential.signing(key, certificate))) <1>
+            .build();
+    return new InMemoryRelyingPartyRegistrationRepository(relyingPartyRegistration);
+}
+
+@Bean
+SecurityFilterChain web(HttpSecurity http, RelyingPartyRegistrationRepository registrations) throws Exception {
+	RelyingPartyRegistrationResolver registrationResolver = new DefaultRelyingPartyRegistrationResolver(registrations);
+    LogoutHandler logoutResponseHandler = logoutResponseHandler(registrationResolver);
+    LogoutSuccessHandler logoutRequestSuccessHandler = logoutRequestSuccessHandler(registrationResolver);
+
+    http
+        .authorizeRequests((authorize) -> authorize
+            .anyRequest().authenticated()
+        )
+        .saml2Login(withDefaults())
+        .logout((logout) -> logout
+                .logoutUrl("/saml2/logout")
+                .logoutSuccessHandler(successHandler))
+        .addFilterBefore(new Saml2LogoutResponseFilter(logoutHandler), CsrfFilter.class);
+
+    return http.build();
+}
+
+private LogoutSuccessHandler logoutRequestSuccessHandler(RelyingPartyRegistrationResolver registrationResolver) { <2>
+    OpenSaml4LogoutRequestResolver logoutRequestResolver = new OpenSaml4LogoutRequestResolver(registrationResolver);
+    return new Saml2LogoutRequestSuccessHandler(logoutRequestResolver);
+}
+
+private LogoutHandler logoutHandler(RelyingPartyRegistrationResolver registrationResolver) { <3>
+    return new OpenSamlLogoutResponseHandler(relyingPartyRegistrationResolver);
+}
+----
+<1> - First, add your signing key to the `RelyingPartyRegistration` instance or to <<servlet-saml2login-rpr-duplicated,multiple instances>>
+<2> - Second, supply a `LogoutSuccessHandler` for initiating Single Logout, sending a `saml2:LogoutRequest` to the asserting party
+<3> - Third, supply the `LogoutHandler` s needed to handle the `saml2:LogoutResponse` s sent from the asserting party.
+
+==== Runtime Expectations for RP-Initiated
+
+Given the above configuration any logged in user can send a `POST /logout` to your application to perform RP-initiated SLO.
+Your application will then do the following:
+
+1. Logout the user and invalidate the session
+2. Use a `Saml2LogoutRequestResolver` to create, sign, and serialize a `<saml2:LogoutRequest>` based on the <<servlet-saml2login-relyingpartyregistration,`RelyingPartyRegistration`>> associated with the currently logged-in user.
+3. Send a redirect or post to the asserting party based on the <<servlet-saml2login-relyingpartyregistration,`RelyingPartyRegistration`>>
+4. Deserialize, verify, and process the `<saml2:LogoutResponse>` sent by the asserting party
+5. Redirect to any configured successful logout endpoint
+
+[TIP]
+If your asserting party does not send `<saml2:LogoutResponse>` s when logout is complete, the asserting party can still send a `POST /saml2/logout` and then there is no need to configure the `Saml2LogoutResponseHandler`.
+
+==== AP-Initiated Single Logout
+
+Instead of RP-initiated Single Logout, you can again begin from the initial minimal example and add the following configuration to achieve AP-initiated Single Logout:
+
+[source,java]
+----
+@Value("${private.key}") RSAPrivateKey key;
+@Value("${public.certificate}") X509Certificate certificate;
+
+@Bean
+RelyingPartyRegistrationRepository registrations() {
+    RelyingPartyRegistration relyingPartyRegistration = RelyingPartyRegistrations
+            .fromMetadataLocation("https://ap.example.org/metadata")
+            .registrationId("id")
+            .signingX509Credentials((signing) -> signing.add(Saml2X509Credential.signing(key, certificate))) <1>
+            .build();
+    return new InMemoryRelyingPartyRegistrationRepository(relyingPartyRegistration);
+}
+
+@Bean
+SecurityFilterChain web(HttpSecurity http, RelyingPartyRegistrationRepository registrations) throws Exception {
+	RelyingPartyRegistrationResolver registrationResolver = new DefaultRelyingPartyRegistrationResolver(registrations);
+    LogoutHandler logoutRequestHandler = logoutRequestHandler(registrationResolver);
+    LogoutSuccessHandler logoutResponseSuccessHandler = logoutResponseSuccessHandler(registrationResolver);
+
+    http
+        .authorizeRequests((authorize) -> authorize
+            .anyRequest().authenticated()
+        )
+        .saml2Login(withDefaults())
+        .addFilterBefore(new Saml2LogoutRequestFilter(logoutResponseSuccessHandler, logoutRequestHandler), CsrfFilter.class);
+
+    return http.build();
+}
+
+private LogoutHandler logoutHandler(RelyingPartyRegistrationResolver registrationResolver) { <2>
+    return new CompositeLogoutHandler(
+    		new OpenSamlLogoutRequestHandler(relyingPartyRegistrationResolver),
+            new SecurityContextLogoutHandler(),
+            new LogoutSuccessEventPublishingLogoutHandler());
+}
+
+private LogoutSuccessHandler logoutSuccessHandler(RelyingPartyRegistrationResolver registrationResolver) { <3>
+    OpenSaml4LogoutResponseResolver logoutResponseResolver = new OpenSaml4LogoutResponseResolver(registrationResolver);
+    return new Saml2LogoutResponseSuccessHandler(logoutResponseResolver);
+}
+----
+<1> - First, add your signing key to the `RelyingPartyRegistration` instance or to <<servlet-saml2login-rpr-duplicated,multiple instances>>
+<2> - Second, supply the `LogoutHandler` needed to handle the `saml2:LogoutRequest` s sent from the asserting party.
+<3> - Third, supply a `LogoutSuccessHandler` for completing Single Logout, sending a `saml2:LogoutResponse` to the asserting party
+
+==== Runtime Expectations for AP-Initiated
+
+Given the above configuration, an asserting party can send a `POST /logout/saml2` to your application that includes a `<saml2:LogoutRequest>`
+Also, your application can participate in an AP-initated logout when the asserting party sends a `<saml2:LogoutRequest>` to `/logout/saml2/slo`:
+
+1. Use a `Saml2LogoutRequestHandler` to deserialize, verify, and process the `<saml2:LogoutRequest>` sent by the asserting party
+2. Logout the user and invalidate the session
+3. Create, sign, and serialize a `<saml2:LogoutResponse>` based on the <<servlet-saml2login-relyingpartyregistration,`RelyingPartyRegistration`>> associated with the just logged-out user
+4. Send a redirect or post to the asserting party based on the <<servlet-saml2login-relyingpartyregistration,`RelyingPartyRegistration`>>
+
+[TIP]
+If your asserting party does not expect you do send a `<saml2:LogoutResponse>` s when logout is complete, you may not need to configure a `LogoutSuccessHandler`
+
+[NOTE]
+In the event that you need to support both logout flows, you can combine the above to configurations.
+
+=== Configuring Logout Endpoints
+
+There are three default endpoints that Spring Security's SAML 2.0 Single Logout support exposes:
+* `/logout` - the endpoint for initiating single logout with an asserting party
+* `/logout/saml2/slo` - the endpoint for receiving logout requests or responses from an asserting party
+
+Because the user is already logged in, the `registrationId` is already known.
+For this reason, `+{registrationId}+` is not part of these URLs by default.
+
+These URLs are customizable in the DSL.
+
+For example, if you are migrating your existing relying party over to Spring Security, your asserting party may already be pointing to `GET /SLOService.saml2`.
+To reduce changes in configuration for the asserting party, you can configure the filter in the DSL like so:
 
 ====
 .Java
 [source,java,role="primary"]
 ----
+Saml2LogoutResponseFilter filter = new Saml2LogoutResponseFilter(logoutHandler);
+filter.setLogoutRequestMatcher(new AntPathRequestMatcher("/SLOService.saml2", "GET"));
 http
     // ...
-    .logout(logout -> logout
-        .logoutSuccessHandler(myCustomSuccessHandler())
-        .logoutRequestMatcher(myRequestMatcher())
-    )
+    .addFilterBefore(filter, CsrfFilter.class);
 ----
 
-.Kotlin
-[source,kotlin,role="secondary"]
+=== Customizing `<saml2:LogoutRequest>` Resolution
+
+It's common to need to set other values in the `<saml2:LogoutRequest>` than the defaults that Spring Security provides.
+
+By default, Spring Security will issue a `<saml2:LogoutRequest>` and supply:
+
+* The `Destination` attribute - from `RelyingPartyRegistration#getAssertingPartyDetails#getSingleLogoutServiceLocation`
+* The `ID` attribute - a GUID
+* The `<Issuer>` element - from `RelyingPartyRegistration#getEntityId`
+* The `<NameID>` element - from `Authentication#getName`
+
+To add other values, you can use delegation, like so:
+
+[source,java]
 ----
-http {
-    logout {
-        // ...
-        logoutSuccessHandler = myCustomSuccessHandler()
-        logoutRequestMatcher = myRequestMatcher()
+OpenSamlLogoutRequestResolver delegate = new OpenSamlLogoutRequestResolver(registrationResolver);
+return (request, response, authentication) -> {
+	OpenSamlLogoutRequestBuilder builder = delegate.resolveLogoutRequest(request, response, authentication); <1>
+	builder.name(((Saml2AuthenticatedPrincipal) authentication.getPrincipal()).getFirstAttribute("CustomAttribute")); <2>
+	builder.logoutRequest((logoutRequest) -> logoutRequest.setIssueInstant(DateTime.now()));
+	return builder.logoutRequest(); <3>
+};
+----
+<1> - Spring Security applies default values to a `<saml2:LogoutRequest>`
+<2> - Your application specifies customizations
+<3> - You complete the invocation by calling `request()`
+
+[NOTE]
+Support for OpenSAML 4 is coming.
+In anticipation of that, `OpenSamlLogoutRequestResolver` does not add an `IssueInstant`.
+Once OpenSAML 4 support is added, the default will be able to appropriate negotiate that datatype change, meaning you will no longer have to set it.
+
+=== Customizing `<saml2:LogoutResponse>` Resolution
+
+It's common to need to set other values in the `<saml2:LogoutResponse>` than the defaults that Spring Security provides.
+
+By default, Spring Security will issue a `<saml2:LogoutResponse>` and supply:
+
+* The `Destination` attribute - from `RelyingPartyRegistration#getAssertingPartyDetails#getSingleLogoutServiceResponseLocation`
+* The `ID` attribute - a GUID
+* The `<Issuer>` element - from `RelyingPartyRegistration#getEntityId`
+* The `<Status>` element - `SUCCESS`
+
+To add other values, you can use delegation, like so:
+
+[source,java]
+----
+OpenSamlLogoutResponseResolver delegate = new OpenSamlLogoutResponseResolver(registrationResolver);
+return (request, response, authentication) -> {
+	OpenSamlLogoutResponseBuilder builder = delegate.resolveLogoutResponse(request, response, authentication); <1>
+    if (checkOtherPrevailingConditions()) {
+        builder.status(StatusCode.PARTIAL_LOGOUT); <2>
     }
+	builder.logoutResponse((logoutResponse) -> logoutResponse.setIssueInstant(DateTime.now()));
+	return builder.logoutResponse(); <3>
+};
+----
+<1> - Spring Security applies default values to a `<saml2:LogoutResponse>`
+<2> - Your application specifies customizations
+<3> - You complete the invocation by calling `response()`
+
+[NOTE]
+Support for OpenSAML 4 is coming.
+In anticipation of that, `OpenSamlLogoutResponseResolver` does not add an `IssueInstant`.
+Once OpenSAML 4 support is added, the default will be able to appropriate negotiate that datatype change, meaning you will no longer have to set it.
+
+=== Customizing `<saml2:LogoutRequest>` Validation
+
+To customize validation, you can implement your own `LogoutHandler`.
+At this point, the validation is minimal, so you may be able to first delegate to the default `LogoutHandler` like so:
+
+[source,java]
+----
+LogoutHandler logoutHandler(RelyingPartyRegistrationResolver registrationResolver) {
+	OpenSamlLogoutRequestHandler delegate = new OpenSamlLogoutRequestHandler(registrationResolver);
+	return (request, response, authentication) -> {
+		delegate.logout(request, response, authentication); // verify signature, issuer, destination, and principal name
+		LogoutRequest logoutRequest = // ... parse using OpenSAML
+        // perform custom validation
+	}
 }
 ----
-====
 
-The success handler will send logout requests to the asserting party.
+=== Customizing `<saml2:LogoutResponse>` Validation
 
-The request matcher will detect logout requests from the asserting party.
+To customize validation, you can implement your own `LogoutHandler`.
+At this point, the validation is minimal, so you may be able to first delegate to the default `LogoutHandler` like so:
+
+[source,java]
+----
+LogoutHandler logoutHandler(RelyingPartyRegistrationResolver registrationResolver) {
+	OpenSamlLogoutResponseHandler delegate = new OpenSamlLogoutResponseHandler(registrationResolver);
+	return (request, response, authentication) -> {
+		delegate.logout(request, response, authentication); // verify signature, issuer, destination, and status
+		LogoutResponse logoutResponse = // ... parse using OpenSAML
+        // perform custom validation
+	}
+}
+----

+ 7 - 0
saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/core/Saml2ErrorCodes.java

@@ -37,6 +37,13 @@ public interface Saml2ErrorCodes {
 	 */
 	String MALFORMED_RESPONSE_DATA = "malformed_response_data";
 
+	/**
+	 * Request is invalid in a general way.
+	 *
+	 * @since 5.6
+	 */
+	String INVALID_REQUEST = "invalid_request";
+
 	/**
 	 * Response is invalid in a general way.
 	 *

+ 175 - 0
saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/OpenSamlLogoutRequestValidator.java

@@ -0,0 +1,175 @@
+/*
+ * Copyright 2002-2021 the original author or authors.
+ *
+ * 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
+ *
+ *      https://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 org.springframework.security.saml2.provider.service.authentication.logout;
+
+import java.io.ByteArrayInputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Collection;
+import java.util.function.Consumer;
+
+import net.shibboleth.utilities.java.support.xml.ParserPool;
+import org.opensaml.core.config.ConfigurationService;
+import org.opensaml.core.xml.config.XMLObjectProviderRegistry;
+import org.opensaml.core.xml.config.XMLObjectProviderRegistrySupport;
+import org.opensaml.saml.saml2.core.LogoutRequest;
+import org.opensaml.saml.saml2.core.NameID;
+import org.opensaml.saml.saml2.core.impl.LogoutRequestUnmarshaller;
+import org.w3c.dom.Document;
+import org.w3c.dom.Element;
+
+import org.springframework.security.core.Authentication;
+import org.springframework.security.saml2.Saml2Exception;
+import org.springframework.security.saml2.core.OpenSamlInitializationService;
+import org.springframework.security.saml2.core.Saml2Error;
+import org.springframework.security.saml2.core.Saml2ErrorCodes;
+import org.springframework.security.saml2.provider.service.authentication.logout.OpenSamlVerificationUtils.VerifierPartial;
+import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration;
+import org.springframework.security.saml2.provider.service.registration.Saml2MessageBinding;
+
+/**
+ * A {@link Saml2LogoutRequestValidator} that authenticates a SAML 2.0 Logout Requests
+ * received from a SAML 2.0 Asserting Party using OpenSAML.
+ *
+ * @author Josh Cummings
+ * @since 5.6
+ */
+public final class OpenSamlLogoutRequestValidator implements Saml2LogoutRequestValidator {
+
+	static {
+		OpenSamlInitializationService.initialize();
+	}
+
+	private final ParserPool parserPool;
+
+	private final LogoutRequestUnmarshaller unmarshaller;
+
+	/**
+	 * Constructs a {@link OpenSamlLogoutRequestValidator}
+	 */
+	public OpenSamlLogoutRequestValidator() {
+		XMLObjectProviderRegistry registry = ConfigurationService.get(XMLObjectProviderRegistry.class);
+		this.parserPool = registry.getParserPool();
+		this.unmarshaller = (LogoutRequestUnmarshaller) XMLObjectProviderRegistrySupport.getUnmarshallerFactory()
+				.getUnmarshaller(LogoutRequest.DEFAULT_ELEMENT_NAME);
+	}
+
+	/**
+	 * {@inheritDoc}
+	 */
+	@Override
+	public Saml2LogoutValidatorResult validate(Saml2LogoutRequestValidatorParameters parameters) {
+		Saml2LogoutRequest request = parameters.getLogoutRequest();
+		RelyingPartyRegistration registration = parameters.getRelyingPartyRegistration();
+		Authentication authentication = parameters.getAuthentication();
+		byte[] b = Saml2Utils.samlDecode(request.getSamlRequest());
+		LogoutRequest logoutRequest = parse(inflateIfRequired(request, b));
+		return Saml2LogoutValidatorResult.withErrors().errors(verifySignature(request, logoutRequest, registration))
+				.errors(validateRequest(logoutRequest, registration, authentication)).build();
+	}
+
+	private String inflateIfRequired(Saml2LogoutRequest request, byte[] b) {
+		if (request.getBinding() == Saml2MessageBinding.REDIRECT) {
+			return Saml2Utils.samlInflate(b);
+		}
+		return new String(b, StandardCharsets.UTF_8);
+	}
+
+	private LogoutRequest parse(String request) throws Saml2Exception {
+		try {
+			Document document = this.parserPool
+					.parse(new ByteArrayInputStream(request.getBytes(StandardCharsets.UTF_8)));
+			Element element = document.getDocumentElement();
+			return (LogoutRequest) this.unmarshaller.unmarshall(element);
+		}
+		catch (Exception ex) {
+			throw new Saml2Exception("Failed to deserialize LogoutRequest", ex);
+		}
+	}
+
+	private Consumer<Collection<Saml2Error>> verifySignature(Saml2LogoutRequest request, LogoutRequest logoutRequest,
+			RelyingPartyRegistration registration) {
+		return (errors) -> {
+			VerifierPartial partial = OpenSamlVerificationUtils.verifySignature(logoutRequest, registration);
+			if (logoutRequest.isSigned()) {
+				errors.addAll(partial.post(logoutRequest.getSignature()));
+			}
+			else {
+				errors.addAll(partial.redirect(request));
+			}
+		};
+	}
+
+	private Consumer<Collection<Saml2Error>> validateRequest(LogoutRequest request,
+			RelyingPartyRegistration registration, Authentication authentication) {
+		return (errors) -> {
+			validateIssuer(request, registration).accept(errors);
+			validateDestination(request, registration).accept(errors);
+			validateName(request, authentication).accept(errors);
+		};
+	}
+
+	private Consumer<Collection<Saml2Error>> validateIssuer(LogoutRequest request,
+			RelyingPartyRegistration registration) {
+		return (errors) -> {
+			if (request.getIssuer() == null) {
+				errors.add(new Saml2Error(Saml2ErrorCodes.INVALID_ISSUER, "Failed to find issuer in LogoutResponse"));
+				return;
+			}
+			String issuer = request.getIssuer().getValue();
+			if (!issuer.equals(registration.getAssertingPartyDetails().getEntityId())) {
+				errors.add(
+						new Saml2Error(Saml2ErrorCodes.INVALID_ISSUER, "Failed to match issuer to configured issuer"));
+			}
+		};
+	}
+
+	private Consumer<Collection<Saml2Error>> validateDestination(LogoutRequest request,
+			RelyingPartyRegistration registration) {
+		return (errors) -> {
+			if (request.getDestination() == null) {
+				errors.add(new Saml2Error(Saml2ErrorCodes.INVALID_DESTINATION,
+						"Failed to find destination in LogoutResponse"));
+				return;
+			}
+			String destination = request.getDestination();
+			if (!destination.equals(registration.getSingleLogoutServiceLocation())) {
+				errors.add(new Saml2Error(Saml2ErrorCodes.INVALID_DESTINATION,
+						"Failed to match destination to configured destination"));
+			}
+		};
+	}
+
+	private Consumer<Collection<Saml2Error>> validateName(LogoutRequest request, Authentication authentication) {
+		return (errors) -> {
+			if (authentication == null) {
+				return;
+			}
+			NameID nameId = request.getNameID();
+			if (nameId == null) {
+				errors.add(
+						new Saml2Error(Saml2ErrorCodes.SUBJECT_NOT_FOUND, "Failed to find subject in LogoutRequest"));
+				return;
+			}
+			String name = nameId.getValue();
+			if (!name.equals(authentication.getName())) {
+				errors.add(new Saml2Error(Saml2ErrorCodes.INVALID_REQUEST,
+						"Failed to match subject in LogoutRequest with currently logged in user"));
+			}
+		};
+	}
+
+}

+ 187 - 0
saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/OpenSamlLogoutResponseValidator.java

@@ -0,0 +1,187 @@
+/*
+ * Copyright 2002-2021 the original author or authors.
+ *
+ * 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
+ *
+ *      https://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 org.springframework.security.saml2.provider.service.authentication.logout;
+
+import java.io.ByteArrayInputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Collection;
+import java.util.function.Consumer;
+
+import net.shibboleth.utilities.java.support.xml.ParserPool;
+import org.opensaml.core.config.ConfigurationService;
+import org.opensaml.core.xml.config.XMLObjectProviderRegistry;
+import org.opensaml.core.xml.config.XMLObjectProviderRegistrySupport;
+import org.opensaml.saml.saml2.core.LogoutResponse;
+import org.opensaml.saml.saml2.core.StatusCode;
+import org.opensaml.saml.saml2.core.impl.LogoutResponseUnmarshaller;
+import org.w3c.dom.Document;
+import org.w3c.dom.Element;
+
+import org.springframework.security.saml2.Saml2Exception;
+import org.springframework.security.saml2.core.OpenSamlInitializationService;
+import org.springframework.security.saml2.core.Saml2Error;
+import org.springframework.security.saml2.core.Saml2ErrorCodes;
+import org.springframework.security.saml2.provider.service.authentication.logout.OpenSamlVerificationUtils.VerifierPartial;
+import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration;
+import org.springframework.security.saml2.provider.service.registration.Saml2MessageBinding;
+
+/**
+ * A {@link Saml2LogoutResponseValidator} that authenticates a SAML 2.0 Logout Responses
+ * received from a SAML 2.0 Asserting Party using OpenSAML.
+ *
+ * @author Josh Cummings
+ * @since 5.6
+ */
+public class OpenSamlLogoutResponseValidator implements Saml2LogoutResponseValidator {
+
+	static {
+		OpenSamlInitializationService.initialize();
+	}
+
+	private final ParserPool parserPool;
+
+	private final LogoutResponseUnmarshaller unmarshaller;
+
+	/**
+	 * Constructs a {@link OpenSamlLogoutRequestValidator}
+	 */
+	public OpenSamlLogoutResponseValidator() {
+		XMLObjectProviderRegistry registry = ConfigurationService.get(XMLObjectProviderRegistry.class);
+		this.parserPool = registry.getParserPool();
+		this.unmarshaller = (LogoutResponseUnmarshaller) XMLObjectProviderRegistrySupport.getUnmarshallerFactory()
+				.getUnmarshaller(LogoutResponse.DEFAULT_ELEMENT_NAME);
+	}
+
+	/**
+	 * {@inheritDoc}
+	 */
+	@Override
+	public Saml2LogoutValidatorResult validate(Saml2LogoutResponseValidatorParameters parameters) {
+		Saml2LogoutResponse response = parameters.getLogoutResponse();
+		Saml2LogoutRequest request = parameters.getLogoutRequest();
+		RelyingPartyRegistration registration = parameters.getRelyingPartyRegistration();
+		byte[] b = Saml2Utils.samlDecode(response.getSamlResponse());
+		LogoutResponse logoutResponse = parse(inflateIfRequired(response, b));
+		return Saml2LogoutValidatorResult.withErrors().errors(verifySignature(response, logoutResponse, registration))
+				.errors(validateRequest(logoutResponse, registration))
+				.errors(validateLogoutRequest(logoutResponse, request.getId())).build();
+	}
+
+	private String inflateIfRequired(Saml2LogoutResponse response, byte[] b) {
+		if (response.getBinding() == Saml2MessageBinding.REDIRECT) {
+			return Saml2Utils.samlInflate(b);
+		}
+		return new String(b, StandardCharsets.UTF_8);
+	}
+
+	private LogoutResponse parse(String response) throws Saml2Exception {
+		try {
+			Document document = this.parserPool
+					.parse(new ByteArrayInputStream(response.getBytes(StandardCharsets.UTF_8)));
+			Element element = document.getDocumentElement();
+			return (LogoutResponse) this.unmarshaller.unmarshall(element);
+		}
+		catch (Exception ex) {
+			throw new Saml2Exception("Failed to deserialize LogoutResponse", ex);
+		}
+	}
+
+	private Consumer<Collection<Saml2Error>> verifySignature(Saml2LogoutResponse response,
+			LogoutResponse logoutResponse, RelyingPartyRegistration registration) {
+		return (errors) -> {
+			VerifierPartial partial = OpenSamlVerificationUtils.verifySignature(logoutResponse, registration);
+			if (logoutResponse.isSigned()) {
+				errors.addAll(partial.post(logoutResponse.getSignature()));
+			}
+			else {
+				errors.addAll(partial.redirect(response));
+			}
+		};
+	}
+
+	private Consumer<Collection<Saml2Error>> validateRequest(LogoutResponse response,
+			RelyingPartyRegistration registration) {
+		return (errors) -> {
+			validateIssuer(response, registration).accept(errors);
+			validateDestination(response, registration).accept(errors);
+			validateStatus(response).accept(errors);
+		};
+	}
+
+	private Consumer<Collection<Saml2Error>> validateIssuer(LogoutResponse response,
+			RelyingPartyRegistration registration) {
+		return (errors) -> {
+			if (response.getIssuer() == null) {
+				errors.add(new Saml2Error(Saml2ErrorCodes.INVALID_ISSUER, "Failed to find issuer in LogoutResponse"));
+				return;
+			}
+			String issuer = response.getIssuer().getValue();
+			if (!issuer.equals(registration.getAssertingPartyDetails().getEntityId())) {
+				errors.add(
+						new Saml2Error(Saml2ErrorCodes.INVALID_ISSUER, "Failed to match issuer to configured issuer"));
+			}
+		};
+	}
+
+	private Consumer<Collection<Saml2Error>> validateDestination(LogoutResponse response,
+			RelyingPartyRegistration registration) {
+		return (errors) -> {
+			if (response.getDestination() == null) {
+				errors.add(new Saml2Error(Saml2ErrorCodes.INVALID_DESTINATION,
+						"Failed to find destination in LogoutResponse"));
+				return;
+			}
+			String destination = response.getDestination();
+			if (!destination.equals(registration.getSingleLogoutServiceResponseLocation())) {
+				errors.add(new Saml2Error(Saml2ErrorCodes.INVALID_DESTINATION,
+						"Failed to match destination to configured destination"));
+			}
+		};
+	}
+
+	private Consumer<Collection<Saml2Error>> validateStatus(LogoutResponse response) {
+		return (errors) -> {
+			if (response.getStatus() == null) {
+				return;
+			}
+			if (response.getStatus().getStatusCode() == null) {
+				return;
+			}
+			if (StatusCode.SUCCESS.equals(response.getStatus().getStatusCode().getValue())) {
+				return;
+			}
+			if (StatusCode.PARTIAL_LOGOUT.equals(response.getStatus().getStatusCode().getValue())) {
+				return;
+			}
+			errors.add(new Saml2Error(Saml2ErrorCodes.INVALID_RESPONSE, "Response indicated logout failed"));
+		};
+	}
+
+	private Consumer<Collection<Saml2Error>> validateLogoutRequest(LogoutResponse response, String id) {
+		return (errors) -> {
+			if (response.getInResponseTo() == null) {
+				return;
+			}
+			if (response.getInResponseTo().equals(id)) {
+				return;
+			}
+			errors.add(new Saml2Error(Saml2ErrorCodes.INVALID_RESPONSE,
+					"LogoutResponse InResponseTo doesn't match ID of associated LogoutRequest"));
+		};
+	}
+
+}

+ 247 - 0
saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/OpenSamlVerificationUtils.java

@@ -0,0 +1,247 @@
+/*
+ * Copyright 2002-2021 the original author or authors.
+ *
+ * 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
+ *
+ *      https://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 org.springframework.security.saml2.provider.service.authentication.logout;
+
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Set;
+
+import net.shibboleth.utilities.java.support.resolver.CriteriaSet;
+import org.opensaml.core.criterion.EntityIdCriterion;
+import org.opensaml.saml.common.xml.SAMLConstants;
+import org.opensaml.saml.criterion.ProtocolCriterion;
+import org.opensaml.saml.metadata.criteria.role.impl.EvaluableProtocolRoleDescriptorCriterion;
+import org.opensaml.saml.saml2.core.Issuer;
+import org.opensaml.saml.saml2.core.RequestAbstractType;
+import org.opensaml.saml.saml2.core.StatusResponseType;
+import org.opensaml.saml.security.impl.SAMLSignatureProfileValidator;
+import org.opensaml.security.credential.Credential;
+import org.opensaml.security.credential.CredentialResolver;
+import org.opensaml.security.credential.UsageType;
+import org.opensaml.security.credential.criteria.impl.EvaluableEntityIDCredentialCriterion;
+import org.opensaml.security.credential.criteria.impl.EvaluableUsageCredentialCriterion;
+import org.opensaml.security.credential.impl.CollectionCredentialResolver;
+import org.opensaml.security.criteria.UsageCriterion;
+import org.opensaml.security.x509.BasicX509Credential;
+import org.opensaml.xmlsec.config.impl.DefaultSecurityConfigurationBootstrap;
+import org.opensaml.xmlsec.signature.Signature;
+import org.opensaml.xmlsec.signature.support.SignatureTrustEngine;
+import org.opensaml.xmlsec.signature.support.impl.ExplicitKeySignatureTrustEngine;
+
+import org.springframework.security.saml2.core.Saml2Error;
+import org.springframework.security.saml2.core.Saml2ErrorCodes;
+import org.springframework.security.saml2.core.Saml2X509Credential;
+import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration;
+import org.springframework.web.util.UriUtils;
+
+/**
+ * Utility methods for verifying SAML component signatures with OpenSAML
+ *
+ * For internal use only.
+ *
+ * @author Josh Cummings
+ */
+
+final class OpenSamlVerificationUtils {
+
+	static VerifierPartial verifySignature(StatusResponseType object, RelyingPartyRegistration registration) {
+		return new VerifierPartial(object, registration);
+	}
+
+	static VerifierPartial verifySignature(RequestAbstractType object, RelyingPartyRegistration registration) {
+		return new VerifierPartial(object, registration);
+	}
+
+	static class VerifierPartial {
+
+		private final String id;
+
+		private final CriteriaSet criteria;
+
+		private final SignatureTrustEngine trustEngine;
+
+		VerifierPartial(StatusResponseType object, RelyingPartyRegistration registration) {
+			this.id = object.getID();
+			this.criteria = verificationCriteria(object.getIssuer());
+			this.trustEngine = trustEngine(registration);
+		}
+
+		VerifierPartial(RequestAbstractType object, RelyingPartyRegistration registration) {
+			this.id = object.getID();
+			this.criteria = verificationCriteria(object.getIssuer());
+			this.trustEngine = trustEngine(registration);
+		}
+
+		Collection<Saml2Error> redirect(Saml2LogoutRequest request) {
+			return redirect(new RedirectSignature(request));
+		}
+
+		Collection<Saml2Error> redirect(Saml2LogoutResponse response) {
+			return redirect(new RedirectSignature(response));
+		}
+
+		Collection<Saml2Error> redirect(RedirectSignature signature) {
+			if (signature.getAlgorithm() == null) {
+				return Collections.singletonList(new Saml2Error(Saml2ErrorCodes.INVALID_SIGNATURE,
+						"Missing signature algorithm for object [" + this.id + "]"));
+			}
+			if (!signature.hasSignature()) {
+				return Collections.singletonList(new Saml2Error(Saml2ErrorCodes.INVALID_SIGNATURE,
+						"Missing signature for object [" + this.id + "]"));
+			}
+			Collection<Saml2Error> errors = new ArrayList<>();
+			String algorithmUri = signature.getAlgorithm();
+			try {
+				if (!this.trustEngine.validate(signature.getSignature(), signature.getContent(), algorithmUri,
+						this.criteria, null)) {
+					errors.add(new Saml2Error(Saml2ErrorCodes.INVALID_SIGNATURE,
+							"Invalid signature for object [" + this.id + "]"));
+				}
+			}
+			catch (Exception ex) {
+				errors.add(new Saml2Error(Saml2ErrorCodes.INVALID_SIGNATURE,
+						"Invalid signature for object [" + this.id + "]: "));
+			}
+			return errors;
+		}
+
+		Collection<Saml2Error> post(Signature signature) {
+			Collection<Saml2Error> errors = new ArrayList<>();
+			SAMLSignatureProfileValidator profileValidator = new SAMLSignatureProfileValidator();
+			try {
+				profileValidator.validate(signature);
+			}
+			catch (Exception ex) {
+				errors.add(new Saml2Error(Saml2ErrorCodes.INVALID_SIGNATURE,
+						"Invalid signature for object [" + this.id + "]: "));
+			}
+
+			try {
+				if (!this.trustEngine.validate(signature, this.criteria)) {
+					errors.add(new Saml2Error(Saml2ErrorCodes.INVALID_SIGNATURE,
+							"Invalid signature for object [" + this.id + "]"));
+				}
+			}
+			catch (Exception ex) {
+				errors.add(new Saml2Error(Saml2ErrorCodes.INVALID_SIGNATURE,
+						"Invalid signature for object [" + this.id + "]: "));
+			}
+
+			return errors;
+		}
+
+		private CriteriaSet verificationCriteria(Issuer issuer) {
+			CriteriaSet criteria = new CriteriaSet();
+			criteria.add(new EvaluableEntityIDCredentialCriterion(new EntityIdCriterion(issuer.getValue())));
+			criteria.add(new EvaluableProtocolRoleDescriptorCriterion(new ProtocolCriterion(SAMLConstants.SAML20P_NS)));
+			criteria.add(new EvaluableUsageCredentialCriterion(new UsageCriterion(UsageType.SIGNING)));
+			return criteria;
+		}
+
+		private SignatureTrustEngine trustEngine(RelyingPartyRegistration registration) {
+			Set<Credential> credentials = new HashSet<>();
+			Collection<Saml2X509Credential> keys = registration.getAssertingPartyDetails()
+					.getVerificationX509Credentials();
+			for (Saml2X509Credential key : keys) {
+				BasicX509Credential cred = new BasicX509Credential(key.getCertificate());
+				cred.setUsageType(UsageType.SIGNING);
+				cred.setEntityId(registration.getAssertingPartyDetails().getEntityId());
+				credentials.add(cred);
+			}
+			CredentialResolver credentialsResolver = new CollectionCredentialResolver(credentials);
+			return new ExplicitKeySignatureTrustEngine(credentialsResolver,
+					DefaultSecurityConfigurationBootstrap.buildBasicInlineKeyInfoCredentialResolver());
+		}
+
+		private static class RedirectSignature {
+
+			private final String algorithm;
+
+			private final byte[] signature;
+
+			private final byte[] content;
+
+			RedirectSignature(Saml2LogoutRequest request) {
+				this.algorithm = request.getParameter("SigAlg");
+				if (request.getParameter("Signature") != null) {
+					this.signature = Saml2Utils.samlDecode(request.getParameter("Signature"));
+				}
+				else {
+					this.signature = null;
+				}
+				this.content = content(request.getSamlRequest(), "SAMLRequest", request.getRelayState(),
+						request.getParameter("SigAlg"));
+			}
+
+			RedirectSignature(Saml2LogoutResponse response) {
+				this.algorithm = response.getParameter("SigAlg");
+				if (response.getParameter("Signature") != null) {
+					this.signature = Saml2Utils.samlDecode(response.getParameter("Signature"));
+				}
+				else {
+					this.signature = null;
+				}
+				this.content = content(response.getSamlResponse(), "SAMLResponse", response.getRelayState(),
+						response.getParameter("SigAlg"));
+			}
+
+			static byte[] content(String samlObject, String objectParameterName, String relayState, String algorithm) {
+				if (relayState != null) {
+					return String
+							.format("%s=%s&RelayState=%s&SigAlg=%s", objectParameterName,
+									UriUtils.encode(samlObject, StandardCharsets.ISO_8859_1),
+									UriUtils.encode(relayState, StandardCharsets.ISO_8859_1),
+									UriUtils.encode(algorithm, StandardCharsets.ISO_8859_1))
+							.getBytes(StandardCharsets.UTF_8);
+				}
+				else {
+					return String
+							.format("%s=%s&SigAlg=%s", objectParameterName,
+									UriUtils.encode(samlObject, StandardCharsets.ISO_8859_1),
+									UriUtils.encode(algorithm, StandardCharsets.ISO_8859_1))
+							.getBytes(StandardCharsets.UTF_8);
+				}
+			}
+
+			byte[] getContent() {
+				return this.content;
+			}
+
+			String getAlgorithm() {
+				return this.algorithm;
+			}
+
+			byte[] getSignature() {
+				return this.signature;
+			}
+
+			boolean hasSignature() {
+				return this.signature != null;
+			}
+
+		}
+
+	}
+
+	private OpenSamlVerificationUtils() {
+
+	}
+
+}

+ 248 - 0
saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/Saml2LogoutRequest.java

@@ -0,0 +1,248 @@
+/*
+ * Copyright 2002-2020 the original author or authors.
+ *
+ * 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
+ *
+ *      https://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 org.springframework.security.saml2.provider.service.authentication.logout;
+
+import java.io.Serializable;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.function.Consumer;
+
+import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration;
+import org.springframework.security.saml2.provider.service.registration.Saml2MessageBinding;
+import org.springframework.security.saml2.provider.service.web.authentication.logout.Saml2LogoutRequestResolver;
+
+/**
+ * A class that represents a signed and serialized SAML 2.0 Logout Request
+ *
+ * @author Josh Cummings
+ * @since 5.6
+ */
+public final class Saml2LogoutRequest implements Serializable {
+
+	private final String location;
+
+	private final Saml2MessageBinding binding;
+
+	private final Map<String, String> parameters;
+
+	private final String id;
+
+	private final String relyingPartyRegistrationId;
+
+	private Saml2LogoutRequest(String location, Saml2MessageBinding binding, Map<String, String> parameters, String id,
+			String relyingPartyRegistrationId) {
+		this.location = location;
+		this.binding = binding;
+		this.parameters = Collections.unmodifiableMap(new HashMap<>(parameters));
+		this.id = id;
+		this.relyingPartyRegistrationId = relyingPartyRegistrationId;
+	}
+
+	/**
+	 * The unique identifier for this Logout Request
+	 * @return the Logout Request identifier
+	 */
+	public String getId() {
+		return this.id;
+	}
+
+	/**
+	 * Get the location of the asserting party's <a href=
+	 * "https://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf#page=7">SingleLogoutService</a>
+	 * @return the SingleLogoutService location
+	 */
+	public String getLocation() {
+		return this.location;
+	}
+
+	/**
+	 * Get the binding for the asserting party's <a href=
+	 * "https://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf#page=7">SingleLogoutService</a>
+	 * @return the SingleLogoutService binding
+	 */
+	public Saml2MessageBinding getBinding() {
+		return this.binding;
+	}
+
+	/**
+	 * Get the signed and serialized &lt;saml2:LogoutRequest&gt; payload
+	 * @return the signed and serialized &lt;saml2:LogoutRequest&gt; payload
+	 */
+	public String getSamlRequest() {
+		return this.parameters.get("SAMLRequest");
+	}
+
+	/**
+	 * The relay state associated with this Logout Request
+	 * @return the relay state
+	 */
+	public String getRelayState() {
+		return this.parameters.get("RelayState");
+	}
+
+	/**
+	 * Get the {@code name} parameters, a short-hand for <code>
+	 * getParameters().get(name)
+	 * </code>
+	 *
+	 * Useful when specifying additional query parameters for the Logout Request
+	 * @param name the parameter's name
+	 * @return the parameter's value
+	 */
+	public String getParameter(String name) {
+		return this.parameters.get(name);
+	}
+
+	/**
+	 * Get all parameters
+	 *
+	 * Useful when specifying additional query parameters for the Logout Request
+	 * @return the Logout Request query parameters
+	 */
+	public Map<String, String> getParameters() {
+		return this.parameters;
+	}
+
+	/**
+	 * The identifier for the {@link RelyingPartyRegistration} associated with this Logout
+	 * Request
+	 * @return the {@link RelyingPartyRegistration} id
+	 */
+	public String getRelyingPartyRegistrationId() {
+		return this.relyingPartyRegistrationId;
+	}
+
+	/**
+	 * Create a {@link Builder} instance from this {@link RelyingPartyRegistration}
+	 *
+	 * Specifically, this will pull the <a href=
+	 * "https://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf#page=7">SingleLogoutService</a>
+	 * location and binding from the {@link RelyingPartyRegistration}
+	 * @param registration the {@link RelyingPartyRegistration} to use
+	 * @return the {@link Builder} for further configurations
+	 */
+	public static Builder withRelyingPartyRegistration(RelyingPartyRegistration registration) {
+		return new Builder(registration);
+	}
+
+	public static final class Builder {
+
+		private final RelyingPartyRegistration registration;
+
+		private String location;
+
+		private Saml2MessageBinding binding;
+
+		private Map<String, String> parameters = new HashMap<>();
+
+		private String id;
+
+		private Builder(RelyingPartyRegistration registration) {
+			this.registration = registration;
+			this.location = registration.getAssertingPartyDetails().getSingleLogoutServiceLocation();
+			this.binding = registration.getAssertingPartyDetails().getSingleLogoutServiceBinding();
+		}
+
+		/**
+		 * Use this signed and serialized and Base64-encoded &lt;saml2:LogoutRequest&gt;
+		 *
+		 * Note that if using the Redirect binding, the value should be
+		 * {@link java.util.zip.DeflaterOutputStream deflated} and then Base64-encoded.
+		 *
+		 * It should not be URL-encoded as this will be done when the request is sent
+		 * @param samlRequest the &lt;saml2:LogoutRequest&gt; to use
+		 * @return the {@link Builder} for further configurations
+		 * @see Saml2LogoutRequestResolver
+		 */
+		public Builder samlRequest(String samlRequest) {
+			this.parameters.put("SAMLRequest", samlRequest);
+			return this;
+		}
+
+		/**
+		 * Use this SAML 2.0 Message Binding
+		 *
+		 * By default, the asserting party's configured binding is used
+		 * @param binding the SAML 2.0 Message Binding to use
+		 * @return the {@link Builder} for further configurations
+		 */
+		public Builder binding(Saml2MessageBinding binding) {
+			this.binding = binding;
+			return this;
+		}
+
+		/**
+		 * Use this location for the SAML 2.0 logout endpoint
+		 *
+		 * By default, the asserting party's endpoint is used
+		 * @param location the SAML 2.0 location to use
+		 * @return the {@link Builder} for further configurations
+		 */
+		public Builder location(String location) {
+			this.location = location;
+			return this;
+		}
+
+		/**
+		 * Use this value for the relay state when sending the Logout Request to the
+		 * asserting party
+		 *
+		 * It should not be URL-encoded as this will be done when the request is sent
+		 * @param relayState the relay state
+		 * @return the {@link Builder} for further configurations
+		 */
+		public Builder relayState(String relayState) {
+			this.parameters.put("RelayState", relayState);
+			return this;
+		}
+
+		/**
+		 * This is the unique id used in the {@link #samlRequest}
+		 * @param id the Logout Request id
+		 * @return the {@link Builder} for further configurations
+		 */
+		public Builder id(String id) {
+			this.id = id;
+			return this;
+		}
+
+		/**
+		 * Use this {@link Consumer} to modify the set of query parameters
+		 *
+		 * No parameter should be URL-encoded as this will be done when the request is
+		 * sent
+		 * @param parametersConsumer the {@link Consumer}
+		 * @return the {@link Builder} for further configurations
+		 */
+		public Builder parameters(Consumer<Map<String, String>> parametersConsumer) {
+			parametersConsumer.accept(this.parameters);
+			return this;
+		}
+
+		/**
+		 * Build the {@link Saml2LogoutRequest}
+		 * @return a constructed {@link Saml2LogoutRequest}
+		 */
+		public Saml2LogoutRequest build() {
+			return new Saml2LogoutRequest(this.location, this.binding, this.parameters, this.id,
+					this.registration.getRegistrationId());
+		}
+
+	}
+
+}

+ 38 - 0
saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/Saml2LogoutRequestValidator.java

@@ -0,0 +1,38 @@
+/*
+ * Copyright 2002-2021 the original author or authors.
+ *
+ * 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
+ *
+ *      https://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 org.springframework.security.saml2.provider.service.authentication.logout;
+
+/**
+ * Validates SAML 2.0 Logout Requests
+ *
+ * @author Josh Cummings
+ * @since 5.6
+ */
+public interface Saml2LogoutRequestValidator {
+
+	/**
+	 * Authenticates the SAML 2.0 Logout Request received from the SAML 2.0 Asserting
+	 * Party.
+	 *
+	 * By default, verifies the signature, validates the issuer, destination, and user
+	 * identifier.
+	 * @param parameters the {@link Saml2LogoutRequestValidatorParameters} needed
+	 * @return the authentication result
+	 */
+	Saml2LogoutValidatorResult validate(Saml2LogoutRequestValidatorParameters parameters);
+
+}

+ 73 - 0
saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/Saml2LogoutRequestValidatorParameters.java

@@ -0,0 +1,73 @@
+/*
+ * Copyright 2002-2021 the original author or authors.
+ *
+ * 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
+ *
+ *      https://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 org.springframework.security.saml2.provider.service.authentication.logout;
+
+import org.springframework.security.core.Authentication;
+import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration;
+
+/**
+ * A holder of the parameters needed to invoke {@link Saml2LogoutRequestValidator}
+ *
+ * @author Josh Cummings
+ * @since 5.6
+ */
+public class Saml2LogoutRequestValidatorParameters {
+
+	private final Saml2LogoutRequest request;
+
+	private final RelyingPartyRegistration registration;
+
+	private final Authentication authentication;
+
+	/**
+	 * Construct a {@link Saml2LogoutRequestValidatorParameters}
+	 * @param request the SAML 2.0 Logout Request received from the asserting party
+	 * @param registration the associated {@link RelyingPartyRegistration}
+	 * @param authentication the current user
+	 */
+	public Saml2LogoutRequestValidatorParameters(Saml2LogoutRequest request, RelyingPartyRegistration registration,
+			Authentication authentication) {
+		this.request = request;
+		this.registration = registration;
+		this.authentication = authentication;
+	}
+
+	/**
+	 * The SAML 2.0 Logout Request sent by the asserting party
+	 * @return the logout request
+	 */
+	public Saml2LogoutRequest getLogoutRequest() {
+		return this.request;
+	}
+
+	/**
+	 * The {@link RelyingPartyRegistration} representing this relying party
+	 * @return the relying party
+	 */
+	public RelyingPartyRegistration getRelyingPartyRegistration() {
+		return this.registration;
+	}
+
+	/**
+	 * The current {@link Authentication}
+	 * @return the authenticated user
+	 */
+	public Authentication getAuthentication() {
+		return this.authentication;
+	}
+
+}

+ 207 - 0
saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/Saml2LogoutResponse.java

@@ -0,0 +1,207 @@
+/*
+ * Copyright 2002-2021 the original author or authors.
+ *
+ * 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
+ *
+ *      https://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 org.springframework.security.saml2.provider.service.authentication.logout;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.function.Consumer;
+
+import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration;
+import org.springframework.security.saml2.provider.service.registration.Saml2MessageBinding;
+import org.springframework.security.saml2.provider.service.web.authentication.logout.Saml2LogoutResponseResolver;
+
+/**
+ * A class that represents a signed and serialized SAML 2.0 Logout Response
+ *
+ * @author Josh Cummings
+ * @since 5.6
+ */
+public final class Saml2LogoutResponse {
+
+	private final String location;
+
+	private final Saml2MessageBinding binding;
+
+	private final Map<String, String> parameters;
+
+	private Saml2LogoutResponse(String location, Saml2MessageBinding binding, Map<String, String> parameters) {
+		this.location = location;
+		this.binding = binding;
+		this.parameters = Collections.unmodifiableMap(new HashMap<>(parameters));
+	}
+
+	/**
+	 * Get the response location of the asserting party's <a href=
+	 * "https://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf#page=7">SingleLogoutService</a>
+	 * @return the SingleLogoutService response location
+	 */
+	public String getResponseLocation() {
+		return this.location;
+	}
+
+	/**
+	 * Get the binding for the asserting party's <a href=
+	 * "https://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf#page=7">SingleLogoutService</a>
+	 * @return the SingleLogoutService binding
+	 */
+	public Saml2MessageBinding getBinding() {
+		return this.binding;
+	}
+
+	/**
+	 * Get the signed and serialized &lt;saml2:LogoutResponse&gt; payload
+	 * @return the signed and serialized &lt;saml2:LogoutResponse&gt; payload
+	 */
+	public String getSamlResponse() {
+		return this.parameters.get("SAMLResponse");
+	}
+
+	/**
+	 * The relay state associated with this Logout Request
+	 * @return the relay state
+	 */
+	public String getRelayState() {
+		return this.parameters.get("RelayState");
+	}
+
+	/**
+	 * Get the {@code name} parameter, a short-hand for <code>
+	 *	getParameters().get(name)
+	 * </code>
+	 *
+	 * Useful when specifying additional query parameters for the Logout Response
+	 * @param name the parameter's name
+	 * @return the parameter's value
+	 */
+	public String getParameter(String name) {
+		return this.parameters.get(name);
+	}
+
+	/**
+	 * Get all parameters
+	 *
+	 * Useful when specifying additional query parameters for the Logout Response
+	 * @return the Logout Response query parameters
+	 */
+	public Map<String, String> getParameters() {
+		return this.parameters;
+	}
+
+	/**
+	 * Create a {@link Builder} instance from this {@link RelyingPartyRegistration}
+	 *
+	 * Specifically, this will pull the <a href=
+	 * "https://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf#page=7">SingleLogoutService</a>
+	 * response location and binding from the {@link RelyingPartyRegistration}
+	 * @param registration the {@link RelyingPartyRegistration} to use
+	 * @return the {@link Builder} for further configurations
+	 */
+	public static Builder withRelyingPartyRegistration(RelyingPartyRegistration registration) {
+		return new Builder(registration);
+	}
+
+	public static final class Builder {
+
+		private String location;
+
+		private Saml2MessageBinding binding;
+
+		private Map<String, String> parameters = new HashMap<>();
+
+		private Builder(RelyingPartyRegistration registration) {
+			this.location = registration.getAssertingPartyDetails().getSingleLogoutServiceResponseLocation();
+			this.binding = registration.getAssertingPartyDetails().getSingleLogoutServiceBinding();
+		}
+
+		/**
+		 * Use this signed and serialized and Base64-encoded &lt;saml2:LogoutResponse&gt;
+		 *
+		 * Note that if using the Redirect binding, the value should be
+		 * {@link java.util.zip.DeflaterOutputStream deflated} and then Base64-encoded.
+		 *
+		 * It should not be URL-encoded as this will be done when the response is sent
+		 * @param samlResponse the &lt;saml2:LogoutResponse&gt; to use
+		 * @return the {@link Builder} for further configurations
+		 * @see Saml2LogoutResponseResolver
+		 */
+		public Builder samlResponse(String samlResponse) {
+			this.parameters.put("SAMLResponse", samlResponse);
+			return this;
+		}
+
+		/**
+		 * Use this SAML 2.0 Message Binding
+		 *
+		 * By default, the asserting party's configured binding is used
+		 * @param binding the SAML 2.0 Message Binding to use
+		 * @return the {@link Saml2LogoutRequest.Builder} for further configurations
+		 */
+		public Builder binding(Saml2MessageBinding binding) {
+			this.binding = binding;
+			return this;
+		}
+
+		/**
+		 * Use this location for the SAML 2.0 logout endpoint
+		 *
+		 * By default, the asserting party's endpoint is used
+		 * @param location the SAML 2.0 location to use
+		 * @return the {@link Saml2LogoutRequest.Builder} for further configurations
+		 */
+		public Builder location(String location) {
+			this.location = location;
+			return this;
+		}
+
+		/**
+		 * Use this value for the relay state when sending the Logout Request to the
+		 * asserting party
+		 *
+		 * It should not be URL-encoded as this will be done when the response is sent
+		 * @param relayState the relay state
+		 * @return the {@link Builder} for further configurations
+		 */
+		public Builder relayState(String relayState) {
+			this.parameters.put("RelayState", relayState);
+			return this;
+		}
+
+		/**
+		 * Use this {@link Consumer} to modify the set of query parameters
+		 *
+		 * No parameter should be URL-encoded as this will be done when the response is
+		 * sent, though any signature specified should be Base64-encoded
+		 * @param parametersConsumer the {@link Consumer}
+		 * @return the {@link Builder} for further configurations
+		 */
+		public Builder parameters(Consumer<Map<String, String>> parametersConsumer) {
+			parametersConsumer.accept(this.parameters);
+			return this;
+		}
+
+		/**
+		 * Build the {@link Saml2LogoutResponse}
+		 * @return a constructed {@link Saml2LogoutResponse}
+		 */
+		public Saml2LogoutResponse build() {
+			return new Saml2LogoutResponse(this.location, this.binding, this.parameters);
+		}
+
+	}
+
+}

+ 38 - 0
saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/Saml2LogoutResponseValidator.java

@@ -0,0 +1,38 @@
+/*
+ * Copyright 2002-2021 the original author or authors.
+ *
+ * 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
+ *
+ *      https://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 org.springframework.security.saml2.provider.service.authentication.logout;
+
+/**
+ * Validates SAML 2.0 Logout Responses
+ *
+ * @author Josh Cummings
+ * @since 5.6
+ */
+public interface Saml2LogoutResponseValidator {
+
+	/**
+	 * Authenticates the SAML 2.0 Logout Response received from the SAML 2.0 Asserting
+	 * Party.
+	 *
+	 * By default, verifies the signature, validates the issuer, destination, and status.
+	 * It also ensures that it aligns with the given logout request.
+	 * @param parameters the {@link Saml2LogoutResponseValidatorParameters} needed
+	 * @return the authentication result
+	 */
+	Saml2LogoutValidatorResult validate(Saml2LogoutResponseValidatorParameters parameters);
+
+}

+ 72 - 0
saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/Saml2LogoutResponseValidatorParameters.java

@@ -0,0 +1,72 @@
+/*
+ * Copyright 2002-2021 the original author or authors.
+ *
+ * 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
+ *
+ *      https://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 org.springframework.security.saml2.provider.service.authentication.logout;
+
+import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration;
+
+/**
+ * A holder of the parameters needed to invoke {@link Saml2LogoutResponseValidator}
+ *
+ * @author Josh Cummings
+ * @since 5.6
+ */
+public class Saml2LogoutResponseValidatorParameters {
+
+	private final Saml2LogoutResponse response;
+
+	private final Saml2LogoutRequest request;
+
+	private final RelyingPartyRegistration registration;
+
+	/**
+	 * Construct a {@link Saml2LogoutRequestValidatorParameters}
+	 * @param response the SAML 2.0 Logout Response received from the asserting party
+	 * @param request the SAML 2.0 Logout Request send by this application
+	 * @param registration the associated {@link RelyingPartyRegistration}
+	 */
+	public Saml2LogoutResponseValidatorParameters(Saml2LogoutResponse response, Saml2LogoutRequest request,
+			RelyingPartyRegistration registration) {
+		this.response = response;
+		this.request = request;
+		this.registration = registration;
+	}
+
+	/**
+	 * The SAML 2.0 Logout Response received from the asserting party
+	 * @return the logout response
+	 */
+	public Saml2LogoutResponse getLogoutResponse() {
+		return this.response;
+	}
+
+	/**
+	 * The SAML 2.0 Logout Request sent by this application
+	 * @return the logout request
+	 */
+	public Saml2LogoutRequest getLogoutRequest() {
+		return this.request;
+	}
+
+	/**
+	 * The {@link RelyingPartyRegistration} representing this relying party
+	 * @return the relying party
+	 */
+	public RelyingPartyRegistration getRelyingPartyRegistration() {
+		return this.registration;
+	}
+
+}

+ 106 - 0
saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/Saml2LogoutValidatorResult.java

@@ -0,0 +1,106 @@
+/*
+ * Copyright 2002-2020 the original author or authors.
+ *
+ * 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
+ *
+ *      https://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 org.springframework.security.saml2.provider.service.authentication.logout;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.function.Consumer;
+
+import org.springframework.security.saml2.core.Saml2Error;
+import org.springframework.util.Assert;
+
+/**
+ * A result emitted from a SAML 2.0 Logout validation attempt
+ *
+ * @author Josh Cummings
+ * @since 5.6
+ */
+public final class Saml2LogoutValidatorResult {
+
+	static final Saml2LogoutValidatorResult NO_ERRORS = new Saml2LogoutValidatorResult(Collections.emptyList());
+
+	private final Collection<Saml2Error> errors;
+
+	private Saml2LogoutValidatorResult(Collection<Saml2Error> errors) {
+		Assert.notNull(errors, "errors cannot be null");
+		this.errors = new ArrayList<>(errors);
+	}
+
+	/**
+	 * Say whether this result indicates success
+	 * @return whether this result has errors
+	 */
+	public boolean hasErrors() {
+		return !this.errors.isEmpty();
+	}
+
+	/**
+	 * Return error details regarding the validation attempt
+	 * @return the collection of results in this result, if any; returns an empty list
+	 * otherwise
+	 */
+	public Collection<Saml2Error> getErrors() {
+		return Collections.unmodifiableCollection(this.errors);
+	}
+
+	/**
+	 * Construct a successful {@link Saml2LogoutValidatorResult}
+	 * @return an {@link Saml2LogoutValidatorResult} with no errors
+	 */
+	public static Saml2LogoutValidatorResult success() {
+		return NO_ERRORS;
+	}
+
+	/**
+	 * Construct a {@link Saml2LogoutValidatorResult.Builder}, starting with the given
+	 * {@code errors}.
+	 *
+	 * Note that a result with no errors is considered a success.
+	 * @param errors
+	 * @return
+	 */
+	public static Saml2LogoutValidatorResult.Builder withErrors(Saml2Error... errors) {
+		return new Builder(errors);
+	}
+
+	public static final class Builder {
+
+		private final Collection<Saml2Error> errors;
+
+		private Builder(Saml2Error... errors) {
+			this(Arrays.asList(errors));
+		}
+
+		private Builder(Collection<Saml2Error> errors) {
+			Assert.noNullElements(errors, "errors cannot have null elements");
+			this.errors = new ArrayList<>(errors);
+		}
+
+		public Builder errors(Consumer<Collection<Saml2Error>> errorsConsumer) {
+			errorsConsumer.accept(this.errors);
+			return this;
+		}
+
+		public Saml2LogoutValidatorResult build() {
+			return new Saml2LogoutValidatorResult(this.errors);
+		}
+
+	}
+
+}

+ 76 - 0
saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/Saml2Utils.java

@@ -0,0 +1,76 @@
+/*
+ * Copyright 2002-2021 the original author or authors.
+ *
+ * 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
+ *
+ *      https://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 org.springframework.security.saml2.provider.service.authentication.logout;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.zip.Deflater;
+import java.util.zip.DeflaterOutputStream;
+import java.util.zip.Inflater;
+import java.util.zip.InflaterOutputStream;
+
+import org.springframework.security.saml2.Saml2Exception;
+
+/**
+ * Utility methods for working with serialized SAML messages.
+ *
+ * For internal use only.
+ *
+ * @author Josh Cummings
+ */
+final class Saml2Utils {
+
+	private Saml2Utils() {
+	}
+
+	static String samlEncode(byte[] b) {
+		return Base64.getEncoder().encodeToString(b);
+	}
+
+	static byte[] samlDecode(String s) {
+		return Base64.getDecoder().decode(s);
+	}
+
+	static byte[] samlDeflate(String s) {
+		try {
+			ByteArrayOutputStream b = new ByteArrayOutputStream();
+			DeflaterOutputStream deflater = new DeflaterOutputStream(b, new Deflater(Deflater.DEFLATED, true));
+			deflater.write(s.getBytes(StandardCharsets.UTF_8));
+			deflater.finish();
+			return b.toByteArray();
+		}
+		catch (IOException ex) {
+			throw new Saml2Exception("Unable to deflate string", ex);
+		}
+	}
+
+	static String samlInflate(byte[] b) {
+		try {
+			ByteArrayOutputStream out = new ByteArrayOutputStream();
+			InflaterOutputStream iout = new InflaterOutputStream(out, new Inflater(true));
+			iout.write(b);
+			iout.finish();
+			return new String(out.toByteArray(), StandardCharsets.UTF_8);
+		}
+		catch (IOException ex) {
+			throw new Saml2Exception("Unable to inflate string", ex);
+		}
+	}
+
+}

+ 10 - 0
saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/metadata/OpenSamlMetadataResolver.java

@@ -32,6 +32,7 @@ import org.opensaml.saml.saml2.metadata.AssertionConsumerService;
 import org.opensaml.saml.saml2.metadata.EntityDescriptor;
 import org.opensaml.saml.saml2.metadata.KeyDescriptor;
 import org.opensaml.saml.saml2.metadata.SPSSODescriptor;
+import org.opensaml.saml.saml2.metadata.SingleLogoutService;
 import org.opensaml.saml.saml2.metadata.impl.EntityDescriptorMarshaller;
 import org.opensaml.security.credential.UsageType;
 import org.opensaml.xmlsec.signature.KeyInfo;
@@ -85,6 +86,7 @@ public final class OpenSamlMetadataResolver implements Saml2MetadataResolver {
 		spSsoDescriptor.getKeyDescriptors()
 				.addAll(buildKeys(registration.getDecryptionX509Credentials(), UsageType.ENCRYPTION));
 		spSsoDescriptor.getAssertionConsumerServices().add(buildAssertionConsumerService(registration));
+		spSsoDescriptor.getSingleLogoutServices().add(buildSingleLogoutService(registration));
 		return spSsoDescriptor;
 	}
 
@@ -123,6 +125,14 @@ public final class OpenSamlMetadataResolver implements Saml2MetadataResolver {
 		return assertionConsumerService;
 	}
 
+	private SingleLogoutService buildSingleLogoutService(RelyingPartyRegistration registration) {
+		SingleLogoutService singleLogoutService = build(SingleLogoutService.DEFAULT_ELEMENT_NAME);
+		singleLogoutService.setLocation(registration.getSingleLogoutServiceLocation());
+		singleLogoutService.setResponseLocation(registration.getSingleLogoutServiceResponseLocation());
+		singleLogoutService.setBinding(registration.getSingleLogoutServiceBinding().getUrn());
+		return singleLogoutService;
+	}
+
 	@SuppressWarnings("unchecked")
 	private <T> T build(QName elementName) {
 		XMLObjectBuilder<?> builder = XMLObjectProviderRegistrySupport.getBuilderFactory().getBuilder(elementName);

+ 25 - 3
saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/registration/OpenSamlAssertingPartyMetadataConverter.java

@@ -34,6 +34,7 @@ import org.opensaml.saml.saml2.metadata.EntityDescriptor;
 import org.opensaml.saml.saml2.metadata.Extensions;
 import org.opensaml.saml.saml2.metadata.IDPSSODescriptor;
 import org.opensaml.saml.saml2.metadata.KeyDescriptor;
+import org.opensaml.saml.saml2.metadata.SingleLogoutService;
 import org.opensaml.saml.saml2.metadata.SingleSignOnService;
 import org.opensaml.security.credential.UsageType;
 import org.opensaml.xmlsec.keyinfo.KeyInfoSupport;
@@ -105,6 +106,10 @@ class OpenSamlAssertingPartyMetadataConverter {
 			builder.assertingPartyDetails(
 					(party) -> party.signingAlgorithms((algorithms) -> algorithms.add(method.getAlgorithm())));
 		}
+		if (idpssoDescriptor.getSingleSignOnServices().isEmpty()) {
+			throw new Saml2Exception(
+					"Metadata response is missing a SingleSignOnService, necessary for sending AuthnRequests");
+		}
 		for (SingleSignOnService singleSignOnService : idpssoDescriptor.getSingleSignOnServices()) {
 			Saml2MessageBinding binding;
 			if (singleSignOnService.getBinding().equals(Saml2MessageBinding.POST.getUrn())) {
@@ -119,10 +124,27 @@ class OpenSamlAssertingPartyMetadataConverter {
 			builder.assertingPartyDetails(
 					(party) -> party.singleSignOnServiceLocation(singleSignOnService.getLocation())
 							.singleSignOnServiceBinding(binding));
-			return builder;
+			break;
+		}
+		for (SingleLogoutService singleLogoutService : idpssoDescriptor.getSingleLogoutServices()) {
+			Saml2MessageBinding binding;
+			if (singleLogoutService.getBinding().equals(Saml2MessageBinding.POST.getUrn())) {
+				binding = Saml2MessageBinding.POST;
+			}
+			else if (singleLogoutService.getBinding().equals(Saml2MessageBinding.REDIRECT.getUrn())) {
+				binding = Saml2MessageBinding.REDIRECT;
+			}
+			else {
+				continue;
+			}
+			String responseLocation = (singleLogoutService.getResponseLocation() == null)
+					? singleLogoutService.getLocation() : singleLogoutService.getResponseLocation();
+			builder.assertingPartyDetails(
+					(party) -> party.singleLogoutServiceLocation(singleLogoutService.getLocation())
+							.singleLogoutServiceResponseLocation(responseLocation).singleLogoutServiceBinding(binding));
+			break;
 		}
-		throw new Saml2Exception(
-				"Metadata response is missing a SingleSignOnService, necessary for sending AuthnRequests");
+		return builder;
 	}
 
 	private List<X509Certificate> certificates(KeyDescriptor keyDescriptor) {

+ 250 - 6
saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/registration/RelyingPartyRegistration.java

@@ -81,6 +81,12 @@ public final class RelyingPartyRegistration {
 
 	private final Saml2MessageBinding assertionConsumerServiceBinding;
 
+	private final String singleLogoutServiceLocation;
+
+	private final String singleLogoutServiceResponseLocation;
+
+	private final Saml2MessageBinding singleLogoutServiceBinding;
+
 	private final ProviderDetails providerDetails;
 
 	private final List<org.springframework.security.saml2.credentials.Saml2X509Credential> credentials;
@@ -90,7 +96,9 @@ public final class RelyingPartyRegistration {
 	private final Collection<Saml2X509Credential> signingX509Credentials;
 
 	private RelyingPartyRegistration(String registrationId, String entityId, String assertionConsumerServiceLocation,
-			Saml2MessageBinding assertionConsumerServiceBinding, ProviderDetails providerDetails,
+			Saml2MessageBinding assertionConsumerServiceBinding, String singleLogoutServiceLocation,
+			String singleLogoutServiceResponseLocation, Saml2MessageBinding singleLogoutServiceBinding,
+			ProviderDetails providerDetails,
 			Collection<org.springframework.security.saml2.credentials.Saml2X509Credential> credentials,
 			Collection<Saml2X509Credential> decryptionX509Credentials,
 			Collection<Saml2X509Credential> signingX509Credentials) {
@@ -118,6 +126,9 @@ public final class RelyingPartyRegistration {
 		this.entityId = entityId;
 		this.assertionConsumerServiceLocation = assertionConsumerServiceLocation;
 		this.assertionConsumerServiceBinding = assertionConsumerServiceBinding;
+		this.singleLogoutServiceLocation = singleLogoutServiceLocation;
+		this.singleLogoutServiceResponseLocation = singleLogoutServiceResponseLocation;
+		this.singleLogoutServiceBinding = singleLogoutServiceBinding;
 		this.providerDetails = providerDetails;
 		this.credentials = Collections.unmodifiableList(new LinkedList<>(credentials));
 		this.decryptionX509Credentials = Collections.unmodifiableList(new LinkedList<>(decryptionX509Credentials));
@@ -177,6 +188,52 @@ public final class RelyingPartyRegistration {
 		return this.assertionConsumerServiceBinding;
 	}
 
+	/**
+	 * Get the <a href=
+	 * "https://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf#page=7">SingleLogoutService
+	 * Binding</a>
+	 *
+	 *
+	 * <p>
+	 * Equivalent to the value found in &lt;SingleLogoutService Binding="..."/&gt; in the
+	 * relying party's &lt;SPSSODescriptor&gt;.
+	 * @return the SingleLogoutService Binding
+	 * @since 5.6
+	 */
+	public Saml2MessageBinding getSingleLogoutServiceBinding() {
+		return this.singleLogoutServiceBinding;
+	}
+
+	/**
+	 * Get the <a href=
+	 * "https://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf#page=7">SingleLogoutService
+	 * Location</a>
+	 *
+	 * <p>
+	 * Equivalent to the value found in &lt;SingleLogoutService Location="..."/&gt; in the
+	 * relying party's &lt;SPSSODescriptor&gt;.
+	 * @return the SingleLogoutService Location
+	 * @since 5.6
+	 */
+	public String getSingleLogoutServiceLocation() {
+		return this.singleLogoutServiceLocation;
+	}
+
+	/**
+	 * Get the <a href=
+	 * "https://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf#page=7">SingleLogoutService
+	 * Response Location</a>
+	 *
+	 * <p>
+	 * Equivalent to the value found in &lt;SingleLogoutService
+	 * ResponseLocation="..."/&gt; in the relying party's &lt;SPSSODescriptor&gt;.
+	 * @return the SingleLogoutService Response Location
+	 * @since 5.6
+	 */
+	public String getSingleLogoutServiceResponseLocation() {
+		return this.singleLogoutServiceResponseLocation;
+	}
+
 	/**
 	 * Get the {@link Collection} of decryption {@link Saml2X509Credential}s associated
 	 * with this relying party
@@ -364,6 +421,9 @@ public final class RelyingPartyRegistration {
 				.decryptionX509Credentials((c) -> c.addAll(registration.getDecryptionX509Credentials()))
 				.assertionConsumerServiceLocation(registration.getAssertionConsumerServiceLocation())
 				.assertionConsumerServiceBinding(registration.getAssertionConsumerServiceBinding())
+				.singleLogoutServiceLocation(registration.getSingleLogoutServiceLocation())
+				.singleLogoutServiceResponseLocation(registration.getSingleLogoutServiceResponseLocation())
+				.singleLogoutServiceBinding(registration.getSingleLogoutServiceBinding())
 				.assertingPartyDetails((assertingParty) -> assertingParty
 						.entityId(registration.getAssertingPartyDetails().getEntityId())
 						.wantAuthnRequestsSigned(registration.getAssertingPartyDetails().getWantAuthnRequestsSigned())
@@ -376,7 +436,13 @@ public final class RelyingPartyRegistration {
 						.singleSignOnServiceLocation(
 								registration.getAssertingPartyDetails().getSingleSignOnServiceLocation())
 						.singleSignOnServiceBinding(
-								registration.getAssertingPartyDetails().getSingleSignOnServiceBinding()));
+								registration.getAssertingPartyDetails().getSingleSignOnServiceBinding())
+						.singleLogoutServiceLocation(
+								registration.getAssertingPartyDetails().getSingleLogoutServiceLocation())
+						.singleLogoutServiceResponseLocation(
+								registration.getAssertingPartyDetails().getSingleLogoutServiceResponseLocation())
+						.singleLogoutServiceBinding(
+								registration.getAssertingPartyDetails().getSingleLogoutServiceBinding()));
 	}
 
 	private static Saml2X509Credential fromDeprecated(
@@ -445,10 +511,17 @@ public final class RelyingPartyRegistration {
 
 		private final Saml2MessageBinding singleSignOnServiceBinding;
 
+		private final String singleLogoutServiceLocation;
+
+		private final String singleLogoutServiceResponseLocation;
+
+		private final Saml2MessageBinding singleLogoutServiceBinding;
+
 		private AssertingPartyDetails(String entityId, boolean wantAuthnRequestsSigned, List<String> signingAlgorithms,
 				Collection<Saml2X509Credential> verificationX509Credentials,
 				Collection<Saml2X509Credential> encryptionX509Credentials, String singleSignOnServiceLocation,
-				Saml2MessageBinding singleSignOnServiceBinding) {
+				Saml2MessageBinding singleSignOnServiceBinding, String singleLogoutServiceLocation,
+				String singleLogoutServiceResponseLocation, Saml2MessageBinding singleLogoutServiceBinding) {
 			Assert.hasText(entityId, "entityId cannot be null or empty");
 			Assert.notEmpty(signingAlgorithms, "signingAlgorithms cannot be empty");
 			Assert.notNull(verificationX509Credentials, "verificationX509Credentials cannot be null");
@@ -472,6 +545,9 @@ public final class RelyingPartyRegistration {
 			this.encryptionX509Credentials = encryptionX509Credentials;
 			this.singleSignOnServiceLocation = singleSignOnServiceLocation;
 			this.singleSignOnServiceBinding = singleSignOnServiceBinding;
+			this.singleLogoutServiceLocation = singleLogoutServiceLocation;
+			this.singleLogoutServiceResponseLocation = singleLogoutServiceResponseLocation;
+			this.singleLogoutServiceBinding = singleLogoutServiceBinding;
 		}
 
 		/**
@@ -565,6 +641,51 @@ public final class RelyingPartyRegistration {
 			return this.singleSignOnServiceBinding;
 		}
 
+		/**
+		 * Get the <a href=
+		 * "https://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf#page=7">SingleLogoutService
+		 * Location</a>
+		 *
+		 * <p>
+		 * Equivalent to the value found in &lt;SingleLogoutService Location="..."/&gt; in
+		 * the asserting party's &lt;IDPSSODescriptor&gt;.
+		 * @return the SingleLogoutService Location
+		 * @since 5.6
+		 */
+		public String getSingleLogoutServiceLocation() {
+			return this.singleLogoutServiceLocation;
+		}
+
+		/**
+		 * Get the <a href=
+		 * "https://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf#page=7">SingleLogoutService
+		 * Response Location</a>
+		 *
+		 * <p>
+		 * Equivalent to the value found in &lt;SingleLogoutService Location="..."/&gt; in
+		 * the asserting party's &lt;IDPSSODescriptor&gt;.
+		 * @return the SingleLogoutService Response Location
+		 * @since 5.6
+		 */
+		public String getSingleLogoutServiceResponseLocation() {
+			return this.singleLogoutServiceResponseLocation;
+		}
+
+		/**
+		 * Get the <a href=
+		 * "https://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf#page=7">SingleLogoutService
+		 * Binding</a>
+		 *
+		 * <p>
+		 * Equivalent to the value found in &lt;SingleLogoutService Binding="..."/&gt; in
+		 * the asserting party's &lt;IDPSSODescriptor&gt;.
+		 * @return the SingleLogoutService Binding
+		 * @since 5.6
+		 */
+		public Saml2MessageBinding getSingleLogoutServiceBinding() {
+			return this.singleLogoutServiceBinding;
+		}
+
 		public static final class Builder {
 
 			private String entityId;
@@ -581,6 +702,12 @@ public final class RelyingPartyRegistration {
 
 			private Saml2MessageBinding singleSignOnServiceBinding = Saml2MessageBinding.REDIRECT;
 
+			private String singleLogoutServiceLocation;
+
+			private String singleLogoutServiceResponseLocation;
+
+			private Saml2MessageBinding singleLogoutServiceBinding = Saml2MessageBinding.REDIRECT;
+
 			/**
 			 * Set the asserting party's <a href=
 			 * "https://www.oasis-open.org/committees/download.php/51890/SAML%20MD%20simplified%20overview.pdf#2.9%20EntityDescriptor">EntityID</a>.
@@ -677,6 +804,59 @@ public final class RelyingPartyRegistration {
 				return this;
 			}
 
+			/**
+			 * Set the <a href=
+			 * "https://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf#page=7">SingleLogoutService
+			 * Location</a>
+			 *
+			 * <p>
+			 * Equivalent to the value found in &lt;SingleLogoutService
+			 * Location="..."/&gt; in the asserting party's &lt;IDPSSODescriptor&gt;.
+			 * @param singleLogoutServiceLocation the SingleLogoutService Location
+			 * @return the {@link AssertingPartyDetails.Builder} for further configuration
+			 * @since 5.6
+			 */
+			public Builder singleLogoutServiceLocation(String singleLogoutServiceLocation) {
+				this.singleLogoutServiceLocation = singleLogoutServiceLocation;
+				return this;
+			}
+
+			/**
+			 * Set the <a href=
+			 * "https://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf#page=7">SingleLogoutService
+			 * Response Location</a>
+			 *
+			 * <p>
+			 * Equivalent to the value found in &lt;SingleLogoutService
+			 * ResponseLocation="..."/&gt; in the asserting party's
+			 * &lt;IDPSSODescriptor&gt;.
+			 * @param singleLogoutServiceResponseLocation the SingleLogoutService Response
+			 * Location
+			 * @return the {@link AssertingPartyDetails.Builder} for further configuration
+			 * @since 5.6
+			 */
+			public Builder singleLogoutServiceResponseLocation(String singleLogoutServiceResponseLocation) {
+				this.singleLogoutServiceResponseLocation = singleLogoutServiceResponseLocation;
+				return this;
+			}
+
+			/**
+			 * Set the <a href=
+			 * "https://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf#page=7">SingleLogoutService
+			 * Binding</a>
+			 *
+			 * <p>
+			 * Equivalent to the value found in &lt;SingleLogoutService Binding="..."/&gt;
+			 * in the asserting party's &lt;IDPSSODescriptor&gt;.
+			 * @param singleLogoutServiceBinding the SingleLogoutService Binding
+			 * @return the {@link AssertingPartyDetails.Builder} for further configuration
+			 * @since 5.6
+			 */
+			public Builder singleLogoutServiceBinding(Saml2MessageBinding singleLogoutServiceBinding) {
+				this.singleLogoutServiceBinding = singleLogoutServiceBinding;
+				return this;
+			}
+
 			/**
 			 * Creates an immutable ProviderDetails object representing the configuration
 			 * for an Identity Provider, IDP
@@ -689,7 +869,9 @@ public final class RelyingPartyRegistration {
 
 				return new AssertingPartyDetails(this.entityId, this.wantAuthnRequestsSigned, signingAlgorithms,
 						this.verificationX509Credentials, this.encryptionX509Credentials,
-						this.singleSignOnServiceLocation, this.singleSignOnServiceBinding);
+						this.singleSignOnServiceLocation, this.singleSignOnServiceBinding,
+						this.singleLogoutServiceLocation, this.singleLogoutServiceResponseLocation,
+						this.singleLogoutServiceBinding);
 			}
 
 		}
@@ -830,6 +1012,12 @@ public final class RelyingPartyRegistration {
 
 		private Saml2MessageBinding assertionConsumerServiceBinding = Saml2MessageBinding.POST;
 
+		private String singleLogoutServiceLocation = "{baseUrl}/logout/saml2/slo";
+
+		private String singleLogoutServiceResponseLocation;
+
+		private Saml2MessageBinding singleLogoutServiceBinding = Saml2MessageBinding.POST;
+
 		private ProviderDetails.Builder providerDetails = new ProviderDetails.Builder();
 
 		private Collection<org.springframework.security.saml2.credentials.Saml2X509Credential> credentials = new HashSet<>();
@@ -933,6 +1121,58 @@ public final class RelyingPartyRegistration {
 			return this;
 		}
 
+		/**
+		 * Set the <a href=
+		 * "https://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf#page=7">SingleLogoutService
+		 * Binding</a>
+		 *
+		 * <p>
+		 * Equivalent to the value found in &lt;SingleLogoutService Binding="..."/&gt; in
+		 * the relying party's &lt;SPSSODescriptor&gt;.
+		 * @param singleLogoutServiceBinding the SingleLogoutService Binding
+		 * @return the {@link Builder} for further configuration
+		 * @since 5.6
+		 */
+		public Builder singleLogoutServiceBinding(Saml2MessageBinding singleLogoutServiceBinding) {
+			this.singleLogoutServiceBinding = singleLogoutServiceBinding;
+			return this;
+		}
+
+		/**
+		 * Set the <a href=
+		 * "https://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf#page=7">SingleLogoutService
+		 * Location</a>
+		 *
+		 * <p>
+		 * Equivalent to the value found in &lt;SingleLogoutService Location="..."/&gt; in
+		 * the relying party's &lt;SPSSODescriptor&gt;.
+		 * @param singleLogoutServiceLocation the SingleLogoutService Location
+		 * @return the {@link Builder} for further configuration
+		 * @since 5.6
+		 */
+		public Builder singleLogoutServiceLocation(String singleLogoutServiceLocation) {
+			this.singleLogoutServiceLocation = singleLogoutServiceLocation;
+			return this;
+		}
+
+		/**
+		 * Set the <a href=
+		 * "https://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf#page=7">SingleLogoutService
+		 * Response Location</a>
+		 *
+		 * <p>
+		 * Equivalent to the value found in &lt;SingleLogoutService
+		 * ResponseLocation="..."/&gt; in the relying party's &lt;SPSSODescriptor&gt;.
+		 * @param singleLogoutServiceResponseLocation the SingleLogoutService Response
+		 * Location
+		 * @return the {@link Builder} for further configuration
+		 * @since 5.6
+		 */
+		public Builder singleLogoutServiceResponseLocation(String singleLogoutServiceResponseLocation) {
+			this.singleLogoutServiceResponseLocation = singleLogoutServiceResponseLocation;
+			return this;
+		}
+
 		/**
 		 * Apply this {@link Consumer} to further configure the Asserting Party details
 		 * @param assertingPartyDetails The {@link Consumer} to apply
@@ -1075,10 +1315,14 @@ public final class RelyingPartyRegistration {
 			for (Saml2X509Credential credential : this.providerDetails.assertingPartyDetailsBuilder.encryptionX509Credentials) {
 				this.credentials.add(toDeprecated(credential));
 			}
+			if (this.singleLogoutServiceResponseLocation == null) {
+				this.singleLogoutServiceResponseLocation = this.singleLogoutServiceLocation;
+			}
 			return new RelyingPartyRegistration(this.registrationId, this.entityId,
 					this.assertionConsumerServiceLocation, this.assertionConsumerServiceBinding,
-					this.providerDetails.build(), this.credentials, this.decryptionX509Credentials,
-					this.signingX509Credentials);
+					this.singleLogoutServiceLocation, this.singleLogoutServiceResponseLocation,
+					this.singleLogoutServiceBinding, this.providerDetails.build(), this.credentials,
+					this.decryptionX509Credentials, this.signingX509Credentials);
 		}
 
 	}

+ 10 - 2
saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/DefaultRelyingPartyRegistrationResolver.java

@@ -45,7 +45,7 @@ import org.springframework.web.util.UriComponentsBuilder;
  * @since 5.4
  */
 public final class DefaultRelyingPartyRegistrationResolver
-		implements RelyingPartyRegistrationResolver, Converter<HttpServletRequest, RelyingPartyRegistration> {
+		implements Converter<HttpServletRequest, RelyingPartyRegistration>, RelyingPartyRegistrationResolver {
 
 	private Log logger = LogFactory.getLog(getClass());
 
@@ -98,9 +98,14 @@ public final class DefaultRelyingPartyRegistrationResolver
 		String relyingPartyEntityId = templateResolver.apply(relyingPartyRegistration.getEntityId());
 		String assertionConsumerServiceLocation = templateResolver
 				.apply(relyingPartyRegistration.getAssertionConsumerServiceLocation());
+		String singleLogoutServiceLocation = templateResolver
+				.apply(relyingPartyRegistration.getSingleLogoutServiceLocation());
+		String singleLogoutServiceResponseLocation = templateResolver
+				.apply(relyingPartyRegistration.getSingleLogoutServiceResponseLocation());
 		return RelyingPartyRegistration.withRelyingPartyRegistration(relyingPartyRegistration)
 				.entityId(relyingPartyEntityId).assertionConsumerServiceLocation(assertionConsumerServiceLocation)
-				.build();
+				.singleLogoutServiceLocation(singleLogoutServiceLocation)
+				.singleLogoutServiceResponseLocation(singleLogoutServiceResponseLocation).build();
 	}
 
 	private Function<String, String> templateResolver(String applicationUri, RelyingPartyRegistration relyingParty) {
@@ -108,6 +113,9 @@ public final class DefaultRelyingPartyRegistrationResolver
 	}
 
 	private static String resolveUrlTemplate(String template, String baseUrl, RelyingPartyRegistration relyingParty) {
+		if (template == null) {
+			return null;
+		}
 		String entityId = relyingParty.getAssertingPartyDetails().getEntityId();
 		String registrationId = relyingParty.getRegistrationId();
 		Map<String, String> uriVariables = new HashMap<>();

+ 105 - 0
saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/HttpSessionLogoutRequestRepository.java

@@ -0,0 +1,105 @@
+/*
+ * Copyright 2002-2021 the original author or authors.
+ *
+ * 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
+ *
+ *      https://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 org.springframework.security.saml2.provider.service.web.authentication.logout;
+
+import java.security.MessageDigest;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import javax.servlet.http.HttpSession;
+
+import org.springframework.security.crypto.codec.Utf8;
+import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutRequest;
+import org.springframework.util.Assert;
+
+/**
+ * An implementation of an {@link Saml2LogoutRequestRepository} that stores
+ * {@link Saml2LogoutRequest} in the {@code HttpSession}.
+ *
+ * @author Josh Cummings
+ * @since 5.6
+ * @see Saml2LogoutRequestRepository
+ * @see Saml2LogoutRequest
+ */
+public final class HttpSessionLogoutRequestRepository implements Saml2LogoutRequestRepository {
+
+	private static final String DEFAULT_LOGOUT_REQUEST_ATTR_NAME = HttpSessionLogoutRequestRepository.class.getName()
+			+ ".LOGOUT_REQUEST";
+
+	/**
+	 * {@inheritDoc}
+	 */
+	@Override
+	public Saml2LogoutRequest loadLogoutRequest(HttpServletRequest request) {
+		Assert.notNull(request, "request cannot be null");
+		HttpSession session = request.getSession(false);
+		if (session == null) {
+			return null;
+		}
+		Saml2LogoutRequest logoutRequest = (Saml2LogoutRequest) session.getAttribute(DEFAULT_LOGOUT_REQUEST_ATTR_NAME);
+		if (stateParameterEquals(request, logoutRequest)) {
+			return logoutRequest;
+		}
+		return null;
+	}
+
+	/**
+	 * {@inheritDoc}
+	 */
+	@Override
+	public void saveLogoutRequest(Saml2LogoutRequest logoutRequest, HttpServletRequest request,
+			HttpServletResponse response) {
+		Assert.notNull(request, "request cannot be null");
+		Assert.notNull(response, "response cannot be null");
+		if (logoutRequest == null) {
+			request.getSession().removeAttribute(DEFAULT_LOGOUT_REQUEST_ATTR_NAME);
+			return;
+		}
+		String state = logoutRequest.getRelayState();
+		Assert.hasText(state, "logoutRequest.state cannot be empty");
+		request.getSession().setAttribute(DEFAULT_LOGOUT_REQUEST_ATTR_NAME, logoutRequest);
+	}
+
+	/**
+	 * {@inheritDoc}
+	 */
+	@Override
+	public Saml2LogoutRequest removeLogoutRequest(HttpServletRequest request, HttpServletResponse response) {
+		Assert.notNull(request, "request cannot be null");
+		Assert.notNull(response, "response cannot be null");
+		Saml2LogoutRequest logoutRequest = loadLogoutRequest(request);
+		if (logoutRequest == null) {
+			return null;
+		}
+		request.getSession().removeAttribute(DEFAULT_LOGOUT_REQUEST_ATTR_NAME);
+		return logoutRequest;
+	}
+
+	private String getStateParameter(HttpServletRequest request) {
+		return request.getParameter("RelayState");
+	}
+
+	private boolean stateParameterEquals(HttpServletRequest request, Saml2LogoutRequest logoutRequest) {
+		String stateParameter = getStateParameter(request);
+		if (stateParameter == null || logoutRequest == null) {
+			return false;
+		}
+		String relayState = logoutRequest.getRelayState();
+		return MessageDigest.isEqual(Utf8.encode(stateParameter), Utf8.encode(relayState));
+	}
+
+}

+ 167 - 0
saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSamlLogoutRequestResolver.java

@@ -0,0 +1,167 @@
+/*
+ * Copyright 2002-2021 the original author or authors.
+ *
+ * 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
+ *
+ *      https://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 org.springframework.security.saml2.provider.service.web.authentication.logout;
+
+import java.nio.charset.StandardCharsets;
+import java.util.UUID;
+import java.util.function.BiConsumer;
+
+import javax.servlet.http.HttpServletRequest;
+
+import net.shibboleth.utilities.java.support.xml.SerializeSupport;
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+import org.opensaml.core.config.ConfigurationService;
+import org.opensaml.core.xml.config.XMLObjectProviderRegistry;
+import org.opensaml.core.xml.io.MarshallingException;
+import org.opensaml.saml.saml2.core.Issuer;
+import org.opensaml.saml.saml2.core.LogoutRequest;
+import org.opensaml.saml.saml2.core.NameID;
+import org.opensaml.saml.saml2.core.impl.IssuerBuilder;
+import org.opensaml.saml.saml2.core.impl.LogoutRequestBuilder;
+import org.opensaml.saml.saml2.core.impl.LogoutRequestMarshaller;
+import org.opensaml.saml.saml2.core.impl.NameIDBuilder;
+import org.w3c.dom.Element;
+
+import org.springframework.security.core.Authentication;
+import org.springframework.security.saml2.Saml2Exception;
+import org.springframework.security.saml2.core.OpenSamlInitializationService;
+import org.springframework.security.saml2.provider.service.authentication.Saml2AuthenticatedPrincipal;
+import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutRequest;
+import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration;
+import org.springframework.security.saml2.provider.service.registration.Saml2MessageBinding;
+import org.springframework.security.saml2.provider.service.web.RelyingPartyRegistrationResolver;
+import org.springframework.security.saml2.provider.service.web.authentication.logout.OpenSamlSigningUtils.QueryParametersPartial;
+import org.springframework.util.Assert;
+
+/**
+ * For internal use only. Intended for consolidating common behavior related to minting a
+ * SAML 2.0 Logout Request.
+ */
+final class OpenSamlLogoutRequestResolver {
+
+	static {
+		OpenSamlInitializationService.initialize();
+	}
+
+	private final Log logger = LogFactory.getLog(getClass());
+
+	private final LogoutRequestMarshaller marshaller;
+
+	private final IssuerBuilder issuerBuilder;
+
+	private final NameIDBuilder nameIdBuilder;
+
+	private final LogoutRequestBuilder logoutRequestBuilder;
+
+	private final RelyingPartyRegistrationResolver relyingPartyRegistrationResolver;
+
+	/**
+	 * Construct a {@link OpenSamlLogoutRequestResolver}
+	 */
+	OpenSamlLogoutRequestResolver(RelyingPartyRegistrationResolver relyingPartyRegistrationResolver) {
+		this.relyingPartyRegistrationResolver = relyingPartyRegistrationResolver;
+		XMLObjectProviderRegistry registry = ConfigurationService.get(XMLObjectProviderRegistry.class);
+		this.marshaller = (LogoutRequestMarshaller) registry.getMarshallerFactory()
+				.getMarshaller(LogoutRequest.DEFAULT_ELEMENT_NAME);
+		Assert.notNull(this.marshaller, "logoutRequestMarshaller must be configured in OpenSAML");
+		this.logoutRequestBuilder = (LogoutRequestBuilder) registry.getBuilderFactory()
+				.getBuilder(LogoutRequest.DEFAULT_ELEMENT_NAME);
+		Assert.notNull(this.logoutRequestBuilder, "logoutRequestBuilder must be configured in OpenSAML");
+		this.issuerBuilder = (IssuerBuilder) registry.getBuilderFactory().getBuilder(Issuer.DEFAULT_ELEMENT_NAME);
+		Assert.notNull(this.issuerBuilder, "issuerBuilder must be configured in OpenSAML");
+		this.nameIdBuilder = (NameIDBuilder) registry.getBuilderFactory().getBuilder(NameID.DEFAULT_ELEMENT_NAME);
+		Assert.notNull(this.nameIdBuilder, "nameIdBuilder must be configured in OpenSAML");
+	}
+
+	/**
+	 * Prepare to create, sign, and serialize a SAML 2.0 Logout Request.
+	 *
+	 * By default, includes a {@code NameID} based on the {@link Authentication} instance
+	 * as well as the {@code Destination} and {@code Issuer} based on the
+	 * {@link RelyingPartyRegistration} derived from the {@link Authentication}.
+	 * @param request the HTTP request
+	 * @param authentication the current user
+	 * @return a signed and serialized SAML 2.0 Logout Request
+	 */
+	Saml2LogoutRequest resolve(HttpServletRequest request, Authentication authentication) {
+		return resolve(request, authentication, (registration, logoutRequest) -> {
+		});
+	}
+
+	Saml2LogoutRequest resolve(HttpServletRequest request, Authentication authentication,
+			BiConsumer<RelyingPartyRegistration, LogoutRequest> logoutRequestConsumer) {
+		String registrationId = getRegistrationId(authentication);
+		RelyingPartyRegistration registration = this.relyingPartyRegistrationResolver.resolve(request, registrationId);
+		if (registration == null) {
+			return null;
+		}
+		LogoutRequest logoutRequest = this.logoutRequestBuilder.buildObject();
+		logoutRequest.setDestination(registration.getAssertingPartyDetails().getSingleLogoutServiceLocation());
+		Issuer issuer = this.issuerBuilder.buildObject();
+		issuer.setValue(registration.getEntityId());
+		logoutRequest.setIssuer(issuer);
+		NameID nameId = this.nameIdBuilder.buildObject();
+		nameId.setValue(authentication.getName());
+		logoutRequest.setNameID(nameId);
+		logoutRequestConsumer.accept(registration, logoutRequest);
+		if (logoutRequest.getID() == null) {
+			logoutRequest.setID("LR" + UUID.randomUUID());
+		}
+		String relayState = UUID.randomUUID().toString();
+		Saml2LogoutRequest.Builder result = Saml2LogoutRequest.withRelyingPartyRegistration(registration)
+				.id(logoutRequest.getID());
+		if (registration.getAssertingPartyDetails().getSingleLogoutServiceBinding() == Saml2MessageBinding.POST) {
+			String xml = serialize(OpenSamlSigningUtils.sign(logoutRequest, registration));
+			String samlRequest = Saml2Utils.samlEncode(xml.getBytes(StandardCharsets.UTF_8));
+			return result.samlRequest(samlRequest).relayState(relayState).build();
+		}
+		else {
+			String xml = serialize(logoutRequest);
+			String deflatedAndEncoded = Saml2Utils.samlEncode(Saml2Utils.samlDeflate(xml));
+			result.samlRequest(deflatedAndEncoded);
+			QueryParametersPartial partial = OpenSamlSigningUtils.sign(registration)
+					.param("SAMLRequest", deflatedAndEncoded).param("RelayState", relayState);
+			return result.parameters((params) -> params.putAll(partial.parameters())).build();
+		}
+	}
+
+	private String getRegistrationId(Authentication authentication) {
+		if (this.logger.isTraceEnabled()) {
+			this.logger.trace("Attempting to resolve registrationId from " + authentication);
+		}
+		if (authentication == null) {
+			return null;
+		}
+		Object principal = authentication.getPrincipal();
+		if (principal instanceof Saml2AuthenticatedPrincipal) {
+			return ((Saml2AuthenticatedPrincipal) principal).getRelyingPartyRegistrationId();
+		}
+		return null;
+	}
+
+	private String serialize(LogoutRequest logoutRequest) {
+		try {
+			Element element = this.marshaller.marshall(logoutRequest);
+			return SerializeSupport.nodeToString(element);
+		}
+		catch (MarshallingException ex) {
+			throw new Saml2Exception(ex);
+		}
+	}
+
+}

+ 218 - 0
saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSamlLogoutResponseResolver.java

@@ -0,0 +1,218 @@
+/*
+ * Copyright 2002-2021 the original author or authors.
+ *
+ * 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
+ *
+ *      https://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 org.springframework.security.saml2.provider.service.web.authentication.logout;
+
+import java.io.ByteArrayInputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.UUID;
+import java.util.function.BiConsumer;
+
+import javax.servlet.http.HttpServletRequest;
+
+import net.shibboleth.utilities.java.support.xml.ParserPool;
+import net.shibboleth.utilities.java.support.xml.SerializeSupport;
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+import org.opensaml.core.config.ConfigurationService;
+import org.opensaml.core.xml.config.XMLObjectProviderRegistry;
+import org.opensaml.core.xml.config.XMLObjectProviderRegistrySupport;
+import org.opensaml.core.xml.io.MarshallingException;
+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.Status;
+import org.opensaml.saml.saml2.core.StatusCode;
+import org.opensaml.saml.saml2.core.impl.IssuerBuilder;
+import org.opensaml.saml.saml2.core.impl.LogoutRequestUnmarshaller;
+import org.opensaml.saml.saml2.core.impl.LogoutResponseBuilder;
+import org.opensaml.saml.saml2.core.impl.LogoutResponseMarshaller;
+import org.opensaml.saml.saml2.core.impl.StatusBuilder;
+import org.opensaml.saml.saml2.core.impl.StatusCodeBuilder;
+import org.w3c.dom.Document;
+import org.w3c.dom.Element;
+
+import org.springframework.security.core.Authentication;
+import org.springframework.security.saml2.Saml2Exception;
+import org.springframework.security.saml2.core.OpenSamlInitializationService;
+import org.springframework.security.saml2.provider.service.authentication.Saml2AuthenticatedPrincipal;
+import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutResponse;
+import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration;
+import org.springframework.security.saml2.provider.service.registration.Saml2MessageBinding;
+import org.springframework.security.saml2.provider.service.web.RelyingPartyRegistrationResolver;
+import org.springframework.security.saml2.provider.service.web.authentication.logout.OpenSamlSigningUtils.QueryParametersPartial;
+import org.springframework.util.Assert;
+
+/**
+ * For internal use only. Intended for consolidating common behavior related to minting a
+ * SAML 2.0 Logout Response.
+ */
+final class OpenSamlLogoutResponseResolver {
+
+	static {
+		OpenSamlInitializationService.initialize();
+	}
+
+	private final Log logger = LogFactory.getLog(getClass());
+
+	private final ParserPool parserPool;
+
+	private final LogoutRequestUnmarshaller unmarshaller;
+
+	private final LogoutResponseMarshaller marshaller;
+
+	private final LogoutResponseBuilder logoutResponseBuilder;
+
+	private final IssuerBuilder issuerBuilder;
+
+	private final StatusBuilder statusBuilder;
+
+	private final StatusCodeBuilder statusCodeBuilder;
+
+	private final RelyingPartyRegistrationResolver relyingPartyRegistrationResolver;
+
+	/**
+	 * Construct a {@link OpenSamlLogoutResponseResolver}
+	 */
+	OpenSamlLogoutResponseResolver(RelyingPartyRegistrationResolver relyingPartyRegistrationResolver) {
+		this.relyingPartyRegistrationResolver = relyingPartyRegistrationResolver;
+		XMLObjectProviderRegistry registry = ConfigurationService.get(XMLObjectProviderRegistry.class);
+		this.parserPool = registry.getParserPool();
+		this.unmarshaller = (LogoutRequestUnmarshaller) XMLObjectProviderRegistrySupport.getUnmarshallerFactory()
+				.getUnmarshaller(LogoutRequest.DEFAULT_ELEMENT_NAME);
+		this.marshaller = (LogoutResponseMarshaller) registry.getMarshallerFactory()
+				.getMarshaller(LogoutResponse.DEFAULT_ELEMENT_NAME);
+		Assert.notNull(this.marshaller, "logoutResponseMarshaller must be configured in OpenSAML");
+		this.logoutResponseBuilder = (LogoutResponseBuilder) registry.getBuilderFactory()
+				.getBuilder(LogoutResponse.DEFAULT_ELEMENT_NAME);
+		Assert.notNull(this.logoutResponseBuilder, "logoutResponseBuilder must be configured in OpenSAML");
+		this.issuerBuilder = (IssuerBuilder) registry.getBuilderFactory().getBuilder(Issuer.DEFAULT_ELEMENT_NAME);
+		Assert.notNull(this.issuerBuilder, "issuerBuilder must be configured in OpenSAML");
+		this.statusBuilder = (StatusBuilder) registry.getBuilderFactory().getBuilder(Status.DEFAULT_ELEMENT_NAME);
+		Assert.notNull(this.statusBuilder, "statusBuilder must be configured in OpenSAML");
+		this.statusCodeBuilder = (StatusCodeBuilder) registry.getBuilderFactory()
+				.getBuilder(StatusCode.DEFAULT_ELEMENT_NAME);
+		Assert.notNull(this.statusCodeBuilder, "statusCodeBuilder must be configured in OpenSAML");
+	}
+
+	/**
+	 * Prepare to create, sign, and serialize a SAML 2.0 Logout Response.
+	 *
+	 * By default, includes a {@code RelayState} based on the {@link HttpServletRequest}
+	 * as well as the {@code Destination} and {@code Issuer} based on the
+	 * {@link RelyingPartyRegistration} derived from the {@link Authentication}. The
+	 * logout response is also marked as {@code SUCCESS}.
+	 * @param request the HTTP request
+	 * @param authentication the current user
+	 * @return a signed and serialized SAML 2.0 Logout Response
+	 */
+	Saml2LogoutResponse resolve(HttpServletRequest request, Authentication authentication) {
+		return resolve(request, authentication, (registration, logoutResponse) -> {
+		});
+	}
+
+	Saml2LogoutResponse resolve(HttpServletRequest request, Authentication authentication,
+			BiConsumer<RelyingPartyRegistration, LogoutResponse> logoutResponseConsumer) {
+		String registrationId = getRegistrationId(authentication);
+		RelyingPartyRegistration registration = this.relyingPartyRegistrationResolver.resolve(request, registrationId);
+		if (registration == null) {
+			return null;
+		}
+		String serialized = request.getParameter("SAMLRequest");
+		byte[] b = Saml2Utils.samlDecode(serialized);
+		LogoutRequest logoutRequest = parse(inflateIfRequired(registration, b));
+		LogoutResponse logoutResponse = this.logoutResponseBuilder.buildObject();
+		logoutResponse.setDestination(registration.getAssertingPartyDetails().getSingleLogoutServiceResponseLocation());
+		Issuer issuer = this.issuerBuilder.buildObject();
+		issuer.setValue(registration.getEntityId());
+		logoutResponse.setIssuer(issuer);
+		StatusCode code = this.statusCodeBuilder.buildObject();
+		code.setValue(StatusCode.SUCCESS);
+		Status status = this.statusBuilder.buildObject();
+		status.setStatusCode(code);
+		logoutResponse.setStatus(status);
+		logoutResponse.setInResponseTo(logoutRequest.getID());
+		if (logoutResponse.getID() == null) {
+			logoutResponse.setID("LR" + UUID.randomUUID());
+		}
+		logoutResponseConsumer.accept(registration, logoutResponse);
+		Saml2LogoutResponse.Builder result = Saml2LogoutResponse.withRelyingPartyRegistration(registration);
+		if (registration.getAssertingPartyDetails().getSingleLogoutServiceBinding() == Saml2MessageBinding.POST) {
+			String xml = serialize(OpenSamlSigningUtils.sign(logoutResponse, registration));
+			String samlResponse = Saml2Utils.samlEncode(xml.getBytes(StandardCharsets.UTF_8));
+			result.samlResponse(samlResponse);
+			if (request.getParameter("RelayState") != null) {
+				result.relayState(request.getParameter("RelayState"));
+			}
+			return result.build();
+		}
+		else {
+			String xml = serialize(logoutResponse);
+			String deflatedAndEncoded = Saml2Utils.samlEncode(Saml2Utils.samlDeflate(xml));
+			result.samlResponse(deflatedAndEncoded);
+			QueryParametersPartial partial = OpenSamlSigningUtils.sign(registration).param("SAMLResponse",
+					deflatedAndEncoded);
+			if (request.getParameter("RelayState") != null) {
+				partial.param("RelayState", request.getParameter("RelayState"));
+			}
+			return result.parameters((params) -> params.putAll(partial.parameters())).build();
+		}
+	}
+
+	private String getRegistrationId(Authentication authentication) {
+		if (this.logger.isTraceEnabled()) {
+			this.logger.trace("Attempting to resolve registrationId from " + authentication);
+		}
+		if (authentication == null) {
+			return null;
+		}
+		Object principal = authentication.getPrincipal();
+		if (principal instanceof Saml2AuthenticatedPrincipal) {
+			return ((Saml2AuthenticatedPrincipal) principal).getRelyingPartyRegistrationId();
+		}
+		return null;
+	}
+
+	private String inflateIfRequired(RelyingPartyRegistration registration, byte[] b) {
+		if (registration.getSingleLogoutServiceBinding() == Saml2MessageBinding.REDIRECT) {
+			return Saml2Utils.samlInflate(b);
+		}
+		return new String(b, StandardCharsets.UTF_8);
+	}
+
+	private LogoutRequest parse(String request) throws Saml2Exception {
+		try {
+			Document document = this.parserPool
+					.parse(new ByteArrayInputStream(request.getBytes(StandardCharsets.UTF_8)));
+			Element element = document.getDocumentElement();
+			return (LogoutRequest) this.unmarshaller.unmarshall(element);
+		}
+		catch (Exception ex) {
+			throw new Saml2Exception("Failed to deserialize LogoutRequest", ex);
+		}
+	}
+
+	private String serialize(LogoutResponse logoutResponse) {
+		try {
+			Element element = this.marshaller.marshall(logoutResponse);
+			return SerializeSupport.nodeToString(element);
+		}
+		catch (MarshallingException ex) {
+			throw new Saml2Exception(ex);
+		}
+	}
+
+}

+ 173 - 0
saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSamlSigningUtils.java

@@ -0,0 +1,173 @@
+/*
+ * Copyright 2002-2021 the original author or authors.
+ *
+ * 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
+ *
+ *      https://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 org.springframework.security.saml2.provider.service.web.authentication.logout;
+
+import java.nio.charset.StandardCharsets;
+import java.security.PrivateKey;
+import java.security.cert.X509Certificate;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import net.shibboleth.utilities.java.support.resolver.CriteriaSet;
+import net.shibboleth.utilities.java.support.xml.SerializeSupport;
+import org.opensaml.core.xml.XMLObject;
+import org.opensaml.core.xml.config.XMLObjectProviderRegistrySupport;
+import org.opensaml.core.xml.io.Marshaller;
+import org.opensaml.core.xml.io.MarshallingException;
+import org.opensaml.saml.security.impl.SAMLMetadataSignatureSigningParametersResolver;
+import org.opensaml.security.SecurityException;
+import org.opensaml.security.credential.BasicCredential;
+import org.opensaml.security.credential.Credential;
+import org.opensaml.security.credential.CredentialSupport;
+import org.opensaml.security.credential.UsageType;
+import org.opensaml.xmlsec.SignatureSigningParameters;
+import org.opensaml.xmlsec.SignatureSigningParametersResolver;
+import org.opensaml.xmlsec.criterion.SignatureSigningConfigurationCriterion;
+import org.opensaml.xmlsec.crypto.XMLSigningUtil;
+import org.opensaml.xmlsec.impl.BasicSignatureSigningConfiguration;
+import org.opensaml.xmlsec.signature.SignableXMLObject;
+import org.opensaml.xmlsec.signature.support.SignatureConstants;
+import org.opensaml.xmlsec.signature.support.SignatureSupport;
+import org.w3c.dom.Element;
+
+import org.springframework.security.saml2.Saml2Exception;
+import org.springframework.security.saml2.core.Saml2X509Credential;
+import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration;
+import org.springframework.util.Assert;
+import org.springframework.web.util.UriComponentsBuilder;
+import org.springframework.web.util.UriUtils;
+
+/**
+ * Utility methods for signing SAML components with OpenSAML
+ *
+ * For internal use only.
+ *
+ * @author Josh Cummings
+ */
+final class OpenSamlSigningUtils {
+
+	static String serialize(XMLObject object) {
+		try {
+			Marshaller marshaller = XMLObjectProviderRegistrySupport.getMarshallerFactory().getMarshaller(object);
+			Element element = marshaller.marshall(object);
+			return SerializeSupport.nodeToString(element);
+		}
+		catch (MarshallingException ex) {
+			throw new Saml2Exception(ex);
+		}
+	}
+
+	static <O extends SignableXMLObject> O sign(O object, RelyingPartyRegistration relyingPartyRegistration) {
+		SignatureSigningParameters parameters = resolveSigningParameters(relyingPartyRegistration);
+		try {
+			SignatureSupport.signObject(object, parameters);
+			return object;
+		}
+		catch (Exception ex) {
+			throw new Saml2Exception(ex);
+		}
+	}
+
+	static QueryParametersPartial sign(RelyingPartyRegistration registration) {
+		return new QueryParametersPartial(registration);
+	}
+
+	private static SignatureSigningParameters resolveSigningParameters(
+			RelyingPartyRegistration relyingPartyRegistration) {
+		List<Credential> credentials = resolveSigningCredentials(relyingPartyRegistration);
+		List<String> algorithms = relyingPartyRegistration.getAssertingPartyDetails().getSigningAlgorithms();
+		List<String> digests = Collections.singletonList(SignatureConstants.ALGO_ID_DIGEST_SHA256);
+		String canonicalization = SignatureConstants.ALGO_ID_C14N_EXCL_OMIT_COMMENTS;
+		SignatureSigningParametersResolver resolver = new SAMLMetadataSignatureSigningParametersResolver();
+		CriteriaSet criteria = new CriteriaSet();
+		BasicSignatureSigningConfiguration signingConfiguration = new BasicSignatureSigningConfiguration();
+		signingConfiguration.setSigningCredentials(credentials);
+		signingConfiguration.setSignatureAlgorithms(algorithms);
+		signingConfiguration.setSignatureReferenceDigestMethods(digests);
+		signingConfiguration.setSignatureCanonicalizationAlgorithm(canonicalization);
+		criteria.add(new SignatureSigningConfigurationCriterion(signingConfiguration));
+		try {
+			SignatureSigningParameters parameters = resolver.resolveSingle(criteria);
+			Assert.notNull(parameters, "Failed to resolve any signing credential");
+			return parameters;
+		}
+		catch (Exception ex) {
+			throw new Saml2Exception(ex);
+		}
+	}
+
+	private static List<Credential> resolveSigningCredentials(RelyingPartyRegistration relyingPartyRegistration) {
+		List<Credential> credentials = new ArrayList<>();
+		for (Saml2X509Credential x509Credential : relyingPartyRegistration.getSigningX509Credentials()) {
+			X509Certificate certificate = x509Credential.getCertificate();
+			PrivateKey privateKey = x509Credential.getPrivateKey();
+			BasicCredential credential = CredentialSupport.getSimpleCredential(certificate, privateKey);
+			credential.setEntityId(relyingPartyRegistration.getEntityId());
+			credential.setUsageType(UsageType.SIGNING);
+			credentials.add(credential);
+		}
+		return credentials;
+	}
+
+	static class QueryParametersPartial {
+
+		final RelyingPartyRegistration registration;
+
+		final Map<String, String> components = new LinkedHashMap<>();
+
+		QueryParametersPartial(RelyingPartyRegistration registration) {
+			this.registration = registration;
+		}
+
+		QueryParametersPartial param(String key, String value) {
+			this.components.put(key, value);
+			return this;
+		}
+
+		Map<String, String> parameters() {
+			SignatureSigningParameters parameters = resolveSigningParameters(this.registration);
+			Credential credential = parameters.getSigningCredential();
+			String algorithmUri = parameters.getSignatureAlgorithm();
+			this.components.put("SigAlg", algorithmUri);
+			UriComponentsBuilder builder = UriComponentsBuilder.newInstance();
+			for (Map.Entry<String, String> component : this.components.entrySet()) {
+				builder.queryParam(component.getKey(),
+						UriUtils.encode(component.getValue(), StandardCharsets.ISO_8859_1));
+			}
+			String queryString = builder.build(true).toString().substring(1);
+			try {
+				byte[] rawSignature = XMLSigningUtil.signWithURI(credential, algorithmUri,
+						queryString.getBytes(StandardCharsets.UTF_8));
+				String b64Signature = Saml2Utils.samlEncode(rawSignature);
+				this.components.put("Signature", b64Signature);
+			}
+			catch (SecurityException ex) {
+				throw new Saml2Exception(ex);
+			}
+			return this.components;
+		}
+
+	}
+
+	private OpenSamlSigningUtils() {
+
+	}
+
+}

+ 250 - 0
saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutRequestFilter.java

@@ -0,0 +1,250 @@
+/*
+ * Copyright 2002-2021 the original author or authors.
+ *
+ * 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
+ *
+ *      https://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 org.springframework.security.saml2.provider.service.web.authentication.logout;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.function.Function;
+
+import javax.servlet.FilterChain;
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+
+import org.springframework.core.log.LogMessage;
+import org.springframework.http.MediaType;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.saml2.provider.service.authentication.Saml2AuthenticatedPrincipal;
+import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutRequest;
+import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutRequestValidator;
+import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutRequestValidatorParameters;
+import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutResponse;
+import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutValidatorResult;
+import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration;
+import org.springframework.security.saml2.provider.service.registration.Saml2MessageBinding;
+import org.springframework.security.saml2.provider.service.web.RelyingPartyRegistrationResolver;
+import org.springframework.security.web.DefaultRedirectStrategy;
+import org.springframework.security.web.RedirectStrategy;
+import org.springframework.security.web.authentication.logout.CompositeLogoutHandler;
+import org.springframework.security.web.authentication.logout.LogoutHandler;
+import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
+import org.springframework.security.web.util.matcher.RequestMatcher;
+import org.springframework.util.Assert;
+import org.springframework.util.StringUtils;
+import org.springframework.web.filter.OncePerRequestFilter;
+import org.springframework.web.util.HtmlUtils;
+import org.springframework.web.util.UriComponentsBuilder;
+import org.springframework.web.util.UriUtils;
+
+/**
+ * A filter for handling logout requests in the form of a &lt;saml2:LogoutRequest&gt; sent
+ * from the asserting party.
+ *
+ * @author Josh Cummings
+ * @since 5.6
+ * @see Saml2LogoutRequestValidator
+ * @see Saml2AssertingPartyInitiatedLogoutSuccessHandler
+ */
+public final class Saml2LogoutRequestFilter extends OncePerRequestFilter {
+
+	private final Log logger = LogFactory.getLog(getClass());
+
+	private final Saml2LogoutRequestValidator logoutRequestValidator;
+
+	private final RelyingPartyRegistrationResolver relyingPartyRegistrationResolver;
+
+	private final Saml2LogoutResponseResolver logoutResponseResolver;
+
+	private final LogoutHandler handler;
+
+	private final RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();
+
+	private RequestMatcher logoutRequestMatcher = new AntPathRequestMatcher("/logout/saml2/slo");
+
+	/**
+	 * Constructs a {@link Saml2LogoutResponseFilter} for accepting SAML 2.0 Logout
+	 * Requests from the asserting party
+	 * @param relyingPartyRegistrationResolver the strategy for resolving a
+	 * {@link RelyingPartyRegistration}
+	 * @param logoutRequestValidator the SAML 2.0 Logout Request authenticator
+	 * @param logoutResponseResolver the strategy for creating a SAML 2.0 Logout Response
+	 * @param handlers the actions that perform logout
+	 */
+	public Saml2LogoutRequestFilter(RelyingPartyRegistrationResolver relyingPartyRegistrationResolver,
+			Saml2LogoutRequestValidator logoutRequestValidator, Saml2LogoutResponseResolver logoutResponseResolver,
+			LogoutHandler... handlers) {
+		this.relyingPartyRegistrationResolver = relyingPartyRegistrationResolver;
+		this.logoutRequestValidator = logoutRequestValidator;
+		this.logoutResponseResolver = logoutResponseResolver;
+		this.handler = new CompositeLogoutHandler(handlers);
+	}
+
+	@Override
+	protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
+			throws ServletException, IOException {
+
+		if (!this.logoutRequestMatcher.matches(request)) {
+			chain.doFilter(request, response);
+			return;
+		}
+
+		if (request.getParameter("SAMLRequest") == null) {
+			chain.doFilter(request, response);
+			return;
+		}
+
+		Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
+		RelyingPartyRegistration registration = this.relyingPartyRegistrationResolver.resolve(request,
+				getRegistrationId(authentication));
+		if (registration == null) {
+			this.logger
+					.trace("Did not process logout request since failed to find associated RelyingPartyRegistration");
+			response.sendError(HttpServletResponse.SC_BAD_REQUEST);
+			return;
+		}
+		if (!isCorrectBinding(request, registration)) {
+			this.logger.trace("Did not process logout request since used incorrect binding");
+			response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
+			return;
+		}
+
+		String serialized = request.getParameter("SAMLRequest");
+		Saml2LogoutRequest logoutRequest = Saml2LogoutRequest.withRelyingPartyRegistration(registration)
+				.samlRequest(serialized).relayState(request.getParameter("RelayState"))
+				.binding(registration.getSingleLogoutServiceBinding())
+				.location(registration.getSingleLogoutServiceLocation())
+				.parameters((params) -> params.put("SigAlg", request.getParameter("SigAlg")))
+				.parameters((params) -> params.put("Signature", request.getParameter("Signature"))).build();
+		Saml2LogoutRequestValidatorParameters parameters = new Saml2LogoutRequestValidatorParameters(logoutRequest,
+				registration, authentication);
+		Saml2LogoutValidatorResult result = this.logoutRequestValidator.validate(parameters);
+		if (result.hasErrors()) {
+			response.sendError(HttpServletResponse.SC_UNAUTHORIZED, result.getErrors().iterator().next().toString());
+			this.logger.debug(LogMessage.format("Failed to validate LogoutRequest: %s", result.getErrors()));
+			return;
+		}
+		this.handler.logout(request, response, authentication);
+		Saml2LogoutResponse logoutResponse = this.logoutResponseResolver.resolve(request, authentication);
+		if (logoutResponse == null) {
+			this.logger.trace("Returning 401 since no logout response generated");
+			response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
+			return;
+		}
+		if (logoutResponse.getBinding() == Saml2MessageBinding.REDIRECT) {
+			doRedirect(request, response, logoutResponse);
+		}
+		else {
+			doPost(response, logoutResponse);
+		}
+	}
+
+	public void setLogoutRequestMatcher(RequestMatcher logoutRequestMatcher) {
+		Assert.notNull(logoutRequestMatcher, "logoutRequestMatcher cannot be null");
+		this.logoutRequestMatcher = logoutRequestMatcher;
+	}
+
+	private String getRegistrationId(Authentication authentication) {
+		if (authentication == null) {
+			return null;
+		}
+		Object principal = authentication.getPrincipal();
+		if (principal instanceof Saml2AuthenticatedPrincipal) {
+			return ((Saml2AuthenticatedPrincipal) principal).getRelyingPartyRegistrationId();
+		}
+		return null;
+	}
+
+	private boolean isCorrectBinding(HttpServletRequest request, RelyingPartyRegistration registration) {
+		Saml2MessageBinding requiredBinding = registration.getSingleLogoutServiceBinding();
+		if (requiredBinding == Saml2MessageBinding.POST) {
+			return "POST".equals(request.getMethod());
+		}
+		return "GET".equals(request.getMethod());
+	}
+
+	private void doRedirect(HttpServletRequest request, HttpServletResponse response,
+			Saml2LogoutResponse logoutResponse) throws IOException {
+		String location = logoutResponse.getResponseLocation();
+		UriComponentsBuilder uriBuilder = UriComponentsBuilder.fromUriString(location);
+		addParameter("SAMLResponse", logoutResponse::getParameter, uriBuilder);
+		addParameter("RelayState", logoutResponse::getParameter, uriBuilder);
+		addParameter("SigAlg", logoutResponse::getParameter, uriBuilder);
+		addParameter("Signature", logoutResponse::getParameter, uriBuilder);
+		this.redirectStrategy.sendRedirect(request, response, uriBuilder.build(true).toUriString());
+	}
+
+	private void addParameter(String name, Function<String, String> parameters, UriComponentsBuilder builder) {
+		Assert.hasText(name, "name cannot be empty or null");
+		if (StringUtils.hasText(parameters.apply(name))) {
+			builder.queryParam(UriUtils.encode(name, StandardCharsets.ISO_8859_1),
+					UriUtils.encode(parameters.apply(name), StandardCharsets.ISO_8859_1));
+		}
+	}
+
+	private void doPost(HttpServletResponse response, Saml2LogoutResponse logoutResponse) throws IOException {
+		String location = logoutResponse.getResponseLocation();
+		String saml = logoutResponse.getSamlResponse();
+		String relayState = logoutResponse.getRelayState();
+		String html = createSamlPostRequestFormData(location, saml, relayState);
+		response.setContentType(MediaType.TEXT_HTML_VALUE);
+		response.getWriter().write(html);
+	}
+
+	private String createSamlPostRequestFormData(String location, String saml, String relayState) {
+		StringBuilder html = new StringBuilder();
+		html.append("<!DOCTYPE html>\n");
+		html.append("<html>\n").append("    <head>\n");
+		html.append("        <meta charset=\"utf-8\" />\n");
+		html.append("    </head>\n");
+		html.append("    <body onload=\"document.forms[0].submit()\">\n");
+		html.append("        <noscript>\n");
+		html.append("            <p>\n");
+		html.append("                <strong>Note:</strong> Since your browser does not support JavaScript,\n");
+		html.append("                you must press the Continue button once to proceed.\n");
+		html.append("            </p>\n");
+		html.append("        </noscript>\n");
+		html.append("        \n");
+		html.append("        <form action=\"");
+		html.append(location);
+		html.append("\" method=\"post\">\n");
+		html.append("            <div>\n");
+		html.append("                <input type=\"hidden\" name=\"SAMLResponse\" value=\"");
+		html.append(HtmlUtils.htmlEscape(saml));
+		html.append("\"/>\n");
+		if (StringUtils.hasText(relayState)) {
+			html.append("                <input type=\"hidden\" name=\"RelayState\" value=\"");
+			html.append(HtmlUtils.htmlEscape(relayState));
+			html.append("\"/>\n");
+		}
+		html.append("            </div>\n");
+		html.append("            <noscript>\n");
+		html.append("                <div>\n");
+		html.append("                    <input type=\"submit\" value=\"Continue\"/>\n");
+		html.append("                </div>\n");
+		html.append("            </noscript>\n");
+		html.append("        </form>\n");
+		html.append("        \n");
+		html.append("    </body>\n");
+		html.append("</html>");
+		return html.toString();
+	}
+
+}

+ 68 - 0
saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutRequestRepository.java

@@ -0,0 +1,68 @@
+/*
+ * Copyright 2002-2021 the original author or authors.
+ *
+ * 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
+ *
+ *      https://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 org.springframework.security.saml2.provider.service.web.authentication.logout;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutRequest;
+
+/**
+ * Implementations of this interface are responsible for the persistence of
+ * {@link Saml2LogoutRequest} between requests.
+ *
+ * <p>
+ * Used by the {@link Saml2RelyingPartyInitiatedLogoutSuccessHandler} for persisting the
+ * Logout Request before it initiates the SAML 2.0 SLO flow. As well, used by
+ * {@code OpenSamlLogoutResponseHandler} for resolving the Logout Request associated with
+ * that Logout Response.
+ *
+ * @author Josh Cummings
+ * @since 5.6
+ * @see Saml2LogoutRequest
+ * @see HttpSessionLogoutRequestRepository
+ */
+public interface Saml2LogoutRequestRepository {
+
+	/**
+	 * Returns the {@link Saml2LogoutRequest} associated to the provided
+	 * {@code HttpServletRequest} or {@code null} if not available.
+	 * @param request the {@code HttpServletRequest}
+	 * @return the {@link Saml2LogoutRequest} or {@code null} if not available
+	 */
+	Saml2LogoutRequest loadLogoutRequest(HttpServletRequest request);
+
+	/**
+	 * Persists the {@link Saml2LogoutRequest} associating it to the provided
+	 * {@code HttpServletRequest} and/or {@code HttpServletResponse}.
+	 * @param logoutRequest the {@link Saml2LogoutRequest}
+	 * @param request the {@code HttpServletRequest}
+	 * @param response the {@code HttpServletResponse}
+	 */
+	void saveLogoutRequest(Saml2LogoutRequest logoutRequest, HttpServletRequest request, HttpServletResponse response);
+
+	/**
+	 * Removes and returns the {@link Saml2LogoutRequest} associated to the provided
+	 * {@code HttpServletRequest} and {@code HttpServletResponse} or if not available
+	 * returns {@code null}.
+	 * @param request the {@code HttpServletRequest}
+	 * @param response the {@code HttpServletResponse}
+	 * @return the {@link Saml2LogoutRequest} or {@code null} if not available
+	 */
+	Saml2LogoutRequest removeLogoutRequest(HttpServletRequest request, HttpServletResponse response);
+
+}

+ 49 - 0
saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutRequestResolver.java

@@ -0,0 +1,49 @@
+/*
+ * Copyright 2002-2021 the original author or authors.
+ *
+ * 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
+ *
+ *      https://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 org.springframework.security.saml2.provider.service.web.authentication.logout;
+
+import javax.servlet.http.HttpServletRequest;
+
+import org.springframework.security.core.Authentication;
+import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutRequest;
+import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration;
+
+/**
+ * Creates a signed SAML 2.0 Logout Request based on information from the
+ * {@link HttpServletRequest} and current {@link Authentication}.
+ *
+ * The returned logout request is suitable for sending to the asserting party based on,
+ * for example, the location and binding specified in
+ * {@link RelyingPartyRegistration#getAssertingPartyDetails()}.
+ *
+ * @author Josh Cummings
+ * @since 5.6
+ * @see RelyingPartyRegistration
+ */
+public interface Saml2LogoutRequestResolver {
+
+	/**
+	 * Prepare to create, sign, and serialize a SAML 2.0 Logout Request.
+	 *
+	 * By default, includes a {@code NameID} based on the {@link Authentication} instance.
+	 * @param request the HTTP request
+	 * @param authentication the current user
+	 * @return a signed and serialized SAML 2.0 Logout Request
+	 */
+	Saml2LogoutRequest resolve(HttpServletRequest request, Authentication authentication);
+
+}

+ 169 - 0
saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutResponseFilter.java

@@ -0,0 +1,169 @@
+/*
+ * Copyright 2002-2021 the original author or authors.
+ *
+ * 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
+ *
+ *      https://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 org.springframework.security.saml2.provider.service.web.authentication.logout;
+
+import java.io.IOException;
+
+import javax.servlet.FilterChain;
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+
+import org.springframework.core.log.LogMessage;
+import org.springframework.security.saml2.core.Saml2Error;
+import org.springframework.security.saml2.core.Saml2ErrorCodes;
+import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutRequest;
+import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutResponse;
+import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutResponseValidator;
+import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutResponseValidatorParameters;
+import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutValidatorResult;
+import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration;
+import org.springframework.security.saml2.provider.service.registration.Saml2MessageBinding;
+import org.springframework.security.saml2.provider.service.web.RelyingPartyRegistrationResolver;
+import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;
+import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
+import org.springframework.security.web.util.matcher.RequestMatcher;
+import org.springframework.util.Assert;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+/**
+ * A filter for handling a &lt;saml2:LogoutResponse&gt; sent from the asserting party. A
+ * &lt;saml2:LogoutResponse&gt; is sent in response to a &lt;saml2:LogoutRequest&gt;
+ * already sent by the relying party.
+ *
+ * Note that before a &lt;saml2:LogoutRequest&gt; is sent, the user is logged out. Given
+ * that, this implementation should not use any {@link LogoutSuccessHandler} that relies
+ * on the user being logged in.
+ *
+ * @author Josh Cummings
+ * @since 5.6
+ * @see Saml2LogoutRequestRepository
+ * @see Saml2LogoutResponseValidator
+ */
+public final class Saml2LogoutResponseFilter extends OncePerRequestFilter {
+
+	private final Log logger = LogFactory.getLog(getClass());
+
+	private final RelyingPartyRegistrationResolver relyingPartyRegistrationResolver;
+
+	private final Saml2LogoutResponseValidator logoutResponseValidator;
+
+	private final LogoutSuccessHandler logoutSuccessHandler;
+
+	private Saml2LogoutRequestRepository logoutRequestRepository = new HttpSessionLogoutRequestRepository();
+
+	private RequestMatcher logoutRequestMatcher = new AntPathRequestMatcher("/logout/saml2/slo");
+
+	/**
+	 * Constructs a {@link Saml2LogoutResponseFilter} for accepting SAML 2.0 Logout
+	 * Responses from the asserting party
+	 * @param relyingPartyRegistrationResolver the strategy for resolving a
+	 * {@link RelyingPartyRegistration}
+	 * @param logoutResponseValidator authenticates the SAML 2.0 Logout Response
+	 * @param logoutSuccessHandler the action to perform now that logout has succeeded
+	 */
+	public Saml2LogoutResponseFilter(RelyingPartyRegistrationResolver relyingPartyRegistrationResolver,
+			Saml2LogoutResponseValidator logoutResponseValidator, LogoutSuccessHandler logoutSuccessHandler) {
+		this.relyingPartyRegistrationResolver = relyingPartyRegistrationResolver;
+		this.logoutResponseValidator = logoutResponseValidator;
+		this.logoutSuccessHandler = logoutSuccessHandler;
+	}
+
+	/**
+	 * {@inheritDoc}
+	 */
+	@Override
+	protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
+			throws ServletException, IOException {
+
+		if (!this.logoutRequestMatcher.matches(request)) {
+			chain.doFilter(request, response);
+			return;
+		}
+
+		if (request.getParameter("SAMLResponse") == null) {
+			chain.doFilter(request, response);
+			return;
+		}
+
+		Saml2LogoutRequest logoutRequest = this.logoutRequestRepository.removeLogoutRequest(request, response);
+		if (logoutRequest == null) {
+			this.logger.trace("Did not process logout response since could not find associated LogoutRequest");
+			response.sendError(HttpServletResponse.SC_BAD_REQUEST, "Failed to find associated LogoutRequest");
+			return;
+		}
+		RelyingPartyRegistration registration = this.relyingPartyRegistrationResolver.resolve(request,
+				logoutRequest.getRelyingPartyRegistrationId());
+		if (registration == null) {
+			this.logger
+					.trace("Did not process logout request since failed to find associated RelyingPartyRegistration");
+			Saml2Error error = new Saml2Error(Saml2ErrorCodes.RELYING_PARTY_REGISTRATION_NOT_FOUND,
+					"Failed to find associated RelyingPartyRegistration");
+			response.sendError(HttpServletResponse.SC_BAD_REQUEST, error.toString());
+			return;
+		}
+		if (!isCorrectBinding(request, registration)) {
+			this.logger.trace("Did not process logout request since used incorrect binding");
+			response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
+			return;
+		}
+
+		String serialized = request.getParameter("SAMLResponse");
+		Saml2LogoutResponse logoutResponse = Saml2LogoutResponse.withRelyingPartyRegistration(registration)
+				.samlResponse(serialized).relayState(request.getParameter("RelayState"))
+				.binding(registration.getSingleLogoutServiceBinding())
+				.location(registration.getSingleLogoutServiceResponseLocation())
+				.parameters((params) -> params.put("SigAlg", request.getParameter("SigAlg")))
+				.parameters((params) -> params.put("Signature", request.getParameter("Signature"))).build();
+		Saml2LogoutResponseValidatorParameters parameters = new Saml2LogoutResponseValidatorParameters(logoutResponse,
+				logoutRequest, registration);
+		Saml2LogoutValidatorResult result = this.logoutResponseValidator.validate(parameters);
+		if (result.hasErrors()) {
+			response.sendError(HttpServletResponse.SC_UNAUTHORIZED, result.getErrors().iterator().next().toString());
+			this.logger.debug(LogMessage.format("Failed to validate LogoutResponse: %s", result.getErrors()));
+			return;
+		}
+		this.logoutSuccessHandler.onLogoutSuccess(request, response, null);
+	}
+
+	public void setLogoutRequestMatcher(RequestMatcher logoutRequestMatcher) {
+		Assert.notNull(logoutRequestMatcher, "logoutRequestMatcher cannot be null");
+		this.logoutRequestMatcher = logoutRequestMatcher;
+	}
+
+	/**
+	 * Use this {@link Saml2LogoutRequestRepository} for retrieving the SAML 2.0 Logout
+	 * Request associated with the request's {@code RelayState}
+	 * @param logoutRequestRepository the {@link Saml2LogoutRequestRepository} to use
+	 */
+	public void setLogoutRequestRepository(Saml2LogoutRequestRepository logoutRequestRepository) {
+		Assert.notNull(logoutRequestRepository, "logoutRequestRepository cannot be null");
+		this.logoutRequestRepository = logoutRequestRepository;
+	}
+
+	private boolean isCorrectBinding(HttpServletRequest request, RelyingPartyRegistration registration) {
+		Saml2MessageBinding requiredBinding = registration.getSingleLogoutServiceBinding();
+		if (requiredBinding == Saml2MessageBinding.POST) {
+			return "POST".equals(request.getMethod());
+		}
+		return "GET".equals(request.getMethod());
+	}
+
+}

+ 47 - 0
saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutResponseResolver.java

@@ -0,0 +1,47 @@
+/*
+ * Copyright 2002-2021 the original author or authors.
+ *
+ * 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
+ *
+ *      https://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 org.springframework.security.saml2.provider.service.web.authentication.logout;
+
+import javax.servlet.http.HttpServletRequest;
+
+import org.springframework.security.core.Authentication;
+import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutResponse;
+import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration;
+
+/**
+ * Creates a signed SAML 2.0 Logout Response based on information from the
+ * {@link HttpServletRequest} and current {@link Authentication}.
+ *
+ * The returned logout response is suitable for sending to the asserting party based on,
+ * for example, the location and binding specified in
+ * {@link RelyingPartyRegistration#getAssertingPartyDetails()}.
+ *
+ * @author Josh Cummings
+ * @since 5.6
+ * @see RelyingPartyRegistration
+ */
+public interface Saml2LogoutResponseResolver {
+
+	/**
+	 * Prepare to create, sign, and serialize a SAML 2.0 Logout Response.
+	 * @param request the HTTP request
+	 * @param authentication the current user
+	 * @return a signed and serialized SAML 2.0 Logout Response
+	 */
+	Saml2LogoutResponse resolve(HttpServletRequest request, Authentication authentication);
+
+}

+ 171 - 0
saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2RelyingPartyInitiatedLogoutSuccessHandler.java

@@ -0,0 +1,171 @@
+/*
+ * Copyright 2002-2021 the original author or authors.
+ *
+ * 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
+ *
+ *      https://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 org.springframework.security.saml2.provider.service.web.authentication.logout;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.function.Function;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+
+import org.springframework.http.MediaType;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutRequest;
+import org.springframework.security.saml2.provider.service.registration.Saml2MessageBinding;
+import org.springframework.security.web.DefaultRedirectStrategy;
+import org.springframework.security.web.RedirectStrategy;
+import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;
+import org.springframework.util.Assert;
+import org.springframework.util.StringUtils;
+import org.springframework.web.util.HtmlUtils;
+import org.springframework.web.util.UriComponentsBuilder;
+import org.springframework.web.util.UriUtils;
+
+/**
+ * A success handler for issuing a SAML 2.0 Logout Request to the the SAML 2.0 Asserting
+ * Party
+ *
+ * @author Josh Cummings
+ * @since 5.6
+ */
+public final class Saml2RelyingPartyInitiatedLogoutSuccessHandler implements LogoutSuccessHandler {
+
+	private final Log logger = LogFactory.getLog(getClass());
+
+	private final Saml2LogoutRequestResolver logoutRequestResolver;
+
+	private final RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();
+
+	private Saml2LogoutRequestRepository logoutRequestRepository = new HttpSessionLogoutRequestRepository();
+
+	/**
+	 * Constructs a {@link Saml2RelyingPartyInitiatedLogoutSuccessHandler} using the
+	 * provided parameters
+	 * @param logoutRequestResolver the {@link Saml2LogoutRequestResolver} to use
+	 */
+	public Saml2RelyingPartyInitiatedLogoutSuccessHandler(Saml2LogoutRequestResolver logoutRequestResolver) {
+		this.logoutRequestResolver = logoutRequestResolver;
+	}
+
+	/**
+	 * Produce and send a SAML 2.0 Logout Response based on the SAML 2.0 Logout Request
+	 * received from the asserting party
+	 * @param request the HTTP request
+	 * @param response the HTTP response
+	 * @param authentication the current principal details
+	 * @throws IOException when failing to write to the response
+	 */
+	@Override
+	public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication)
+			throws IOException {
+		Saml2LogoutRequest logoutRequest = this.logoutRequestResolver.resolve(request, authentication);
+		if (logoutRequest == null) {
+			this.logger.trace("Returning 401 since no logout request generated");
+			response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
+			return;
+		}
+		this.logoutRequestRepository.saveLogoutRequest(logoutRequest, request, response);
+		if (logoutRequest.getBinding() == Saml2MessageBinding.REDIRECT) {
+			doRedirect(request, response, logoutRequest);
+		}
+		else {
+			doPost(response, logoutRequest);
+		}
+	}
+
+	/**
+	 * Use this {@link Saml2LogoutRequestRepository} for saving the SAML 2.0 Logout
+	 * Request
+	 * @param logoutRequestRepository the {@link Saml2LogoutRequestRepository} to use
+	 */
+	public void setLogoutRequestRepository(Saml2LogoutRequestRepository logoutRequestRepository) {
+		Assert.notNull(logoutRequestRepository, "logoutRequestRepository cannot be null");
+		this.logoutRequestRepository = logoutRequestRepository;
+	}
+
+	private void doRedirect(HttpServletRequest request, HttpServletResponse response, Saml2LogoutRequest logoutRequest)
+			throws IOException {
+		String location = logoutRequest.getLocation();
+		UriComponentsBuilder uriBuilder = UriComponentsBuilder.fromUriString(location);
+		addParameter("SAMLRequest", logoutRequest::getParameter, uriBuilder);
+		addParameter("RelayState", logoutRequest::getParameter, uriBuilder);
+		addParameter("SigAlg", logoutRequest::getParameter, uriBuilder);
+		addParameter("Signature", logoutRequest::getParameter, uriBuilder);
+		this.redirectStrategy.sendRedirect(request, response, uriBuilder.build(true).toUriString());
+	}
+
+	private void addParameter(String name, Function<String, String> parameters, UriComponentsBuilder builder) {
+		Assert.hasText(name, "name cannot be empty or null");
+		if (StringUtils.hasText(parameters.apply(name))) {
+			builder.queryParam(UriUtils.encode(name, StandardCharsets.ISO_8859_1),
+					UriUtils.encode(parameters.apply(name), StandardCharsets.ISO_8859_1));
+		}
+	}
+
+	private void doPost(HttpServletResponse response, Saml2LogoutRequest logoutRequest) throws IOException {
+		String location = logoutRequest.getLocation();
+		String saml = logoutRequest.getSamlRequest();
+		String relayState = logoutRequest.getRelayState();
+		String html = createSamlPostRequestFormData(location, saml, relayState);
+		response.setContentType(MediaType.TEXT_HTML_VALUE);
+		response.getWriter().write(html);
+	}
+
+	private String createSamlPostRequestFormData(String location, String saml, String relayState) {
+		StringBuilder html = new StringBuilder();
+		html.append("<!DOCTYPE html>\n");
+		html.append("<html>\n").append("    <head>\n");
+		html.append("        <meta charset=\"utf-8\" />\n");
+		html.append("    </head>\n");
+		html.append("    <body onload=\"document.forms[0].submit()\">\n");
+		html.append("        <noscript>\n");
+		html.append("            <p>\n");
+		html.append("                <strong>Note:</strong> Since your browser does not support JavaScript,\n");
+		html.append("                you must press the Continue button once to proceed.\n");
+		html.append("            </p>\n");
+		html.append("        </noscript>\n");
+		html.append("        \n");
+		html.append("        <form action=\"");
+		html.append(location);
+		html.append("\" method=\"post\">\n");
+		html.append("            <div>\n");
+		html.append("                <input type=\"hidden\" name=\"SAMLRequest\" value=\"");
+		html.append(HtmlUtils.htmlEscape(saml));
+		html.append("\"/>\n");
+		if (StringUtils.hasText(relayState)) {
+			html.append("                <input type=\"hidden\" name=\"RelayState\" value=\"");
+			html.append(HtmlUtils.htmlEscape(relayState));
+			html.append("\"/>\n");
+		}
+		html.append("            </div>\n");
+		html.append("            <noscript>\n");
+		html.append("                <div>\n");
+		html.append("                    <input type=\"submit\" value=\"Continue\"/>\n");
+		html.append("                </div>\n");
+		html.append("            </noscript>\n");
+		html.append("        </form>\n");
+		html.append("        \n");
+		html.append("    </body>\n");
+		html.append("</html>");
+		return html.toString();
+	}
+
+}

+ 76 - 0
saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2Utils.java

@@ -0,0 +1,76 @@
+/*
+ * Copyright 2002-2021 the original author or authors.
+ *
+ * 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
+ *
+ *      https://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 org.springframework.security.saml2.provider.service.web.authentication.logout;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.zip.Deflater;
+import java.util.zip.DeflaterOutputStream;
+import java.util.zip.Inflater;
+import java.util.zip.InflaterOutputStream;
+
+import org.springframework.security.saml2.Saml2Exception;
+
+/**
+ * Utility methods for working with serialized SAML messages.
+ *
+ * For internal use only.
+ *
+ * @author Josh Cummings
+ */
+final class Saml2Utils {
+
+	private Saml2Utils() {
+	}
+
+	static String samlEncode(byte[] b) {
+		return Base64.getEncoder().encodeToString(b);
+	}
+
+	static byte[] samlDecode(String s) {
+		return Base64.getDecoder().decode(s);
+	}
+
+	static byte[] samlDeflate(String s) {
+		try {
+			ByteArrayOutputStream b = new ByteArrayOutputStream();
+			DeflaterOutputStream deflater = new DeflaterOutputStream(b, new Deflater(Deflater.DEFLATED, true));
+			deflater.write(s.getBytes(StandardCharsets.UTF_8));
+			deflater.finish();
+			return b.toByteArray();
+		}
+		catch (IOException ex) {
+			throw new Saml2Exception("Unable to deflate string", ex);
+		}
+	}
+
+	static String samlInflate(byte[] b) {
+		try {
+			ByteArrayOutputStream out = new ByteArrayOutputStream();
+			InflaterOutputStream iout = new InflaterOutputStream(out, new Inflater(true));
+			iout.write(b);
+			iout.finish();
+			return new String(out.toByteArray(), StandardCharsets.UTF_8);
+		}
+		catch (IOException ex) {
+			throw new Saml2Exception("Unable to inflate string", ex);
+		}
+	}
+
+}

+ 125 - 0
saml2/saml2-service-provider/src/opensaml3Main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSaml3LogoutRequestResolver.java

@@ -0,0 +1,125 @@
+/*
+ * Copyright 2002-2021 the original author or authors.
+ *
+ * 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
+ *
+ *      https://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 org.springframework.security.saml2.provider.service.web.authentication.logout;
+
+import java.time.Clock;
+import java.util.function.Consumer;
+
+import javax.servlet.http.HttpServletRequest;
+
+import org.joda.time.DateTime;
+import org.opensaml.saml.saml2.core.LogoutRequest;
+
+import org.springframework.security.core.Authentication;
+import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutRequest;
+import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration;
+import org.springframework.security.saml2.provider.service.web.RelyingPartyRegistrationResolver;
+import org.springframework.util.Assert;
+
+/**
+ * A {@link Saml2LogoutRequestResolver} for resolving SAML 2.0 Logout Requests with
+ * OpenSAML 3
+ *
+ * @author Josh Cummings
+ * @since 5.6
+ * @deprecated Because OpenSAML 3 has reached End-of-Life, please update to
+ * {@code OpenSaml4LogoutRequestResolver}
+ */
+public final class OpenSaml3LogoutRequestResolver implements Saml2LogoutRequestResolver {
+
+	private final OpenSamlLogoutRequestResolver logoutRequestResolver;
+
+	private Consumer<LogoutRequestParameters> parametersConsumer = (parameters) -> {
+	};
+
+	private Clock clock = Clock.systemUTC();
+
+	/**
+	 * Construct a {@link OpenSaml3LogoutRequestResolver}
+	 */
+	public OpenSaml3LogoutRequestResolver(RelyingPartyRegistrationResolver relyingPartyRegistrationResolver) {
+		this.logoutRequestResolver = new OpenSamlLogoutRequestResolver(relyingPartyRegistrationResolver);
+	}
+
+	/**
+	 * {@inheritDoc}
+	 */
+	@Override
+	public Saml2LogoutRequest resolve(HttpServletRequest request, Authentication authentication) {
+		return this.logoutRequestResolver.resolve(request, authentication, (registration, logoutRequest) -> {
+			logoutRequest.setIssueInstant(new DateTime(this.clock.millis()));
+			this.parametersConsumer
+					.accept(new LogoutRequestParameters(request, registration, authentication, logoutRequest));
+		});
+	}
+
+	/**
+	 * Set a {@link Consumer} for modifying the OpenSAML {@link LogoutRequest}
+	 * @param parametersConsumer a consumer that accepts an
+	 * {@link LogoutRequestParameters}
+	 */
+	public void setParametersConsumer(Consumer<LogoutRequestParameters> parametersConsumer) {
+		Assert.notNull(parametersConsumer, "parametersConsumer cannot be null");
+		this.parametersConsumer = parametersConsumer;
+	}
+
+	/**
+	 * Use this {@link Clock} for generating the issued {@link DateTime}
+	 * @param clock the {@link Clock} to use
+	 */
+	public void setClock(Clock clock) {
+		Assert.notNull(clock, "clock must not be null");
+		this.clock = clock;
+	}
+
+	public static final class LogoutRequestParameters {
+
+		private final HttpServletRequest request;
+
+		private final RelyingPartyRegistration registration;
+
+		private final Authentication authentication;
+
+		private final LogoutRequest logoutRequest;
+
+		public LogoutRequestParameters(HttpServletRequest request, RelyingPartyRegistration registration,
+				Authentication authentication, LogoutRequest logoutRequest) {
+			this.request = request;
+			this.registration = registration;
+			this.authentication = authentication;
+			this.logoutRequest = logoutRequest;
+		}
+
+		public HttpServletRequest getRequest() {
+			return this.request;
+		}
+
+		public RelyingPartyRegistration getRelyingPartyRegistration() {
+			return this.registration;
+		}
+
+		public Authentication getAuthentication() {
+			return this.authentication;
+		}
+
+		public LogoutRequest getLogoutRequest() {
+			return this.logoutRequest;
+		}
+
+	}
+
+}

+ 121 - 0
saml2/saml2-service-provider/src/opensaml3Main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSaml3LogoutResponseResolver.java

@@ -0,0 +1,121 @@
+/*
+ * Copyright 2002-2021 the original author or authors.
+ *
+ * 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
+ *
+ *      https://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 org.springframework.security.saml2.provider.service.web.authentication.logout;
+
+import java.time.Clock;
+import java.util.function.Consumer;
+
+import javax.servlet.http.HttpServletRequest;
+
+import org.joda.time.DateTime;
+import org.opensaml.saml.saml2.core.LogoutResponse;
+
+import org.springframework.security.core.Authentication;
+import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutResponse;
+import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration;
+import org.springframework.security.saml2.provider.service.web.RelyingPartyRegistrationResolver;
+import org.springframework.util.Assert;
+
+/**
+ * A {@link Saml2LogoutResponseResolver} for resolving SAML 2.0 Logout Responses with
+ * OpenSAML 3
+ *
+ * @author Josh Cummings
+ * @since 5.6
+ * @deprecated Because OpenSAML 3 has reached End-of-Life, please update to
+ * {@code OpenSaml4LogoutResponseResolver}
+ */
+public final class OpenSaml3LogoutResponseResolver implements Saml2LogoutResponseResolver {
+
+	private final OpenSamlLogoutResponseResolver logoutResponseResolver;
+
+	private Consumer<LogoutResponseParameters> parametersConsumer = (parameters) -> {
+	};
+
+	private Clock clock = Clock.systemUTC();
+
+	/**
+	 * Construct a {@link OpenSaml3LogoutResponseResolver}
+	 */
+	public OpenSaml3LogoutResponseResolver(RelyingPartyRegistrationResolver relyingPartyRegistrationResolver) {
+		this.logoutResponseResolver = new OpenSamlLogoutResponseResolver(relyingPartyRegistrationResolver);
+	}
+
+	/**
+	 * {@inheritDoc}
+	 */
+	@Override
+	public Saml2LogoutResponse resolve(HttpServletRequest request, Authentication authentication) {
+		return this.logoutResponseResolver.resolve(request, authentication, (registration, logoutResponse) -> {
+			logoutResponse.setIssueInstant(new DateTime(this.clock.millis()));
+			this.parametersConsumer
+					.accept(new LogoutResponseParameters(request, registration, authentication, logoutResponse));
+		});
+	}
+
+	public void setClock(Clock clock) {
+		Assert.notNull(clock, "clock must not be null");
+		this.clock = clock;
+	}
+
+	/**
+	 * Set a {@link Consumer} for modifying the OpenSAML {@link LogoutResponse}
+	 * @param parametersConsumer a consumer that accepts an
+	 * {@link LogoutResponseParameters}
+	 */
+	public void setParametersConsumer(Consumer<LogoutResponseParameters> parametersConsumer) {
+		Assert.notNull(parametersConsumer, "parametersConsumer cannot be null");
+		this.parametersConsumer = parametersConsumer;
+	}
+
+	public static final class LogoutResponseParameters {
+
+		private final HttpServletRequest request;
+
+		private final RelyingPartyRegistration registration;
+
+		private final Authentication authentication;
+
+		private final LogoutResponse logoutResponse;
+
+		public LogoutResponseParameters(HttpServletRequest request, RelyingPartyRegistration registration,
+				Authentication authentication, LogoutResponse logoutResponse) {
+			this.request = request;
+			this.registration = registration;
+			this.authentication = authentication;
+			this.logoutResponse = logoutResponse;
+		}
+
+		public HttpServletRequest getRequest() {
+			return this.request;
+		}
+
+		public RelyingPartyRegistration getRelyingPartyRegistration() {
+			return this.registration;
+		}
+
+		public Authentication getAuthentication() {
+			return this.authentication;
+		}
+
+		public LogoutResponse getLogoutResponse() {
+			return this.logoutResponse;
+		}
+
+	}
+
+}

+ 65 - 0
saml2/saml2-service-provider/src/opensaml3Test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSaml3LogoutRequestResolverTests.java

@@ -0,0 +1,65 @@
+/*
+ * Copyright 2002-2021 the original author or authors.
+ *
+ * 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
+ *
+ *      https://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 org.springframework.security.saml2.provider.service.web.authentication.logout;
+
+import javax.servlet.http.HttpServletRequest;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.security.authentication.TestingAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutRequest;
+import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration;
+import org.springframework.security.saml2.provider.service.registration.TestRelyingPartyRegistrations;
+import org.springframework.security.saml2.provider.service.web.RelyingPartyRegistrationResolver;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+
+/**
+ * Tests for {@link OpenSaml3LogoutRequestResolver}
+ */
+public class OpenSaml3LogoutRequestResolverTests {
+
+	RelyingPartyRegistrationResolver relyingPartyRegistrationResolver = mock(RelyingPartyRegistrationResolver.class);
+
+	@Test
+	public void resolveWhenCustomParametersConsumerThenUses() {
+		OpenSaml3LogoutRequestResolver logoutRequestResolver = new OpenSaml3LogoutRequestResolver(
+				this.relyingPartyRegistrationResolver);
+		logoutRequestResolver.setParametersConsumer((parameters) -> parameters.getLogoutRequest().setID("myid"));
+		HttpServletRequest request = new MockHttpServletRequest();
+		RelyingPartyRegistration registration = TestRelyingPartyRegistrations.relyingPartyRegistration().build();
+		Authentication authentication = new TestingAuthenticationToken("user", "password");
+		given(this.relyingPartyRegistrationResolver.resolve(any(), any())).willReturn(registration);
+		Saml2LogoutRequest logoutRequest = logoutRequestResolver.resolve(request, authentication);
+		assertThat(logoutRequest.getId()).isEqualTo("myid");
+	}
+
+	@Test
+	public void setParametersConsumerWhenNullThenIllegalArgument() {
+		OpenSaml3LogoutRequestResolver logoutRequestResolver = new OpenSaml3LogoutRequestResolver(
+				this.relyingPartyRegistrationResolver);
+		assertThatExceptionOfType(IllegalArgumentException.class)
+				.isThrownBy(() -> logoutRequestResolver.setParametersConsumer(null));
+	}
+
+}

+ 74 - 0
saml2/saml2-service-provider/src/opensaml3Test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSaml3LogoutResponseResolverTests.java

@@ -0,0 +1,74 @@
+/*
+ * Copyright 2002-2021 the original author or authors.
+ *
+ * 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
+ *
+ *      https://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 org.springframework.security.saml2.provider.service.web.authentication.logout;
+
+import java.util.function.Consumer;
+
+import org.junit.jupiter.api.Test;
+import org.opensaml.saml.saml2.core.LogoutRequest;
+
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.security.authentication.TestingAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.saml2.provider.service.authentication.TestOpenSamlObjects;
+import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutResponse;
+import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration;
+import org.springframework.security.saml2.provider.service.registration.TestRelyingPartyRegistrations;
+import org.springframework.security.saml2.provider.service.web.RelyingPartyRegistrationResolver;
+import org.springframework.security.saml2.provider.service.web.authentication.logout.OpenSaml3LogoutResponseResolver.LogoutResponseParameters;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+
+/**
+ * Tests for {@link OpenSaml3LogoutResponseResolver}
+ */
+public class OpenSaml3LogoutResponseResolverTests {
+
+	RelyingPartyRegistrationResolver relyingPartyRegistrationResolver = mock(RelyingPartyRegistrationResolver.class);
+
+	@Test
+	public void resolveWhenCustomParametersConsumerThenUses() {
+		OpenSaml3LogoutResponseResolver logoutResponseResolver = new OpenSaml3LogoutResponseResolver(
+				this.relyingPartyRegistrationResolver);
+		Consumer<LogoutResponseParameters> parametersConsumer = mock(Consumer.class);
+		logoutResponseResolver.setParametersConsumer(parametersConsumer);
+		MockHttpServletRequest request = new MockHttpServletRequest();
+		RelyingPartyRegistration registration = TestRelyingPartyRegistrations.relyingPartyRegistration().build();
+		Authentication authentication = new TestingAuthenticationToken("user", "password");
+		LogoutRequest logoutRequest = TestOpenSamlObjects.assertingPartyLogoutRequest(registration);
+		request.setParameter("SAMLRequest",
+				Saml2Utils.samlEncode(OpenSamlSigningUtils.serialize(logoutRequest).getBytes()));
+		given(this.relyingPartyRegistrationResolver.resolve(any(), any())).willReturn(registration);
+		Saml2LogoutResponse logoutResponse = logoutResponseResolver.resolve(request, authentication);
+		assertThat(logoutResponse).isNotNull();
+		verify(parametersConsumer).accept(any());
+	}
+
+	@Test
+	public void setParametersConsumerWhenNullThenIllegalArgument() {
+		OpenSaml3LogoutRequestResolver logoutRequestResolver = new OpenSaml3LogoutRequestResolver(
+				this.relyingPartyRegistrationResolver);
+		assertThatExceptionOfType(IllegalArgumentException.class)
+				.isThrownBy(() -> logoutRequestResolver.setParametersConsumer(null));
+	}
+
+}

+ 123 - 0
saml2/saml2-service-provider/src/opensaml4Main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSaml4LogoutRequestResolver.java

@@ -0,0 +1,123 @@
+/*
+ * Copyright 2002-2021 the original author or authors.
+ *
+ * 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
+ *
+ *      https://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 org.springframework.security.saml2.provider.service.web.authentication.logout;
+
+import java.time.Clock;
+import java.time.Instant;
+import java.util.function.Consumer;
+
+import javax.servlet.http.HttpServletRequest;
+
+import org.opensaml.saml.saml2.core.LogoutRequest;
+
+import org.springframework.security.core.Authentication;
+import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutRequest;
+import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration;
+import org.springframework.security.saml2.provider.service.web.RelyingPartyRegistrationResolver;
+import org.springframework.util.Assert;
+
+/**
+ * A {@link Saml2LogoutRequestResolver} for resolving SAML 2.0 Logout Requests with
+ * OpenSAML 4
+ *
+ * @author Josh Cummings
+ * @since 5.6
+ */
+public final class OpenSaml4LogoutRequestResolver implements Saml2LogoutRequestResolver {
+
+	private final OpenSamlLogoutRequestResolver logoutRequestResolver;
+
+	private Consumer<LogoutRequestParameters> parametersConsumer = (parameters) -> {
+	};
+
+	private Clock clock = Clock.systemUTC();
+
+	/**
+	 * Construct a {@link OpenSaml4LogoutRequestResolver}
+	 */
+	public OpenSaml4LogoutRequestResolver(RelyingPartyRegistrationResolver relyingPartyRegistrationResolver) {
+		this.logoutRequestResolver = new OpenSamlLogoutRequestResolver(relyingPartyRegistrationResolver);
+	}
+
+	/**
+	 * {@inheritDoc}
+	 */
+	@Override
+	public Saml2LogoutRequest resolve(HttpServletRequest request, Authentication authentication) {
+		return this.logoutRequestResolver.resolve(request, authentication, (registration, logoutRequest) -> {
+			logoutRequest.setIssueInstant(Instant.now(this.clock));
+			this.parametersConsumer
+					.accept(new LogoutRequestParameters(request, registration, authentication, logoutRequest));
+		});
+	}
+
+	/**
+	 * Set a {@link Consumer} for modifying the OpenSAML {@link LogoutRequest}
+	 * @param parametersConsumer a consumer that accepts an
+	 * {@link LogoutRequestParameters}
+	 */
+	public void setParametersConsumer(Consumer<LogoutRequestParameters> parametersConsumer) {
+		Assert.notNull(parametersConsumer, "parametersConsumer cannot be null");
+		this.parametersConsumer = parametersConsumer;
+	}
+
+	/**
+	 * Use this {@link Clock} for determining the issued {@link Instant}
+	 * @param clock the {@link Clock} to use
+	 */
+	public void setClock(Clock clock) {
+		Assert.notNull(clock, "clock must not be null");
+		this.clock = clock;
+	}
+
+	public static final class LogoutRequestParameters {
+
+		private final HttpServletRequest request;
+
+		private final RelyingPartyRegistration registration;
+
+		private final Authentication authentication;
+
+		private final LogoutRequest logoutRequest;
+
+		public LogoutRequestParameters(HttpServletRequest request, RelyingPartyRegistration registration,
+				Authentication authentication, LogoutRequest logoutRequest) {
+			this.request = request;
+			this.registration = registration;
+			this.authentication = authentication;
+			this.logoutRequest = logoutRequest;
+		}
+
+		public HttpServletRequest getRequest() {
+			return this.request;
+		}
+
+		public RelyingPartyRegistration getRelyingPartyRegistration() {
+			return this.registration;
+		}
+
+		public Authentication getAuthentication() {
+			return this.authentication;
+		}
+
+		public LogoutRequest getLogoutRequest() {
+			return this.logoutRequest;
+		}
+
+	}
+
+}

+ 119 - 0
saml2/saml2-service-provider/src/opensaml4Main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSaml4LogoutResponseResolver.java

@@ -0,0 +1,119 @@
+/*
+ * Copyright 2002-2021 the original author or authors.
+ *
+ * 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
+ *
+ *      https://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 org.springframework.security.saml2.provider.service.web.authentication.logout;
+
+import java.time.Clock;
+import java.time.Instant;
+import java.util.function.Consumer;
+
+import javax.servlet.http.HttpServletRequest;
+
+import org.opensaml.saml.saml2.core.LogoutResponse;
+
+import org.springframework.security.core.Authentication;
+import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutResponse;
+import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration;
+import org.springframework.security.saml2.provider.service.web.RelyingPartyRegistrationResolver;
+import org.springframework.util.Assert;
+
+/**
+ * A {@link Saml2LogoutResponseResolver} for resolving SAML 2.0 Logout Responses with
+ * OpenSAML 4
+ *
+ * @author Josh Cummings
+ * @since 5.6
+ */
+public final class OpenSaml4LogoutResponseResolver implements Saml2LogoutResponseResolver {
+
+	private final OpenSamlLogoutResponseResolver logoutResponseResolver;
+
+	private Consumer<LogoutResponseParameters> parametersConsumer = (parameters) -> {
+	};
+
+	private Clock clock = Clock.systemUTC();
+
+	/**
+	 * Construct a {@link OpenSaml4LogoutResponseResolver}
+	 */
+	public OpenSaml4LogoutResponseResolver(RelyingPartyRegistrationResolver relyingPartyRegistrationResolver) {
+		this.logoutResponseResolver = new OpenSamlLogoutResponseResolver(relyingPartyRegistrationResolver);
+	}
+
+	/**
+	 * {@inheritDoc}
+	 */
+	@Override
+	public Saml2LogoutResponse resolve(HttpServletRequest request, Authentication authentication) {
+		return this.logoutResponseResolver.resolve(request, authentication, (registration, logoutResponse) -> {
+			logoutResponse.setIssueInstant(Instant.now(this.clock));
+			this.parametersConsumer
+					.accept(new LogoutResponseParameters(request, registration, authentication, logoutResponse));
+		});
+	}
+
+	/**
+	 * Set a {@link Consumer} for modifying the OpenSAML {@link LogoutResponse}
+	 * @param parametersConsumer a consumer that accepts an
+	 * {@link LogoutResponseParameters}
+	 */
+	public void setParametersConsumer(Consumer<LogoutResponseParameters> parametersConsumer) {
+		Assert.notNull(parametersConsumer, "parametersConsumer cannot be null");
+		this.parametersConsumer = parametersConsumer;
+	}
+
+	public void setClock(Clock clock) {
+		Assert.notNull(clock, "clock must not be null");
+		this.clock = clock;
+	}
+
+	public static final class LogoutResponseParameters {
+
+		private final HttpServletRequest request;
+
+		private final RelyingPartyRegistration registration;
+
+		private final Authentication authentication;
+
+		private final LogoutResponse logoutResponse;
+
+		public LogoutResponseParameters(HttpServletRequest request, RelyingPartyRegistration registration,
+				Authentication authentication, LogoutResponse logoutResponse) {
+			this.request = request;
+			this.registration = registration;
+			this.authentication = authentication;
+			this.logoutResponse = logoutResponse;
+		}
+
+		public HttpServletRequest getRequest() {
+			return this.request;
+		}
+
+		public RelyingPartyRegistration getRelyingPartyRegistration() {
+			return this.registration;
+		}
+
+		public Authentication getAuthentication() {
+			return this.authentication;
+		}
+
+		public LogoutResponse getLogoutResponse() {
+			return this.logoutResponse;
+		}
+
+	}
+
+}

+ 65 - 0
saml2/saml2-service-provider/src/opensaml4Test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSaml4LogoutRequestResolverTests.java

@@ -0,0 +1,65 @@
+/*
+ * Copyright 2002-2021 the original author or authors.
+ *
+ * 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
+ *
+ *      https://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 org.springframework.security.saml2.provider.service.web.authentication.logout;
+
+import javax.servlet.http.HttpServletRequest;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.security.authentication.TestingAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutRequest;
+import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration;
+import org.springframework.security.saml2.provider.service.registration.TestRelyingPartyRegistrations;
+import org.springframework.security.saml2.provider.service.web.RelyingPartyRegistrationResolver;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+
+/**
+ * Tests for {@link OpenSaml4LogoutRequestResolver}
+ */
+public class OpenSaml4LogoutRequestResolverTests {
+
+	RelyingPartyRegistrationResolver relyingPartyRegistrationResolver = mock(RelyingPartyRegistrationResolver.class);
+
+	@Test
+	public void resolveWhenCustomParametersConsumerThenUses() {
+		OpenSaml4LogoutRequestResolver logoutRequestResolver = new OpenSaml4LogoutRequestResolver(
+				this.relyingPartyRegistrationResolver);
+		logoutRequestResolver.setParametersConsumer((parameters) -> parameters.getLogoutRequest().setID("myid"));
+		HttpServletRequest request = new MockHttpServletRequest();
+		RelyingPartyRegistration registration = TestRelyingPartyRegistrations.relyingPartyRegistration().build();
+		Authentication authentication = new TestingAuthenticationToken("user", "password");
+		given(this.relyingPartyRegistrationResolver.resolve(any(), any())).willReturn(registration);
+		Saml2LogoutRequest logoutRequest = logoutRequestResolver.resolve(request, authentication);
+		assertThat(logoutRequest.getId()).isEqualTo("myid");
+	}
+
+	@Test
+	public void setParametersConsumerWhenNullThenIllegalArgument() {
+		OpenSaml4LogoutRequestResolver logoutRequestResolver = new OpenSaml4LogoutRequestResolver(
+				this.relyingPartyRegistrationResolver);
+		assertThatExceptionOfType(IllegalArgumentException.class)
+				.isThrownBy(() -> logoutRequestResolver.setParametersConsumer(null));
+	}
+
+}

+ 74 - 0
saml2/saml2-service-provider/src/opensaml4Test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSaml4LogoutResponseResolverTests.java

@@ -0,0 +1,74 @@
+/*
+ * Copyright 2002-2021 the original author or authors.
+ *
+ * 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
+ *
+ *      https://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 org.springframework.security.saml2.provider.service.web.authentication.logout;
+
+import java.util.function.Consumer;
+
+import org.junit.jupiter.api.Test;
+import org.opensaml.saml.saml2.core.LogoutRequest;
+
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.security.authentication.TestingAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.saml2.provider.service.authentication.TestOpenSamlObjects;
+import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutResponse;
+import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration;
+import org.springframework.security.saml2.provider.service.registration.TestRelyingPartyRegistrations;
+import org.springframework.security.saml2.provider.service.web.RelyingPartyRegistrationResolver;
+import org.springframework.security.saml2.provider.service.web.authentication.logout.OpenSaml4LogoutResponseResolver.LogoutResponseParameters;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+
+/**
+ * Tests for {@link OpenSaml4LogoutResponseResolver}
+ */
+public class OpenSaml4LogoutResponseResolverTests {
+
+	RelyingPartyRegistrationResolver relyingPartyRegistrationResolver = mock(RelyingPartyRegistrationResolver.class);
+
+	@Test
+	public void resolveWhenCustomParametersConsumerThenUses() {
+		OpenSaml4LogoutResponseResolver logoutResponseResolver = new OpenSaml4LogoutResponseResolver(
+				this.relyingPartyRegistrationResolver);
+		Consumer<LogoutResponseParameters> parametersConsumer = mock(Consumer.class);
+		logoutResponseResolver.setParametersConsumer(parametersConsumer);
+		MockHttpServletRequest request = new MockHttpServletRequest();
+		RelyingPartyRegistration registration = TestRelyingPartyRegistrations.relyingPartyRegistration().build();
+		Authentication authentication = new TestingAuthenticationToken("user", "password");
+		LogoutRequest logoutRequest = TestOpenSamlObjects.assertingPartyLogoutRequest(registration);
+		request.setParameter("SAMLRequest",
+				Saml2Utils.samlEncode(OpenSamlSigningUtils.serialize(logoutRequest).getBytes()));
+		given(this.relyingPartyRegistrationResolver.resolve(any(), any())).willReturn(registration);
+		Saml2LogoutResponse logoutResponse = logoutResponseResolver.resolve(request, authentication);
+		assertThat(logoutResponse).isNotNull();
+		verify(parametersConsumer).accept(any());
+	}
+
+	@Test
+	public void setParametersConsumerWhenNullThenIllegalArgument() {
+		OpenSaml4LogoutRequestResolver logoutRequestResolver = new OpenSaml4LogoutRequestResolver(
+				this.relyingPartyRegistrationResolver);
+		assertThatExceptionOfType(IllegalArgumentException.class)
+				.isThrownBy(() -> logoutRequestResolver.setParametersConsumer(null));
+	}
+
+}

+ 44 - 2
saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/authentication/TestOpenSamlObjects.java

@@ -54,6 +54,8 @@ import org.opensaml.saml.saml2.core.EncryptedAssertion;
 import org.opensaml.saml.saml2.core.EncryptedAttribute;
 import org.opensaml.saml.saml2.core.EncryptedID;
 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.Response;
 import org.opensaml.saml.saml2.core.Status;
@@ -63,6 +65,10 @@ import org.opensaml.saml.saml2.core.SubjectConfirmation;
 import org.opensaml.saml.saml2.core.SubjectConfirmationData;
 import org.opensaml.saml.saml2.core.impl.AttributeBuilder;
 import org.opensaml.saml.saml2.core.impl.AttributeStatementBuilder;
+import org.opensaml.saml.saml2.core.impl.IssuerBuilder;
+import org.opensaml.saml.saml2.core.impl.LogoutRequestBuilder;
+import org.opensaml.saml.saml2.core.impl.LogoutResponseBuilder;
+import org.opensaml.saml.saml2.core.impl.NameIDBuilder;
 import org.opensaml.saml.saml2.core.impl.StatusBuilder;
 import org.opensaml.saml.saml2.core.impl.StatusCodeBuilder;
 import org.opensaml.saml.saml2.encryption.Encrypter;
@@ -83,6 +89,7 @@ import org.springframework.security.saml2.Saml2Exception;
 import org.springframework.security.saml2.core.OpenSamlInitializationService;
 import org.springframework.security.saml2.core.Saml2X509Credential;
 import org.springframework.security.saml2.core.TestSaml2X509Credentials;
+import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration;
 
 public final class TestOpenSamlObjects {
 
@@ -93,7 +100,7 @@ public final class TestOpenSamlObjects {
 
 	private static String DESTINATION = "https://localhost/login/saml2/sso/idp-alias";
 
-	private static String RELYING_PARTY_ENTITY_ID = "https://localhost/saml2/service-provider-metadata/idp-alias";
+	public static String RELYING_PARTY_ENTITY_ID = "https://localhost/saml2/service-provider-metadata/idp-alias";
 
 	private static String ASSERTING_PARTY_ENTITY_ID = "https://some.idp.test/saml2/idp";
 
@@ -221,7 +228,7 @@ public final class TestOpenSamlObjects {
 		return signable;
 	}
 
-	static <T extends SignableSAMLObject> T signed(T signable, Saml2X509Credential credential, String entityId) {
+	public static <T extends SignableSAMLObject> T signed(T signable, Saml2X509Credential credential, String entityId) {
 		return signed(signable, credential, entityId, SignatureConstants.ALGO_ID_SIGNATURE_RSA_SHA256);
 	}
 
@@ -342,6 +349,41 @@ public final class TestOpenSamlObjects {
 		return status;
 	}
 
+	public static LogoutRequest assertingPartyLogoutRequest(RelyingPartyRegistration registration) {
+		LogoutRequestBuilder logoutRequestBuilder = new LogoutRequestBuilder();
+		LogoutRequest logoutRequest = logoutRequestBuilder.buildObject();
+		logoutRequest.setID("id");
+		NameIDBuilder nameIdBuilder = new NameIDBuilder();
+		NameID nameId = nameIdBuilder.buildObject();
+		nameId.setValue("user");
+		logoutRequest.setNameID(nameId);
+		IssuerBuilder issuerBuilder = new IssuerBuilder();
+		Issuer issuer = issuerBuilder.buildObject();
+		issuer.setValue(registration.getAssertingPartyDetails().getEntityId());
+		logoutRequest.setIssuer(issuer);
+		logoutRequest.setDestination(registration.getSingleLogoutServiceLocation());
+		return logoutRequest;
+	}
+
+	public static LogoutResponse assertingPartyLogoutResponse(RelyingPartyRegistration registration) {
+		LogoutResponseBuilder logoutResponseBuilder = new LogoutResponseBuilder();
+		LogoutResponse logoutResponse = logoutResponseBuilder.buildObject();
+		logoutResponse.setID("id");
+		StatusBuilder statusBuilder = new StatusBuilder();
+		StatusCodeBuilder statusCodeBuilder = new StatusCodeBuilder();
+		StatusCode code = statusCodeBuilder.buildObject();
+		code.setValue(StatusCode.SUCCESS);
+		Status status = statusBuilder.buildObject();
+		status.setStatusCode(code);
+		logoutResponse.setStatus(status);
+		IssuerBuilder issuerBuilder = new IssuerBuilder();
+		Issuer issuer = issuerBuilder.buildObject();
+		issuer.setValue(registration.getAssertingPartyDetails().getEntityId());
+		logoutResponse.setIssuer(issuer);
+		logoutResponse.setDestination(registration.getSingleLogoutServiceResponseLocation());
+		return logoutResponse;
+	}
+
 	static <T extends XMLObject> T build(QName qName) {
 		return (T) XMLObjectProviderRegistrySupport.getBuilderFactory().getBuilder(qName).buildObject(qName);
 	}

+ 173 - 0
saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/authentication/logout/OpenSamlLogoutRequestValidatorTests.java

@@ -0,0 +1,173 @@
+/*
+ * Copyright 2002-2021 the original author or authors.
+ *
+ * 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
+ *
+ *      https://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 org.springframework.security.saml2.provider.service.authentication.logout;
+
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.Map;
+
+import org.junit.jupiter.api.Test;
+import org.opensaml.core.xml.XMLObject;
+import org.opensaml.saml.saml2.core.LogoutRequest;
+
+import org.springframework.security.core.Authentication;
+import org.springframework.security.saml2.core.Saml2ErrorCodes;
+import org.springframework.security.saml2.core.TestSaml2X509Credentials;
+import org.springframework.security.saml2.provider.service.authentication.DefaultSaml2AuthenticatedPrincipal;
+import org.springframework.security.saml2.provider.service.authentication.Saml2Authentication;
+import org.springframework.security.saml2.provider.service.authentication.TestOpenSamlObjects;
+import org.springframework.security.saml2.provider.service.authentication.logout.OpenSamlSigningUtils.QueryParametersPartial;
+import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration;
+import org.springframework.security.saml2.provider.service.registration.Saml2MessageBinding;
+import org.springframework.security.saml2.provider.service.registration.TestRelyingPartyRegistrations;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link OpenSamlLogoutRequestValidator}
+ *
+ * @author Josh Cummings
+ */
+public class OpenSamlLogoutRequestValidatorTests {
+
+	private final OpenSamlLogoutRequestValidator manager = new OpenSamlLogoutRequestValidator();
+
+	@Test
+	public void handleWhenPostBindingThenValidates() {
+		RelyingPartyRegistration registration = registration().build();
+		LogoutRequest logoutRequest = TestOpenSamlObjects.assertingPartyLogoutRequest(registration);
+		sign(logoutRequest, registration);
+		Saml2LogoutRequest request = post(logoutRequest, registration);
+		Saml2LogoutRequestValidatorParameters parameters = new Saml2LogoutRequestValidatorParameters(request,
+				registration, authentication(registration));
+		Saml2LogoutValidatorResult result = this.manager.validate(parameters);
+		assertThat(result.hasErrors()).isFalse();
+	}
+
+	@Test
+	public void handleWhenRedirectBindingThenValidatesSignatureParameter() {
+		RelyingPartyRegistration registration = registration()
+				.assertingPartyDetails((party) -> party.singleLogoutServiceBinding(Saml2MessageBinding.REDIRECT))
+				.build();
+		LogoutRequest logoutRequest = TestOpenSamlObjects.assertingPartyLogoutRequest(registration);
+		Saml2LogoutRequest request = redirect(logoutRequest, registration, OpenSamlSigningUtils.sign(registration));
+		Saml2LogoutRequestValidatorParameters parameters = new Saml2LogoutRequestValidatorParameters(request,
+				registration, authentication(registration));
+		Saml2LogoutValidatorResult result = this.manager.validate(parameters);
+		assertThat(result.hasErrors()).isFalse();
+	}
+
+	@Test
+	public void handleWhenInvalidIssuerThenInvalidSignatureError() {
+		RelyingPartyRegistration registration = registration().build();
+		LogoutRequest logoutRequest = TestOpenSamlObjects.assertingPartyLogoutRequest(registration);
+		logoutRequest.getIssuer().setValue("wrong");
+		sign(logoutRequest, registration);
+		Saml2LogoutRequest request = post(logoutRequest, registration);
+		Saml2LogoutRequestValidatorParameters parameters = new Saml2LogoutRequestValidatorParameters(request,
+				registration, authentication(registration));
+		Saml2LogoutValidatorResult result = this.manager.validate(parameters);
+		assertThat(result.hasErrors()).isTrue();
+		assertThat(result.getErrors().iterator().next().getErrorCode()).isEqualTo(Saml2ErrorCodes.INVALID_SIGNATURE);
+	}
+
+	@Test
+	public void handleWhenMismatchedUserThenInvalidRequestError() {
+		RelyingPartyRegistration registration = registration().build();
+		LogoutRequest logoutRequest = TestOpenSamlObjects.assertingPartyLogoutRequest(registration);
+		logoutRequest.getNameID().setValue("wrong");
+		sign(logoutRequest, registration);
+		Saml2LogoutRequest request = post(logoutRequest, registration);
+		Saml2LogoutRequestValidatorParameters parameters = new Saml2LogoutRequestValidatorParameters(request,
+				registration, authentication(registration));
+		Saml2LogoutValidatorResult result = this.manager.validate(parameters);
+		assertThat(result.hasErrors()).isTrue();
+		assertThat(result.getErrors().iterator().next().getErrorCode()).isEqualTo(Saml2ErrorCodes.INVALID_REQUEST);
+	}
+
+	@Test
+	public void handleWhenMissingUserThenSubjectNotFoundError() {
+		RelyingPartyRegistration registration = registration().build();
+		LogoutRequest logoutRequest = TestOpenSamlObjects.assertingPartyLogoutRequest(registration);
+		logoutRequest.setNameID(null);
+		sign(logoutRequest, registration);
+		Saml2LogoutRequest request = post(logoutRequest, registration);
+		Saml2LogoutRequestValidatorParameters parameters = new Saml2LogoutRequestValidatorParameters(request,
+				registration, authentication(registration));
+		Saml2LogoutValidatorResult result = this.manager.validate(parameters);
+		assertThat(result.hasErrors()).isTrue();
+		assertThat(result.getErrors().iterator().next().getErrorCode()).isEqualTo(Saml2ErrorCodes.SUBJECT_NOT_FOUND);
+	}
+
+	@Test
+	public void handleWhenMismatchedDestinationThenInvalidDestinationError() {
+		RelyingPartyRegistration registration = registration().build();
+		LogoutRequest logoutRequest = TestOpenSamlObjects.assertingPartyLogoutRequest(registration);
+		logoutRequest.setDestination("wrong");
+		sign(logoutRequest, registration);
+		Saml2LogoutRequest request = post(logoutRequest, registration);
+		Saml2LogoutRequestValidatorParameters parameters = new Saml2LogoutRequestValidatorParameters(request,
+				registration, authentication(registration));
+		Saml2LogoutValidatorResult result = this.manager.validate(parameters);
+		assertThat(result.hasErrors()).isTrue();
+		assertThat(result.getErrors().iterator().next().getErrorCode()).isEqualTo(Saml2ErrorCodes.INVALID_DESTINATION);
+	}
+
+	private RelyingPartyRegistration.Builder registration() {
+		return signing(verifying(TestRelyingPartyRegistrations.noCredentials()))
+				.assertingPartyDetails((party) -> party.singleLogoutServiceBinding(Saml2MessageBinding.POST));
+	}
+
+	private RelyingPartyRegistration.Builder verifying(RelyingPartyRegistration.Builder builder) {
+		return builder.assertingPartyDetails((party) -> party
+				.verificationX509Credentials((c) -> c.add(TestSaml2X509Credentials.relyingPartyVerifyingCredential())));
+	}
+
+	private RelyingPartyRegistration.Builder signing(RelyingPartyRegistration.Builder builder) {
+		return builder.signingX509Credentials((c) -> c.add(TestSaml2X509Credentials.assertingPartySigningCredential()));
+	}
+
+	private Authentication authentication(RelyingPartyRegistration registration) {
+		DefaultSaml2AuthenticatedPrincipal principal = new DefaultSaml2AuthenticatedPrincipal("user", new HashMap<>());
+		principal.setRelyingPartyRegistrationId(registration.getRegistrationId());
+		return new Saml2Authentication(principal, "response", new ArrayList<>());
+	}
+
+	private Saml2LogoutRequest post(LogoutRequest logoutRequest, RelyingPartyRegistration registration) {
+		return Saml2LogoutRequest.withRelyingPartyRegistration(registration)
+				.samlRequest(Saml2Utils.samlEncode(serialize(logoutRequest).getBytes(StandardCharsets.UTF_8))).build();
+	}
+
+	private Saml2LogoutRequest redirect(LogoutRequest logoutRequest, RelyingPartyRegistration registration,
+			QueryParametersPartial partial) {
+		String serialized = Saml2Utils.samlEncode(Saml2Utils.samlDeflate(serialize(logoutRequest)));
+		Map<String, String> parameters = partial.param("SAMLRequest", serialized).parameters();
+		return Saml2LogoutRequest.withRelyingPartyRegistration(registration).samlRequest(serialized)
+				.parameters((params) -> params.putAll(parameters)).build();
+	}
+
+	private void sign(LogoutRequest logoutRequest, RelyingPartyRegistration registration) {
+		TestOpenSamlObjects.signed(logoutRequest, registration.getSigningX509Credentials().iterator().next(),
+				registration.getAssertingPartyDetails().getEntityId());
+	}
+
+	private String serialize(XMLObject object) {
+		return OpenSamlSigningUtils.serialize(object);
+	}
+
+}

+ 158 - 0
saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/authentication/logout/OpenSamlLogoutResponseValidatorTests.java

@@ -0,0 +1,158 @@
+/*
+ * Copyright 2002-2021 the original author or authors.
+ *
+ * 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
+ *
+ *      https://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 org.springframework.security.saml2.provider.service.authentication.logout;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Map;
+
+import org.junit.jupiter.api.Test;
+import org.opensaml.core.xml.XMLObject;
+import org.opensaml.saml.saml2.core.LogoutResponse;
+import org.opensaml.saml.saml2.core.StatusCode;
+
+import org.springframework.security.saml2.core.Saml2ErrorCodes;
+import org.springframework.security.saml2.core.TestSaml2X509Credentials;
+import org.springframework.security.saml2.provider.service.authentication.TestOpenSamlObjects;
+import org.springframework.security.saml2.provider.service.authentication.logout.OpenSamlSigningUtils.QueryParametersPartial;
+import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration;
+import org.springframework.security.saml2.provider.service.registration.Saml2MessageBinding;
+import org.springframework.security.saml2.provider.service.registration.TestRelyingPartyRegistrations;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link OpenSamlLogoutResponseValidator}
+ *
+ * @author Josh Cummings
+ */
+public class OpenSamlLogoutResponseValidatorTests {
+
+	private final OpenSamlLogoutResponseValidator manager = new OpenSamlLogoutResponseValidator();
+
+	@Test
+	public void handleWhenAuthenticatedThenHandles() {
+		RelyingPartyRegistration registration = signing(verifying(registration())).build();
+		Saml2LogoutRequest logoutRequest = Saml2LogoutRequest.withRelyingPartyRegistration(registration).id("id")
+				.build();
+		LogoutResponse logoutResponse = TestOpenSamlObjects.assertingPartyLogoutResponse(registration);
+		sign(logoutResponse, registration);
+		Saml2LogoutResponse response = post(logoutResponse, registration);
+		Saml2LogoutResponseValidatorParameters parameters = new Saml2LogoutResponseValidatorParameters(response,
+				logoutRequest, registration);
+		this.manager.validate(parameters);
+	}
+
+	@Test
+	public void handleWhenRedirectBindingThenValidatesSignatureParameter() {
+		RelyingPartyRegistration registration = signing(verifying(registration()))
+				.assertingPartyDetails((party) -> party.singleLogoutServiceBinding(Saml2MessageBinding.REDIRECT))
+				.build();
+		Saml2LogoutRequest logoutRequest = Saml2LogoutRequest.withRelyingPartyRegistration(registration).id("id")
+				.build();
+		LogoutResponse logoutResponse = TestOpenSamlObjects.assertingPartyLogoutResponse(registration);
+		Saml2LogoutResponse response = redirect(logoutResponse, registration, OpenSamlSigningUtils.sign(registration));
+		Saml2LogoutResponseValidatorParameters parameters = new Saml2LogoutResponseValidatorParameters(response,
+				logoutRequest, registration);
+		this.manager.validate(parameters);
+	}
+
+	@Test
+	public void handleWhenInvalidIssuerThenInvalidSignatureError() {
+		RelyingPartyRegistration registration = registration().build();
+		Saml2LogoutRequest logoutRequest = Saml2LogoutRequest.withRelyingPartyRegistration(registration).id("id")
+				.build();
+		LogoutResponse logoutResponse = TestOpenSamlObjects.assertingPartyLogoutResponse(registration);
+		logoutResponse.getIssuer().setValue("wrong");
+		sign(logoutResponse, registration);
+		Saml2LogoutResponse response = post(logoutResponse, registration);
+		Saml2LogoutResponseValidatorParameters parameters = new Saml2LogoutResponseValidatorParameters(response,
+				logoutRequest, registration);
+		Saml2LogoutValidatorResult result = this.manager.validate(parameters);
+		assertThat(result.hasErrors()).isTrue();
+		assertThat(result.getErrors().iterator().next().getErrorCode()).isEqualTo(Saml2ErrorCodes.INVALID_SIGNATURE);
+	}
+
+	@Test
+	public void handleWhenMismatchedDestinationThenInvalidDestinationError() {
+		RelyingPartyRegistration registration = registration().build();
+		Saml2LogoutRequest logoutRequest = Saml2LogoutRequest.withRelyingPartyRegistration(registration).id("id")
+				.build();
+		LogoutResponse logoutResponse = TestOpenSamlObjects.assertingPartyLogoutResponse(registration);
+		logoutResponse.setDestination("wrong");
+		sign(logoutResponse, registration);
+		Saml2LogoutResponse response = post(logoutResponse, registration);
+		Saml2LogoutResponseValidatorParameters parameters = new Saml2LogoutResponseValidatorParameters(response,
+				logoutRequest, registration);
+		Saml2LogoutValidatorResult result = this.manager.validate(parameters);
+		assertThat(result.hasErrors()).isTrue();
+		assertThat(result.getErrors().iterator().next().getErrorCode()).isEqualTo(Saml2ErrorCodes.INVALID_DESTINATION);
+	}
+
+	@Test
+	public void handleWhenStatusNotSuccessThenInvalidResponseError() {
+		RelyingPartyRegistration registration = registration().build();
+		Saml2LogoutRequest logoutRequest = Saml2LogoutRequest.withRelyingPartyRegistration(registration).id("id")
+				.build();
+		LogoutResponse logoutResponse = TestOpenSamlObjects.assertingPartyLogoutResponse(registration);
+		logoutResponse.getStatus().getStatusCode().setValue(StatusCode.UNKNOWN_PRINCIPAL);
+		sign(logoutResponse, registration);
+		Saml2LogoutResponse response = post(logoutResponse, registration);
+		Saml2LogoutResponseValidatorParameters parameters = new Saml2LogoutResponseValidatorParameters(response,
+				logoutRequest, registration);
+		Saml2LogoutValidatorResult result = this.manager.validate(parameters);
+		assertThat(result.hasErrors()).isTrue();
+		assertThat(result.getErrors().iterator().next().getErrorCode()).isEqualTo(Saml2ErrorCodes.INVALID_RESPONSE);
+	}
+
+	private RelyingPartyRegistration.Builder registration() {
+		return signing(verifying(TestRelyingPartyRegistrations.noCredentials()))
+				.assertingPartyDetails((party) -> party.singleLogoutServiceBinding(Saml2MessageBinding.POST));
+	}
+
+	private RelyingPartyRegistration.Builder verifying(RelyingPartyRegistration.Builder builder) {
+		return builder.assertingPartyDetails((party) -> party
+				.verificationX509Credentials((c) -> c.add(TestSaml2X509Credentials.relyingPartyVerifyingCredential())));
+	}
+
+	private RelyingPartyRegistration.Builder signing(RelyingPartyRegistration.Builder builder) {
+		return builder.signingX509Credentials((c) -> c.add(TestSaml2X509Credentials.assertingPartySigningCredential()));
+	}
+
+	private Saml2LogoutResponse post(LogoutResponse logoutResponse, RelyingPartyRegistration registration) {
+		return Saml2LogoutResponse.withRelyingPartyRegistration(registration)
+				.samlResponse(Saml2Utils.samlEncode(serialize(logoutResponse).getBytes(StandardCharsets.UTF_8)))
+				.build();
+	}
+
+	private Saml2LogoutResponse redirect(LogoutResponse logoutResponse, RelyingPartyRegistration registration,
+			QueryParametersPartial partial) {
+		String serialized = Saml2Utils.samlEncode(Saml2Utils.samlDeflate(serialize(logoutResponse)));
+		Map<String, String> parameters = partial.param("SAMLResponse", serialized).parameters();
+		return Saml2LogoutResponse.withRelyingPartyRegistration(registration).samlResponse(serialized)
+				.parameters((params) -> params.putAll(parameters)).build();
+	}
+
+	private void sign(LogoutResponse logoutResponse, RelyingPartyRegistration registration) {
+		TestOpenSamlObjects.signed(logoutResponse, registration.getSigningX509Credentials().iterator().next(),
+				registration.getAssertingPartyDetails().getEntityId());
+	}
+
+	private String serialize(XMLObject object) {
+		return OpenSamlSigningUtils.serialize(object);
+	}
+
+}

+ 173 - 0
saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/authentication/logout/OpenSamlSigningUtils.java

@@ -0,0 +1,173 @@
+/*
+ * Copyright 2002-2021 the original author or authors.
+ *
+ * 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
+ *
+ *      https://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 org.springframework.security.saml2.provider.service.authentication.logout;
+
+import java.nio.charset.StandardCharsets;
+import java.security.PrivateKey;
+import java.security.cert.X509Certificate;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import net.shibboleth.utilities.java.support.resolver.CriteriaSet;
+import net.shibboleth.utilities.java.support.xml.SerializeSupport;
+import org.opensaml.core.xml.XMLObject;
+import org.opensaml.core.xml.config.XMLObjectProviderRegistrySupport;
+import org.opensaml.core.xml.io.Marshaller;
+import org.opensaml.core.xml.io.MarshallingException;
+import org.opensaml.saml.security.impl.SAMLMetadataSignatureSigningParametersResolver;
+import org.opensaml.security.SecurityException;
+import org.opensaml.security.credential.BasicCredential;
+import org.opensaml.security.credential.Credential;
+import org.opensaml.security.credential.CredentialSupport;
+import org.opensaml.security.credential.UsageType;
+import org.opensaml.xmlsec.SignatureSigningParameters;
+import org.opensaml.xmlsec.SignatureSigningParametersResolver;
+import org.opensaml.xmlsec.criterion.SignatureSigningConfigurationCriterion;
+import org.opensaml.xmlsec.crypto.XMLSigningUtil;
+import org.opensaml.xmlsec.impl.BasicSignatureSigningConfiguration;
+import org.opensaml.xmlsec.signature.SignableXMLObject;
+import org.opensaml.xmlsec.signature.support.SignatureConstants;
+import org.opensaml.xmlsec.signature.support.SignatureSupport;
+import org.w3c.dom.Element;
+
+import org.springframework.security.saml2.Saml2Exception;
+import org.springframework.security.saml2.core.Saml2X509Credential;
+import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration;
+import org.springframework.util.Assert;
+import org.springframework.web.util.UriComponentsBuilder;
+import org.springframework.web.util.UriUtils;
+
+/**
+ * Utility methods for signing SAML components with OpenSAML
+ *
+ * For internal use only.
+ *
+ * @author Josh Cummings
+ */
+final class OpenSamlSigningUtils {
+
+	static String serialize(XMLObject object) {
+		try {
+			Marshaller marshaller = XMLObjectProviderRegistrySupport.getMarshallerFactory().getMarshaller(object);
+			Element element = marshaller.marshall(object);
+			return SerializeSupport.nodeToString(element);
+		}
+		catch (MarshallingException ex) {
+			throw new Saml2Exception(ex);
+		}
+	}
+
+	static <O extends SignableXMLObject> O sign(O object, RelyingPartyRegistration relyingPartyRegistration) {
+		SignatureSigningParameters parameters = resolveSigningParameters(relyingPartyRegistration);
+		try {
+			SignatureSupport.signObject(object, parameters);
+			return object;
+		}
+		catch (Exception ex) {
+			throw new Saml2Exception(ex);
+		}
+	}
+
+	static QueryParametersPartial sign(RelyingPartyRegistration registration) {
+		return new QueryParametersPartial(registration);
+	}
+
+	private static SignatureSigningParameters resolveSigningParameters(
+			RelyingPartyRegistration relyingPartyRegistration) {
+		List<Credential> credentials = resolveSigningCredentials(relyingPartyRegistration);
+		List<String> algorithms = relyingPartyRegistration.getAssertingPartyDetails().getSigningAlgorithms();
+		List<String> digests = Collections.singletonList(SignatureConstants.ALGO_ID_DIGEST_SHA256);
+		String canonicalization = SignatureConstants.ALGO_ID_C14N_EXCL_OMIT_COMMENTS;
+		SignatureSigningParametersResolver resolver = new SAMLMetadataSignatureSigningParametersResolver();
+		CriteriaSet criteria = new CriteriaSet();
+		BasicSignatureSigningConfiguration signingConfiguration = new BasicSignatureSigningConfiguration();
+		signingConfiguration.setSigningCredentials(credentials);
+		signingConfiguration.setSignatureAlgorithms(algorithms);
+		signingConfiguration.setSignatureReferenceDigestMethods(digests);
+		signingConfiguration.setSignatureCanonicalizationAlgorithm(canonicalization);
+		criteria.add(new SignatureSigningConfigurationCriterion(signingConfiguration));
+		try {
+			SignatureSigningParameters parameters = resolver.resolveSingle(criteria);
+			Assert.notNull(parameters, "Failed to resolve any signing credential");
+			return parameters;
+		}
+		catch (Exception ex) {
+			throw new Saml2Exception(ex);
+		}
+	}
+
+	private static List<Credential> resolveSigningCredentials(RelyingPartyRegistration relyingPartyRegistration) {
+		List<Credential> credentials = new ArrayList<>();
+		for (Saml2X509Credential x509Credential : relyingPartyRegistration.getSigningX509Credentials()) {
+			X509Certificate certificate = x509Credential.getCertificate();
+			PrivateKey privateKey = x509Credential.getPrivateKey();
+			BasicCredential credential = CredentialSupport.getSimpleCredential(certificate, privateKey);
+			credential.setEntityId(relyingPartyRegistration.getEntityId());
+			credential.setUsageType(UsageType.SIGNING);
+			credentials.add(credential);
+		}
+		return credentials;
+	}
+
+	static class QueryParametersPartial {
+
+		final RelyingPartyRegistration registration;
+
+		final Map<String, String> components = new LinkedHashMap<>();
+
+		QueryParametersPartial(RelyingPartyRegistration registration) {
+			this.registration = registration;
+		}
+
+		QueryParametersPartial param(String key, String value) {
+			this.components.put(key, value);
+			return this;
+		}
+
+		Map<String, String> parameters() {
+			SignatureSigningParameters parameters = resolveSigningParameters(this.registration);
+			Credential credential = parameters.getSigningCredential();
+			String algorithmUri = parameters.getSignatureAlgorithm();
+			this.components.put("SigAlg", algorithmUri);
+			UriComponentsBuilder builder = UriComponentsBuilder.newInstance();
+			for (Map.Entry<String, String> component : this.components.entrySet()) {
+				builder.queryParam(component.getKey(),
+						UriUtils.encode(component.getValue(), StandardCharsets.ISO_8859_1));
+			}
+			String queryString = builder.build(true).toString().substring(1);
+			try {
+				byte[] rawSignature = XMLSigningUtil.signWithURI(credential, algorithmUri,
+						queryString.getBytes(StandardCharsets.UTF_8));
+				String b64Signature = Saml2Utils.samlEncode(rawSignature);
+				this.components.put("Signature", b64Signature);
+			}
+			catch (SecurityException ex) {
+				throw new Saml2Exception(ex);
+			}
+			return this.components;
+		}
+
+	}
+
+	private OpenSamlSigningUtils() {
+
+	}
+
+}

+ 4 - 2
saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/metadata/OpenSamlMetadataResolverTests.java

@@ -41,7 +41,8 @@ public class OpenSamlMetadataResolverTests {
 				.contains("<md:KeyDescriptor use=\"encryption\">")
 				.contains("<ds:X509Certificate>MIICgTCCAeoCCQCuVzyqFgMSyDANBgkqhkiG9w0BAQsFADCBhDELMAkGA1UEBh")
 				.contains("Binding=\"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect\"")
-				.contains("Location=\"https://rp.example.org/acs\" index=\"1\"");
+				.contains("Location=\"https://rp.example.org/acs\" index=\"1\"")
+				.contains("ResponseLocation=\"https://rp.example.org/logout/saml2/response\"");
 	}
 
 	@Test
@@ -56,7 +57,8 @@ public class OpenSamlMetadataResolverTests {
 				.contains("WantAssertionsSigned=\"true\"").doesNotContain("<md:KeyDescriptor use=\"signing\">")
 				.doesNotContain("<md:KeyDescriptor use=\"encryption\">")
 				.contains("Binding=\"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST\"")
-				.contains("Location=\"https://rp.example.org/acs\" index=\"1\"");
+				.contains("Location=\"https://rp.example.org/acs\" index=\"1\"")
+				.contains("ResponseLocation=\"https://rp.example.org/logout/saml2/response\"");
 	}
 
 }

+ 9 - 3
saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/registration/TestRelyingPartyRegistrations.java

@@ -37,17 +37,23 @@ public final class TestRelyingPartyRegistrations {
 		String apEntityId = "https://simplesaml-for-spring-saml.apps.pcfone.io/saml2/idp/metadata.php";
 		Saml2X509Credential verificationCertificate = TestSaml2X509Credentials.relyingPartyVerifyingCredential();
 		String singleSignOnServiceLocation = "https://simplesaml-for-spring-saml.apps.pcfone.io/saml2/idp/SSOService.php";
+		String singleLogoutServiceLocation = "{baseUrl}/logout/saml2/slo";
 		return RelyingPartyRegistration.withRegistrationId(registrationId).entityId(rpEntityId)
 				.assertionConsumerServiceLocation(assertionConsumerServiceLocation)
-				.credentials((c) -> c.add(signingCredential))
+				.singleLogoutServiceLocation(singleLogoutServiceLocation).credentials((c) -> c.add(signingCredential))
 				.providerDetails((c) -> c.entityId(apEntityId).webSsoUrl(singleSignOnServiceLocation))
 				.credentials((c) -> c.add(verificationCertificate));
 	}
 
 	public static RelyingPartyRegistration.Builder noCredentials() {
 		return RelyingPartyRegistration.withRegistrationId("registration-id").entityId("rp-entity-id")
-				.assertionConsumerServiceLocation("https://rp.example.org/acs").assertingPartyDetails((party) -> party
-						.entityId("ap-entity-id").singleSignOnServiceLocation("https://ap.example.org/sso"));
+				.singleLogoutServiceLocation("https://rp.example.org/logout/saml2/request")
+				.singleLogoutServiceResponseLocation("https://rp.example.org/logout/saml2/response")
+				.assertionConsumerServiceLocation("https://rp.example.org/acs")
+				.assertingPartyDetails((party) -> party.entityId("ap-entity-id")
+						.singleSignOnServiceLocation("https://ap.example.org/sso")
+						.singleLogoutServiceLocation("https://ap.example.org/logout/saml2/request")
+						.singleLogoutServiceResponseLocation("https://ap.example.org/logout/saml2/response"));
 	}
 
 	public static RelyingPartyRegistration.Builder full() {

+ 3 - 0
saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/web/DefaultRelyingPartyRegistrationResolverTests.java

@@ -52,6 +52,9 @@ public class DefaultRelyingPartyRegistrationResolverTests {
 				.isEqualTo("http://localhost/saml2/service-provider-metadata/" + this.registration.getRegistrationId());
 		assertThat(registration.getAssertionConsumerServiceLocation())
 				.isEqualTo("http://localhost/login/saml2/sso/" + this.registration.getRegistrationId());
+		assertThat(registration.getSingleLogoutServiceLocation()).isEqualTo("http://localhost/logout/saml2/slo");
+		assertThat(registration.getSingleLogoutServiceResponseLocation())
+				.isEqualTo("http://localhost/logout/saml2/slo");
 	}
 
 	@Test

+ 229 - 0
saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/HttpSessionLogoutRequestRepositoryTests.java

@@ -0,0 +1,229 @@
+/*
+ * Copyright 2002-2021 the original author or authors.
+ *
+ * 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
+ *
+ *      https://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 org.springframework.security.saml2.provider.service.web.authentication.logout;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+import org.springframework.mock.web.MockHttpSession;
+import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutRequest;
+import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration;
+import org.springframework.security.saml2.provider.service.registration.TestRelyingPartyRegistrations;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+
+/**
+ * Tests for {@link HttpSessionLogoutRequestRepository}
+ */
+public class HttpSessionLogoutRequestRepositoryTests {
+
+	HttpSessionLogoutRequestRepository logoutRequestRepository = new HttpSessionLogoutRequestRepository();
+
+	@Test
+	public void loadLogoutRequestWhenHttpServletRequestIsNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException().isThrownBy(() -> this.logoutRequestRepository.loadLogoutRequest(null));
+	}
+
+	@Test
+	public void loadLogoutRequestWhenNotSavedThenReturnNull() {
+		MockHttpServletRequest request = new MockHttpServletRequest();
+		request.addParameter("RelayState", "state-1234");
+		Saml2LogoutRequest logoutRequest = this.logoutRequestRepository.loadLogoutRequest(request);
+		assertThat(logoutRequest).isNull();
+	}
+
+	@Test
+	public void loadLogoutRequestWhenSavedThenReturnLogoutRequest() {
+		MockHttpServletRequest request = new MockHttpServletRequest();
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		Saml2LogoutRequest logoutRequest = createLogoutRequest().build();
+		this.logoutRequestRepository.saveLogoutRequest(logoutRequest, request, response);
+		request.addParameter("RelayState", logoutRequest.getRelayState());
+		Saml2LogoutRequest loadedLogoutRequest = this.logoutRequestRepository.loadLogoutRequest(request);
+		assertThat(loadedLogoutRequest).isEqualTo(logoutRequest);
+	}
+
+	@Test
+	public void loadLogoutRequestWhenMultipleSavedThenReplacesLogoutRequest() {
+		MockHttpServletRequest request = new MockHttpServletRequest();
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		Saml2LogoutRequest one = createLogoutRequest().relayState("state-1122").build();
+		this.logoutRequestRepository.saveLogoutRequest(one, request, response);
+		Saml2LogoutRequest two = createLogoutRequest().relayState("state-3344").build();
+		this.logoutRequestRepository.saveLogoutRequest(two, request, response);
+		request.setParameter("RelayState", one.getRelayState());
+		assertThat(this.logoutRequestRepository.loadLogoutRequest(request)).isNull();
+		request.setParameter("RelayState", two.getRelayState());
+		assertThat(this.logoutRequestRepository.loadLogoutRequest(request)).isEqualTo(two);
+	}
+
+	@Test
+	public void loadLogoutRequestWhenSavedAndStateParameterNullThenReturnNull() {
+		MockHttpServletRequest request = new MockHttpServletRequest();
+		Saml2LogoutRequest logoutRequest = createLogoutRequest().build();
+		this.logoutRequestRepository.saveLogoutRequest(logoutRequest, request, new MockHttpServletResponse());
+		assertThat(this.logoutRequestRepository.loadLogoutRequest(request)).isNull();
+	}
+
+	@Test
+	public void saveLogoutRequestWhenHttpServletRequestIsNullThenThrowIllegalArgumentException() {
+		Saml2LogoutRequest logoutRequest = createLogoutRequest().build();
+		assertThatIllegalArgumentException().isThrownBy(() -> this.logoutRequestRepository
+				.saveLogoutRequest(logoutRequest, null, new MockHttpServletResponse()));
+	}
+
+	@Test
+	public void saveLogoutRequestWhenHttpServletResponseIsNullThenThrowIllegalArgumentException() {
+		Saml2LogoutRequest logoutRequest = createLogoutRequest().build();
+		assertThatIllegalArgumentException().isThrownBy(() -> this.logoutRequestRepository
+				.saveLogoutRequest(logoutRequest, new MockHttpServletRequest(), null));
+	}
+
+	@Test
+	public void saveLogoutRequestWhenStateNullThenThrowIllegalArgumentException() {
+		Saml2LogoutRequest logoutRequest = createLogoutRequest().relayState(null).build();
+		assertThatIllegalArgumentException().isThrownBy(() -> this.logoutRequestRepository
+				.saveLogoutRequest(logoutRequest, new MockHttpServletRequest(), new MockHttpServletResponse()));
+	}
+
+	@Test
+	public void saveLogoutRequestWhenNotNullThenSaved() {
+		MockHttpServletRequest request = new MockHttpServletRequest();
+		Saml2LogoutRequest logoutRequest = createLogoutRequest().build();
+		this.logoutRequestRepository.saveLogoutRequest(logoutRequest, request, new MockHttpServletResponse());
+		request.addParameter("RelayState", logoutRequest.getRelayState());
+		Saml2LogoutRequest loadedLogoutRequest = this.logoutRequestRepository.loadLogoutRequest(request);
+		assertThat(loadedLogoutRequest).isEqualTo(logoutRequest);
+	}
+
+	@Test
+	public void saveLogoutRequestWhenNoExistingSessionAndDistributedSessionThenSaved() {
+		MockHttpServletRequest request = new MockHttpServletRequest();
+		request.setSession(new MockDistributedHttpSession());
+		Saml2LogoutRequest logoutRequest = createLogoutRequest().build();
+		this.logoutRequestRepository.saveLogoutRequest(logoutRequest, request, new MockHttpServletResponse());
+		request.addParameter("RelayState", logoutRequest.getRelayState());
+		Saml2LogoutRequest loadedLogoutRequest = this.logoutRequestRepository.loadLogoutRequest(request);
+		assertThat(loadedLogoutRequest).isEqualTo(logoutRequest);
+	}
+
+	@Test
+	public void saveLogoutRequestWhenExistingSessionAndDistributedSessionThenSaved() {
+		MockHttpServletRequest request = new MockHttpServletRequest();
+		request.setSession(new MockDistributedHttpSession());
+		Saml2LogoutRequest logoutRequest1 = createLogoutRequest().build();
+		this.logoutRequestRepository.saveLogoutRequest(logoutRequest1, request, new MockHttpServletResponse());
+		Saml2LogoutRequest logoutRequest2 = createLogoutRequest().build();
+		this.logoutRequestRepository.saveLogoutRequest(logoutRequest2, request, new MockHttpServletResponse());
+		request.addParameter("RelayState", logoutRequest2.getRelayState());
+		Saml2LogoutRequest loadedLogoutRequest = this.logoutRequestRepository.loadLogoutRequest(request);
+		assertThat(loadedLogoutRequest).isEqualTo(logoutRequest2);
+	}
+
+	@Test
+	public void saveLogoutRequestWhenNullThenRemoved() {
+		MockHttpServletRequest request = new MockHttpServletRequest();
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		Saml2LogoutRequest logoutRequest = createLogoutRequest().build();
+		this.logoutRequestRepository.saveLogoutRequest(logoutRequest, request, response);
+		request.addParameter("RelayState", logoutRequest.getRelayState());
+		this.logoutRequestRepository.saveLogoutRequest(null, request, response);
+		Saml2LogoutRequest loadedLogoutRequest = this.logoutRequestRepository.loadLogoutRequest(request);
+		assertThat(loadedLogoutRequest).isNull();
+	}
+
+	@Test
+	public void removeLogoutRequestWhenHttpServletRequestIsNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException().isThrownBy(
+				() -> this.logoutRequestRepository.removeLogoutRequest(null, new MockHttpServletResponse()));
+	}
+
+	@Test
+	public void removeLogoutRequestWhenHttpServletResponseIsNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> this.logoutRequestRepository.removeLogoutRequest(new MockHttpServletRequest(), null));
+	}
+
+	@Test
+	public void removeLogoutRequestWhenSavedThenRemoved() {
+		MockHttpServletRequest request = new MockHttpServletRequest();
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		Saml2LogoutRequest logoutRequest = createLogoutRequest().build();
+		this.logoutRequestRepository.saveLogoutRequest(logoutRequest, request, response);
+		request.addParameter("RelayState", logoutRequest.getRelayState());
+		Saml2LogoutRequest removedLogoutRequest = this.logoutRequestRepository.removeLogoutRequest(request, response);
+		Saml2LogoutRequest loadedLogoutRequest = this.logoutRequestRepository.loadLogoutRequest(request);
+		assertThat(removedLogoutRequest).isNotNull();
+		assertThat(loadedLogoutRequest).isNull();
+	}
+
+	// gh-5263
+	@Test
+	public void removeLogoutRequestWhenSavedThenRemovedFromSession() {
+		MockHttpServletRequest request = new MockHttpServletRequest();
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		Saml2LogoutRequest logoutRequest = createLogoutRequest().build();
+		this.logoutRequestRepository.saveLogoutRequest(logoutRequest, request, response);
+		request.addParameter("RelayState", logoutRequest.getRelayState());
+		Saml2LogoutRequest removedLogoutRequest = this.logoutRequestRepository.removeLogoutRequest(request, response);
+		String sessionAttributeName = HttpSessionLogoutRequestRepository.class.getName() + ".AUTHORIZATION_REQUEST";
+		assertThat(removedLogoutRequest).isNotNull();
+		assertThat(request.getSession().getAttribute(sessionAttributeName)).isNull();
+	}
+
+	@Test
+	public void removeLogoutRequestWhenNotSavedThenNotRemoved() {
+		MockHttpServletRequest request = new MockHttpServletRequest();
+		request.addParameter("RelayState", "state-1234");
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		Saml2LogoutRequest removedLogoutRequest = this.logoutRequestRepository.removeLogoutRequest(request, response);
+		assertThat(removedLogoutRequest).isNull();
+	}
+
+	private Saml2LogoutRequest.Builder createLogoutRequest() {
+		RelyingPartyRegistration registration = TestRelyingPartyRegistrations.full().build();
+		return Saml2LogoutRequest.withRelyingPartyRegistration(registration).samlRequest("request").id("id")
+				.parameters((params) -> params.put("RelayState", "state-1234"));
+	}
+
+	static class MockDistributedHttpSession extends MockHttpSession {
+
+		@Override
+		public Object getAttribute(String name) {
+			return wrap(super.getAttribute(name));
+		}
+
+		@Override
+		public void setAttribute(String name, Object value) {
+			super.setAttribute(name, wrap(value));
+		}
+
+		private Object wrap(Object object) {
+			if (object instanceof Map) {
+				object = new HashMap<>((Map<Object, Object>) object);
+			}
+			return object;
+		}
+
+	}
+
+}

+ 115 - 0
saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSamlLogoutRequestResolverTests.java

@@ -0,0 +1,115 @@
+/*
+ * Copyright 2002-2021 the original author or authors.
+ *
+ * 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
+ *
+ *      https://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 org.springframework.security.saml2.provider.service.web.authentication.logout;
+
+import java.io.ByteArrayInputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.HashMap;
+
+import javax.servlet.http.HttpServletRequest;
+
+import org.junit.jupiter.api.Test;
+import org.opensaml.core.xml.config.XMLObjectProviderRegistrySupport;
+import org.opensaml.saml.saml2.core.LogoutRequest;
+import org.w3c.dom.Document;
+import org.w3c.dom.Element;
+
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.security.saml2.Saml2Exception;
+import org.springframework.security.saml2.provider.service.authentication.DefaultSaml2AuthenticatedPrincipal;
+import org.springframework.security.saml2.provider.service.authentication.Saml2Authentication;
+import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutRequest;
+import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration;
+import org.springframework.security.saml2.provider.service.registration.Saml2MessageBinding;
+import org.springframework.security.saml2.provider.service.registration.TestRelyingPartyRegistrations;
+import org.springframework.security.saml2.provider.service.web.RelyingPartyRegistrationResolver;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+
+/**
+ * Tests for {@link OpenSamlLogoutRequestResolver}
+ *
+ * @author Josh Cummings
+ */
+public class OpenSamlLogoutRequestResolverTests {
+
+	RelyingPartyRegistrationResolver relyingPartyRegistrationResolver = mock(RelyingPartyRegistrationResolver.class);
+
+	OpenSamlLogoutRequestResolver logoutRequestResolver = new OpenSamlLogoutRequestResolver(
+			this.relyingPartyRegistrationResolver);
+
+	@Test
+	public void resolveRedirectWhenAuthenticatedThenIncludesName() {
+		RelyingPartyRegistration registration = TestRelyingPartyRegistrations.full().build();
+		Saml2Authentication authentication = authentication(registration);
+		HttpServletRequest request = new MockHttpServletRequest();
+		given(this.relyingPartyRegistrationResolver.resolve(any(), any())).willReturn(registration);
+		Saml2LogoutRequest saml2LogoutRequest = this.logoutRequestResolver.resolve(request, authentication);
+		assertThat(saml2LogoutRequest.getParameter("SigAlg")).isNotNull();
+		assertThat(saml2LogoutRequest.getParameter("Signature")).isNotNull();
+		assertThat(saml2LogoutRequest.getParameter("RelayState")).isNotNull();
+		Saml2MessageBinding binding = registration.getAssertingPartyDetails().getSingleLogoutServiceBinding();
+		LogoutRequest logoutRequest = getLogoutRequest(saml2LogoutRequest.getSamlRequest(), binding);
+		assertThat(logoutRequest.getNameID().getValue()).isEqualTo(authentication.getName());
+	}
+
+	@Test
+	public void resolvePostWhenAuthenticatedThenIncludesName() {
+		RelyingPartyRegistration registration = TestRelyingPartyRegistrations.full()
+				.assertingPartyDetails((party) -> party.singleLogoutServiceBinding(Saml2MessageBinding.POST)).build();
+		Saml2Authentication authentication = authentication(registration);
+		HttpServletRequest request = new MockHttpServletRequest();
+		given(this.relyingPartyRegistrationResolver.resolve(any(), any())).willReturn(registration);
+		Saml2LogoutRequest saml2LogoutRequest = this.logoutRequestResolver.resolve(request, authentication);
+		assertThat(saml2LogoutRequest.getParameter("SigAlg")).isNull();
+		assertThat(saml2LogoutRequest.getParameter("Signature")).isNull();
+		assertThat(saml2LogoutRequest.getParameter("RelayState")).isNotNull();
+		Saml2MessageBinding binding = registration.getAssertingPartyDetails().getSingleLogoutServiceBinding();
+		LogoutRequest logoutRequest = getLogoutRequest(saml2LogoutRequest.getSamlRequest(), binding);
+		assertThat(logoutRequest.getNameID().getValue()).isEqualTo(authentication.getName());
+	}
+
+	private Saml2Authentication authentication(RelyingPartyRegistration registration) {
+		DefaultSaml2AuthenticatedPrincipal principal = new DefaultSaml2AuthenticatedPrincipal("user", new HashMap<>());
+		principal.setRelyingPartyRegistrationId(registration.getRegistrationId());
+		return new Saml2Authentication(principal, "response", new ArrayList<>());
+	}
+
+	private LogoutRequest getLogoutRequest(String samlRequest, Saml2MessageBinding binding) {
+		if (binding == Saml2MessageBinding.REDIRECT) {
+			samlRequest = Saml2Utils.samlInflate(Saml2Utils.samlDecode(samlRequest));
+		}
+		else {
+			samlRequest = new String(Saml2Utils.samlDecode(samlRequest), StandardCharsets.UTF_8);
+		}
+		try {
+			Document document = XMLObjectProviderRegistrySupport.getParserPool()
+					.parse(new ByteArrayInputStream(samlRequest.getBytes(StandardCharsets.UTF_8)));
+			Element element = document.getDocumentElement();
+			return (LogoutRequest) XMLObjectProviderRegistrySupport.getUnmarshallerFactory().getUnmarshaller(element)
+					.unmarshall(element);
+		}
+		catch (Exception ex) {
+			throw new Saml2Exception(ex);
+		}
+	}
+
+}

+ 125 - 0
saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSamlLogoutResponseResolverTests.java

@@ -0,0 +1,125 @@
+/*
+ * Copyright 2002-2021 the original author or authors.
+ *
+ * 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
+ *
+ *      https://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 org.springframework.security.saml2.provider.service.web.authentication.logout;
+
+import java.io.ByteArrayInputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.HashMap;
+
+import org.junit.jupiter.api.Test;
+import org.opensaml.core.xml.config.XMLObjectProviderRegistrySupport;
+import org.opensaml.saml.saml2.core.LogoutRequest;
+import org.opensaml.saml.saml2.core.LogoutResponse;
+import org.opensaml.saml.saml2.core.StatusCode;
+import org.w3c.dom.Document;
+import org.w3c.dom.Element;
+
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.saml2.Saml2Exception;
+import org.springframework.security.saml2.provider.service.authentication.DefaultSaml2AuthenticatedPrincipal;
+import org.springframework.security.saml2.provider.service.authentication.Saml2Authentication;
+import org.springframework.security.saml2.provider.service.authentication.TestOpenSamlObjects;
+import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutResponse;
+import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration;
+import org.springframework.security.saml2.provider.service.registration.Saml2MessageBinding;
+import org.springframework.security.saml2.provider.service.registration.TestRelyingPartyRegistrations;
+import org.springframework.security.saml2.provider.service.web.RelyingPartyRegistrationResolver;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+
+/**
+ * Tests for {@link OpenSamlLogoutResponseResolver}
+ *
+ * @author Josh Cummings
+ */
+public class OpenSamlLogoutResponseResolverTests {
+
+	RelyingPartyRegistrationResolver relyingPartyRegistrationResolver = mock(RelyingPartyRegistrationResolver.class);
+
+	OpenSamlLogoutResponseResolver logoutResponseResolver = new OpenSamlLogoutResponseResolver(
+			this.relyingPartyRegistrationResolver);
+
+	@Test
+	public void resolveRedirectWhenAuthenticatedThenSuccess() {
+		RelyingPartyRegistration registration = TestRelyingPartyRegistrations.full().build();
+		MockHttpServletRequest request = new MockHttpServletRequest();
+		LogoutRequest logoutRequest = TestOpenSamlObjects.assertingPartyLogoutRequest(registration);
+		request.setParameter("SAMLRequest",
+				Saml2Utils.samlEncode(OpenSamlSigningUtils.serialize(logoutRequest).getBytes()));
+		request.setParameter("RelayState", "abcd");
+		Authentication authentication = authentication(registration);
+		given(this.relyingPartyRegistrationResolver.resolve(any(), any())).willReturn(registration);
+		Saml2LogoutResponse saml2LogoutResponse = this.logoutResponseResolver.resolve(request, authentication);
+		assertThat(saml2LogoutResponse.getParameter("SigAlg")).isNotNull();
+		assertThat(saml2LogoutResponse.getParameter("Signature")).isNotNull();
+		assertThat(saml2LogoutResponse.getParameter("RelayState")).isSameAs("abcd");
+		Saml2MessageBinding binding = registration.getAssertingPartyDetails().getSingleLogoutServiceBinding();
+		LogoutResponse logoutResponse = getLogoutResponse(saml2LogoutResponse.getSamlResponse(), binding);
+		assertThat(logoutResponse.getStatus().getStatusCode().getValue()).isEqualTo(StatusCode.SUCCESS);
+	}
+
+	@Test
+	public void resolvePostWhenAuthenticatedThenSuccess() {
+		RelyingPartyRegistration registration = TestRelyingPartyRegistrations.full()
+				.assertingPartyDetails((party) -> party.singleLogoutServiceBinding(Saml2MessageBinding.POST)).build();
+		MockHttpServletRequest request = new MockHttpServletRequest();
+		LogoutRequest logoutRequest = TestOpenSamlObjects.assertingPartyLogoutRequest(registration);
+		request.setParameter("SAMLRequest",
+				Saml2Utils.samlEncode(OpenSamlSigningUtils.serialize(logoutRequest).getBytes()));
+		request.setParameter("RelayState", "abcd");
+		Authentication authentication = authentication(registration);
+		given(this.relyingPartyRegistrationResolver.resolve(any(), any())).willReturn(registration);
+		Saml2LogoutResponse saml2LogoutResponse = this.logoutResponseResolver.resolve(request, authentication);
+		assertThat(saml2LogoutResponse.getParameter("SigAlg")).isNull();
+		assertThat(saml2LogoutResponse.getParameter("Signature")).isNull();
+		assertThat(saml2LogoutResponse.getParameter("RelayState")).isSameAs("abcd");
+		Saml2MessageBinding binding = registration.getAssertingPartyDetails().getSingleLogoutServiceBinding();
+		LogoutResponse logoutResponse = getLogoutResponse(saml2LogoutResponse.getSamlResponse(), binding);
+		assertThat(logoutResponse.getStatus().getStatusCode().getValue()).isEqualTo(StatusCode.SUCCESS);
+	}
+
+	private Saml2Authentication authentication(RelyingPartyRegistration registration) {
+		DefaultSaml2AuthenticatedPrincipal principal = new DefaultSaml2AuthenticatedPrincipal("user", new HashMap<>());
+		principal.setRelyingPartyRegistrationId(registration.getRegistrationId());
+		return new Saml2Authentication(principal, "response", new ArrayList<>());
+	}
+
+	private LogoutResponse getLogoutResponse(String saml2Response, Saml2MessageBinding binding) {
+		if (binding == Saml2MessageBinding.REDIRECT) {
+			saml2Response = Saml2Utils.samlInflate(Saml2Utils.samlDecode(saml2Response));
+		}
+		else {
+			saml2Response = new String(Saml2Utils.samlDecode(saml2Response), StandardCharsets.UTF_8);
+		}
+		try {
+			Document document = XMLObjectProviderRegistrySupport.getParserPool()
+					.parse(new ByteArrayInputStream(saml2Response.getBytes(StandardCharsets.UTF_8)));
+			Element element = document.getDocumentElement();
+			return (LogoutResponse) XMLObjectProviderRegistrySupport.getUnmarshallerFactory().getUnmarshaller(element)
+					.unmarshall(element);
+		}
+		catch (Exception ex) {
+			throw new Saml2Exception(ex);
+		}
+	}
+
+}

+ 155 - 0
saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutRequestFilterTests.java

@@ -0,0 +1,155 @@
+/*
+ * Copyright 2002-2021 the original author or authors.
+ *
+ * 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
+ *
+ *      https://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 org.springframework.security.saml2.provider.service.web.authentication.logout;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.mock.web.MockFilterChain;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+import org.springframework.security.authentication.TestingAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.saml2.core.Saml2Error;
+import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutRequestValidator;
+import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutResponse;
+import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutValidatorResult;
+import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration;
+import org.springframework.security.saml2.provider.service.registration.Saml2MessageBinding;
+import org.springframework.security.saml2.provider.service.registration.TestRelyingPartyRegistrations;
+import org.springframework.security.saml2.provider.service.web.RelyingPartyRegistrationResolver;
+import org.springframework.security.web.authentication.logout.LogoutHandler;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.BDDMockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+
+/**
+ * Tests for {@link Saml2LogoutRequestFilter}
+ */
+public class Saml2LogoutRequestFilterTests {
+
+	RelyingPartyRegistrationResolver relyingPartyRegistrationResolver = mock(RelyingPartyRegistrationResolver.class);
+
+	Saml2LogoutRequestValidator logoutRequestValidator = mock(Saml2LogoutRequestValidator.class);
+
+	LogoutHandler logoutHandler = mock(LogoutHandler.class);
+
+	Saml2LogoutResponseResolver logoutResponseResolver = mock(Saml2LogoutResponseResolver.class);
+
+	Saml2LogoutRequestFilter logoutRequestProcessingFilter = new Saml2LogoutRequestFilter(
+			this.relyingPartyRegistrationResolver, this.logoutRequestValidator, this.logoutResponseResolver,
+			this.logoutHandler);
+
+	@AfterEach
+	public void tearDown() {
+		SecurityContextHolder.clearContext();
+	}
+
+	@Test
+	public void doFilterWhenSamlRequestThenRedirects() throws Exception {
+		RelyingPartyRegistration registration = TestRelyingPartyRegistrations.full().build();
+		Authentication authentication = new TestingAuthenticationToken("user", "password");
+		SecurityContextHolder.getContext().setAuthentication(authentication);
+		MockHttpServletRequest request = new MockHttpServletRequest("POST", "/logout/saml2/slo");
+		request.setServletPath("/logout/saml2/slo");
+		request.setParameter("SAMLRequest", "request");
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		given(this.relyingPartyRegistrationResolver.resolve(any(), any())).willReturn(registration);
+		given(this.logoutRequestValidator.validate(any())).willReturn(Saml2LogoutValidatorResult.success());
+		Saml2LogoutResponse logoutResponse = Saml2LogoutResponse.withRelyingPartyRegistration(registration)
+				.samlResponse("response").build();
+		given(this.logoutResponseResolver.resolve(any(), any())).willReturn(logoutResponse);
+		this.logoutRequestProcessingFilter.doFilterInternal(request, response, new MockFilterChain());
+		verify(this.logoutRequestValidator).validate(any());
+		verify(this.logoutHandler).logout(any(), any(), any());
+		verify(this.logoutResponseResolver).resolve(any(), any());
+		String content = response.getHeader("Location");
+		assertThat(content).contains("SAMLResponse");
+		assertThat(content)
+				.startsWith(registration.getAssertingPartyDetails().getSingleLogoutServiceResponseLocation());
+	}
+
+	@Test
+	public void doFilterWhenSamlRequestThenPosts() throws Exception {
+		RelyingPartyRegistration registration = TestRelyingPartyRegistrations.full()
+				.assertingPartyDetails((party) -> party.singleLogoutServiceBinding(Saml2MessageBinding.POST)).build();
+		Authentication authentication = new TestingAuthenticationToken("user", "password");
+		SecurityContextHolder.getContext().setAuthentication(authentication);
+		MockHttpServletRequest request = new MockHttpServletRequest("POST", "/logout/saml2/slo");
+		request.setServletPath("/logout/saml2/slo");
+		request.setParameter("SAMLRequest", "request");
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		given(this.relyingPartyRegistrationResolver.resolve(any(), any())).willReturn(registration);
+		given(this.logoutRequestValidator.validate(any())).willReturn(Saml2LogoutValidatorResult.success());
+		Saml2LogoutResponse logoutResponse = Saml2LogoutResponse.withRelyingPartyRegistration(registration)
+				.samlResponse("response").build();
+		given(this.logoutResponseResolver.resolve(any(), any())).willReturn(logoutResponse);
+		this.logoutRequestProcessingFilter.doFilterInternal(request, response, new MockFilterChain());
+		verify(this.logoutRequestValidator).validate(any());
+		verify(this.logoutHandler).logout(any(), any(), any());
+		verify(this.logoutResponseResolver).resolve(any(), any());
+		String content = response.getContentAsString();
+		assertThat(content).contains("SAMLResponse");
+		assertThat(content).contains(registration.getAssertingPartyDetails().getSingleLogoutServiceResponseLocation());
+	}
+
+	@Test
+	public void doFilterWhenRequestMismatchesThenNoLogout() throws Exception {
+		Authentication authentication = new TestingAuthenticationToken("user", "password");
+		SecurityContextHolder.getContext().setAuthentication(authentication);
+		MockHttpServletRequest request = new MockHttpServletRequest("POST", "/logout");
+		request.setServletPath("/logout");
+		request.setParameter("SAMLResponse", "response");
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		this.logoutRequestProcessingFilter.doFilterInternal(request, response, new MockFilterChain());
+		verifyNoInteractions(this.logoutRequestValidator, this.logoutHandler);
+	}
+
+	@Test
+	public void doFilterWhenNoSamlRequestOrResponseThenNoLogout() throws Exception {
+		Authentication authentication = new TestingAuthenticationToken("user", "password");
+		SecurityContextHolder.getContext().setAuthentication(authentication);
+		MockHttpServletRequest request = new MockHttpServletRequest("POST", "/logout/saml2/slo");
+		request.setServletPath("/logout/saml2/slo");
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		this.logoutRequestProcessingFilter.doFilterInternal(request, response, new MockFilterChain());
+		verifyNoInteractions(this.logoutRequestValidator, this.logoutHandler);
+	}
+
+	@Test
+	public void doFilterWhenValidationFailsThen401() throws Exception {
+		RelyingPartyRegistration registration = TestRelyingPartyRegistrations.full().build();
+		Authentication authentication = new TestingAuthenticationToken("user", "password");
+		SecurityContextHolder.getContext().setAuthentication(authentication);
+		MockHttpServletRequest request = new MockHttpServletRequest("POST", "/logout/saml2/slo");
+		request.setServletPath("/logout/saml2/slo");
+		request.setParameter("SAMLRequest", "request");
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		given(this.relyingPartyRegistrationResolver.resolve(request, null)).willReturn(registration);
+		given(this.logoutRequestValidator.validate(any()))
+				.willReturn(Saml2LogoutValidatorResult.withErrors(new Saml2Error("error", "description")).build());
+		this.logoutRequestProcessingFilter.doFilter(request, response, new MockFilterChain());
+		assertThat(response.getStatus()).isEqualTo(401);
+		verifyNoInteractions(this.logoutHandler);
+	}
+
+}

+ 153 - 0
saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutResponseFilterTests.java

@@ -0,0 +1,153 @@
+/*
+ * Copyright 2002-2021 the original author or authors.
+ *
+ * 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
+ *
+ *      https://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 org.springframework.security.saml2.provider.service.web.authentication.logout;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.mock.web.MockFilterChain;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+import org.springframework.security.authentication.TestingAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.saml2.core.Saml2Error;
+import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutRequest;
+import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutResponseValidator;
+import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutValidatorResult;
+import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration;
+import org.springframework.security.saml2.provider.service.registration.Saml2MessageBinding;
+import org.springframework.security.saml2.provider.service.registration.TestRelyingPartyRegistrations;
+import org.springframework.security.saml2.provider.service.web.RelyingPartyRegistrationResolver;
+import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.BDDMockito.mock;
+import static org.mockito.BDDMockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+
+/**
+ * Tests for {@link Saml2LogoutResponseFilter}
+ */
+public class Saml2LogoutResponseFilterTests {
+
+	RelyingPartyRegistrationResolver relyingPartyRegistrationResolver = mock(RelyingPartyRegistrationResolver.class);
+
+	Saml2LogoutRequestRepository logoutRequestRepository = mock(Saml2LogoutRequestRepository.class);
+
+	Saml2LogoutResponseValidator logoutResponseValidator = mock(Saml2LogoutResponseValidator.class);
+
+	LogoutSuccessHandler logoutSuccessHandler = mock(LogoutSuccessHandler.class);
+
+	Saml2LogoutResponseFilter logoutResponseProcessingFilter = new Saml2LogoutResponseFilter(
+			this.relyingPartyRegistrationResolver, this.logoutResponseValidator, this.logoutSuccessHandler);
+
+	@BeforeEach
+	public void setUp() {
+		this.logoutResponseProcessingFilter.setLogoutRequestRepository(this.logoutRequestRepository);
+	}
+
+	@AfterEach
+	public void tearDown() {
+		SecurityContextHolder.clearContext();
+	}
+
+	@Test
+	public void doFilterWhenSamlResponsePostThenLogout() throws Exception {
+		Authentication authentication = new TestingAuthenticationToken("user", "password");
+		SecurityContextHolder.getContext().setAuthentication(authentication);
+		MockHttpServletRequest request = new MockHttpServletRequest("POST", "/logout/saml2/slo");
+		request.setServletPath("/logout/saml2/slo");
+		request.setParameter("SAMLResponse", "response");
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		RelyingPartyRegistration registration = TestRelyingPartyRegistrations.full().build();
+		given(this.relyingPartyRegistrationResolver.resolve(request, "registration-id")).willReturn(registration);
+		Saml2LogoutRequest logoutRequest = Saml2LogoutRequest.withRelyingPartyRegistration(registration)
+				.samlRequest("request").build();
+		given(this.logoutRequestRepository.removeLogoutRequest(request, response)).willReturn(logoutRequest);
+		given(this.logoutResponseValidator.validate(any())).willReturn(Saml2LogoutValidatorResult.success());
+		this.logoutResponseProcessingFilter.doFilterInternal(request, response, new MockFilterChain());
+		verify(this.logoutResponseValidator).validate(any());
+		verify(this.logoutSuccessHandler).onLogoutSuccess(any(), any(), any());
+	}
+
+	@Test
+	public void doFilterWhenSamlResponseRedirectThenLogout() throws Exception {
+		Authentication authentication = new TestingAuthenticationToken("user", "password");
+		SecurityContextHolder.getContext().setAuthentication(authentication);
+		MockHttpServletRequest request = new MockHttpServletRequest("GET", "/logout/saml2/slo");
+		request.setServletPath("/logout/saml2/slo");
+		request.setParameter("SAMLResponse", "response");
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		RelyingPartyRegistration registration = TestRelyingPartyRegistrations.full()
+				.singleLogoutServiceBinding(Saml2MessageBinding.REDIRECT).build();
+		given(this.relyingPartyRegistrationResolver.resolve(request, "registration-id")).willReturn(registration);
+		Saml2LogoutRequest logoutRequest = Saml2LogoutRequest.withRelyingPartyRegistration(registration)
+				.samlRequest("request").build();
+		given(this.logoutRequestRepository.removeLogoutRequest(request, response)).willReturn(logoutRequest);
+		given(this.logoutResponseValidator.validate(any())).willReturn(Saml2LogoutValidatorResult.success());
+		this.logoutResponseProcessingFilter.doFilterInternal(request, response, new MockFilterChain());
+		verify(this.logoutResponseValidator).validate(any());
+		verify(this.logoutSuccessHandler).onLogoutSuccess(any(), any(), any());
+	}
+
+	@Test
+	public void doFilterWhenRequestMismatchesThenNoLogout() throws Exception {
+		Authentication authentication = new TestingAuthenticationToken("user", "password");
+		SecurityContextHolder.getContext().setAuthentication(authentication);
+		MockHttpServletRequest request = new MockHttpServletRequest("POST", "/logout");
+		request.setServletPath("/logout");
+		request.setParameter("SAMLRequest", "request");
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		this.logoutResponseProcessingFilter.doFilterInternal(request, response, new MockFilterChain());
+		verifyNoInteractions(this.logoutResponseValidator, this.logoutSuccessHandler);
+	}
+
+	@Test
+	public void doFilterWhenNoSamlRequestOrResponseThenNoLogout() throws Exception {
+		Authentication authentication = new TestingAuthenticationToken("user", "password");
+		SecurityContextHolder.getContext().setAuthentication(authentication);
+		MockHttpServletRequest request = new MockHttpServletRequest("POST", "/logout/saml2/slo");
+		request.setServletPath("/logout/saml2/slo");
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		this.logoutResponseProcessingFilter.doFilterInternal(request, response, new MockFilterChain());
+		verifyNoInteractions(this.logoutResponseValidator, this.logoutSuccessHandler);
+	}
+
+	@Test
+	public void doFilterWhenValidatorFailsThenStops() throws Exception {
+		Authentication authentication = new TestingAuthenticationToken("user", "password");
+		SecurityContextHolder.getContext().setAuthentication(authentication);
+		MockHttpServletRequest request = new MockHttpServletRequest("POST", "/logout/saml2/slo");
+		request.setServletPath("/logout/saml2/slo");
+		request.setParameter("SAMLResponse", "response");
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		RelyingPartyRegistration registration = TestRelyingPartyRegistrations.full().build();
+		given(this.relyingPartyRegistrationResolver.resolve(request, "registration-id")).willReturn(registration);
+		Saml2LogoutRequest logoutRequest = Saml2LogoutRequest.withRelyingPartyRegistration(registration)
+				.samlRequest("request").build();
+		given(this.logoutRequestRepository.removeLogoutRequest(request, response)).willReturn(logoutRequest);
+		given(this.logoutResponseValidator.validate(any()))
+				.willReturn(Saml2LogoutValidatorResult.withErrors(new Saml2Error("error", "description")).build());
+		this.logoutResponseProcessingFilter.doFilterInternal(request, response, new MockFilterChain());
+		verify(this.logoutResponseValidator).validate(any());
+		verifyNoInteractions(this.logoutSuccessHandler);
+	}
+
+}

+ 107 - 0
saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2RelyingPartyInitiatedLogoutSuccessHandlerTests.java

@@ -0,0 +1,107 @@
+/*
+ * Copyright 2002-2021 the original author or authors.
+ *
+ * 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
+ *
+ *      https://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 org.springframework.security.saml2.provider.service.web.authentication.logout;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.saml2.provider.service.authentication.DefaultSaml2AuthenticatedPrincipal;
+import org.springframework.security.saml2.provider.service.authentication.Saml2Authentication;
+import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutRequest;
+import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration;
+import org.springframework.security.saml2.provider.service.registration.Saml2MessageBinding;
+import org.springframework.security.saml2.provider.service.registration.TestRelyingPartyRegistrations;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.BDDMockito.mock;
+
+/**
+ * Tests for {@link Saml2RelyingPartyInitiatedLogoutSuccessHandler}
+ *
+ * @author Josh Cummings
+ */
+public class Saml2RelyingPartyInitiatedLogoutSuccessHandlerTests {
+
+	Saml2LogoutRequestResolver logoutRequestResolver = mock(Saml2LogoutRequestResolver.class);
+
+	Saml2LogoutRequestRepository logoutRequestRepository = mock(Saml2LogoutRequestRepository.class);
+
+	Saml2RelyingPartyInitiatedLogoutSuccessHandler logoutRequestSuccessHandler = new Saml2RelyingPartyInitiatedLogoutSuccessHandler(
+			this.logoutRequestResolver);
+
+	@BeforeEach
+	public void setUp() {
+		this.logoutRequestSuccessHandler.setLogoutRequestRepository(this.logoutRequestRepository);
+	}
+
+	@AfterEach
+	public void tearDown() {
+		SecurityContextHolder.clearContext();
+	}
+
+	@Test
+	public void onLogoutSuccessWhenRedirectThenRedirectsToAssertingParty() throws Exception {
+		RelyingPartyRegistration registration = TestRelyingPartyRegistrations.full().build();
+		Authentication authentication = authentication(registration);
+		SecurityContextHolder.getContext().setAuthentication(authentication);
+		Saml2LogoutRequest logoutRequest = Saml2LogoutRequest.withRelyingPartyRegistration(registration)
+				.samlRequest("request").build();
+		MockHttpServletRequest request = new MockHttpServletRequest("POST", "/saml2/logout");
+		request.setServletPath("/saml2/logout");
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		given(this.logoutRequestResolver.resolve(any(), any())).willReturn(logoutRequest);
+		this.logoutRequestSuccessHandler.onLogoutSuccess(request, response, authentication);
+		String content = response.getHeader("Location");
+		assertThat(content).contains("SAMLRequest");
+		assertThat(content).startsWith(registration.getAssertingPartyDetails().getSingleLogoutServiceLocation());
+	}
+
+	@Test
+	public void onLogoutSuccessWhenPostThenPostsToAssertingParty() throws Exception {
+		RelyingPartyRegistration registration = TestRelyingPartyRegistrations.full()
+				.assertingPartyDetails((party) -> party.singleLogoutServiceBinding(Saml2MessageBinding.POST)).build();
+		Authentication authentication = authentication(registration);
+		SecurityContextHolder.getContext().setAuthentication(authentication);
+		Saml2LogoutRequest logoutRequest = Saml2LogoutRequest.withRelyingPartyRegistration(registration)
+				.samlRequest("request").build();
+		MockHttpServletRequest request = new MockHttpServletRequest("POST", "/saml2/logout");
+		request.setServletPath("/saml2/logout");
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		given(this.logoutRequestResolver.resolve(any(), any())).willReturn(logoutRequest);
+		this.logoutRequestSuccessHandler.onLogoutSuccess(request, response, authentication);
+		String content = response.getContentAsString();
+		assertThat(content).contains("SAMLRequest");
+		assertThat(content).contains(registration.getAssertingPartyDetails().getSingleLogoutServiceLocation());
+	}
+
+	private Saml2Authentication authentication(RelyingPartyRegistration registration) {
+		DefaultSaml2AuthenticatedPrincipal principal = new DefaultSaml2AuthenticatedPrincipal("user", new HashMap<>());
+		principal.setRelyingPartyRegistrationId(registration.getRegistrationId());
+		return new Saml2Authentication(principal, "response", new ArrayList<>());
+	}
+
+}