浏览代码

Add Single Logout Support

Closes gh-8731
Josh Cummings 4 年之前
父节点
当前提交
e807fae869
共有 41 个文件被更改,包括 4957 次插入35 次删除
  1. 267 8
      docs/manual/src/docs/asciidoc/_includes/servlet/saml2/saml2-login.adoc
  2. 7 0
      saml2/saml2-service-provider/core/src/main/java/org/springframework/security/saml2/core/Saml2ErrorCodes.java
  3. 217 0
      saml2/saml2-service-provider/core/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/Saml2LogoutRequest.java
  4. 184 0
      saml2/saml2-service-provider/core/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/Saml2LogoutResponse.java
  5. 10 0
      saml2/saml2-service-provider/core/src/main/java/org/springframework/security/saml2/provider/service/metadata/OpenSamlMetadataResolver.java
  6. 25 3
      saml2/saml2-service-provider/core/src/main/java/org/springframework/security/saml2/provider/service/registration/OpenSamlAssertingPartyMetadataConverter.java
  7. 238 6
      saml2/saml2-service-provider/core/src/main/java/org/springframework/security/saml2/provider/service/registration/RelyingPartyRegistration.java
  8. 10 11
      saml2/saml2-service-provider/core/src/main/java/org/springframework/security/saml2/provider/service/web/DefaultRelyingPartyRegistrationResolver.java
  9. 112 0
      saml2/saml2-service-provider/core/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/HttpSessionLogoutRequestRepository.java
  10. 198 0
      saml2/saml2-service-provider/core/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSamlLogoutRequestHandler.java
  11. 243 0
      saml2/saml2-service-provider/core/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSamlLogoutRequestResolver.java
  12. 217 0
      saml2/saml2-service-provider/core/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSamlLogoutResponseHandler.java
  13. 268 0
      saml2/saml2-service-provider/core/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSamlLogoutResponseResolver.java
  14. 173 0
      saml2/saml2-service-provider/core/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSamlSigningUtils.java
  15. 218 0
      saml2/saml2-service-provider/core/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSamlVerificationUtils.java
  16. 92 0
      saml2/saml2-service-provider/core/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutRequestFilter.java
  17. 68 0
      saml2/saml2-service-provider/core/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutRequestRepository.java
  18. 82 0
      saml2/saml2-service-provider/core/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutRequestResolver.java
  19. 159 0
      saml2/saml2-service-provider/core/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutRequestSuccessHandler.java
  20. 117 0
      saml2/saml2-service-provider/core/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutResponseFilter.java
  21. 90 0
      saml2/saml2-service-provider/core/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutResponseResolver.java
  22. 148 0
      saml2/saml2-service-provider/core/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutResponseSuccessHandler.java
  23. 35 0
      saml2/saml2-service-provider/core/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2RequestAttributeNames.java
  24. 79 0
      saml2/saml2-service-provider/core/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2Utils.java
  25. 44 2
      saml2/saml2-service-provider/core/src/test/java/org/springframework/security/saml2/provider/service/authentication/TestOpenSamlObjects.java
  26. 4 2
      saml2/saml2-service-provider/core/src/test/java/org/springframework/security/saml2/provider/service/metadata/OpenSamlMetadataResolverTests.java
  27. 9 3
      saml2/saml2-service-provider/core/src/test/java/org/springframework/security/saml2/provider/service/registration/TestRelyingPartyRegistrations.java
  28. 3 0
      saml2/saml2-service-provider/core/src/test/java/org/springframework/security/saml2/provider/service/web/DefaultRelyingPartyRegistrationResolverTests.java
  29. 242 0
      saml2/saml2-service-provider/core/src/test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/HttpSessionLogoutRequestRepositoryTests.java
  30. 182 0
      saml2/saml2-service-provider/core/src/test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSamlLogoutRequestHandlerTests.java
  31. 112 0
      saml2/saml2-service-provider/core/src/test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSamlLogoutRequestResolverTests.java
  32. 189 0
      saml2/saml2-service-provider/core/src/test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSamlLogoutResponseHandlerTests.java
  33. 119 0
      saml2/saml2-service-provider/core/src/test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSamlLogoutResponseResolverTests.java
  34. 116 0
      saml2/saml2-service-provider/core/src/test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutRequestFilterTests.java
  35. 111 0
      saml2/saml2-service-provider/core/src/test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutRequestSuccessHandlerTests.java
  36. 122 0
      saml2/saml2-service-provider/core/src/test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutResponseFilterTests.java
  37. 95 0
      saml2/saml2-service-provider/core/src/test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutResponseSuccessHandlerTests.java
  38. 91 0
      saml2/saml2-service-provider/opensaml3/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSaml3LogoutRequestResolver.java
  39. 89 0
      saml2/saml2-service-provider/opensaml3/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSaml3LogoutResponseResolver.java
  40. 87 0
      saml2/saml2-service-provider/opensaml4/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSaml4LogoutRequestResolver.java
  41. 85 0
      saml2/saml2-service-provider/opensaml4/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSaml4LogoutResponseResolver.java

+ 267 - 8
docs/manual/src/docs/asciidoc/_includes/servlet/saml2/saml2-login.adoc

@@ -1053,20 +1053,279 @@ filter.setRequestMatcher(new 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:
 
 [source,java]
 ----
+Saml2LogoutResponseFilter filter = new Saml2LogoutResponseFilter(logoutHandler);
+filter.setLogoutRequestMatcher(new AntPathRequestMatcher("/SLOService.saml2", "GET"));
 http
     // ...
-    .logout(logout -> logout
-        .logoutSuccessHandler(myCustomSuccessHandler())
-        .logoutRequestMatcher(myRequestMatcher())
-    )
+    .addFilterBefore(filter, CsrfFilter.class);
+----
+
+=== 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]
+----
+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/core/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.5
+	 */
+	String INVALID_REQUEST = "invalid_request";
+
 	/**
 	 * Response is invalid in a general way.
 	 *

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

@@ -0,0 +1,217 @@
+/*
+ * 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.5
+ */
+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} parameter
+	 *
+	 * 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
+	 */
+	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 Map<String, String> parameters = new HashMap<>();
+
+		private String id;
+
+		private Builder(RelyingPartyRegistration registration) {
+			this.registration = registration;
+		}
+
+		/**
+		 * 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 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.registration.getAssertingPartyDetails().getSingleLogoutServiceLocation(),
+					this.registration.getAssertingPartyDetails().getSingleLogoutServiceBinding(), this.parameters,
+					this.id, this.registration.getRegistrationId());
+		}
+
+	}
+
+}

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

@@ -0,0 +1,184 @@
+/*
+ * 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.5
+ */
+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
+	 */
+	public Map<String, String> getParameters() {
+		return this.parameters;
+	}
+
+	/**
+	 * Create a {@link Saml2LogoutResponse.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 Saml2LogoutResponse.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 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);
+		}
+
+	}
+
+}

+ 10 - 0
saml2/saml2-service-provider/core/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/core/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) {

+ 238 - 6
saml2/saml2-service-provider/core/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,51 @@ public final class RelyingPartyRegistration {
 		return this.assertionConsumerServiceBinding;
 	}
 
+	/**
+	 * Get the <a href=
+	 * "https://wiki.shibboleth.net/confluence/display/CONCEPT/MetadataForIdP#MetadataForIdP-Logout">SingleLogoutService</a>
+	 * Binding.
+	 *
+	 * <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.5
+	 */
+	public Saml2MessageBinding getSingleLogoutServiceBinding() {
+		return this.singleLogoutServiceBinding;
+	}
+
+	/**
+	 * Get the <a href=
+	 * "https://wiki.shibboleth.net/confluence/display/CONCEPT/MetadataForIdP#MetadataForIdP-Logout">SingleLogoutService</a>
+	 * Location.
+	 *
+	 * <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.5
+	 */
+	public String getSingleLogoutServiceLocation() {
+		return this.singleLogoutServiceLocation;
+	}
+
+	/**
+	 * Get the <a href=
+	 * "https://wiki.shibboleth.net/confluence/display/CONCEPT/MetadataForIdP#MetadataForIdP-Logout">SingleLogoutService</a>
+	 * Response Location.
+	 *
+	 * <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.5
+	 */
+	public String getSingleLogoutServiceResponseLocation() {
+		return this.singleLogoutServiceResponseLocation;
+	}
+
 	/**
 	 * Get the {@link Collection} of decryption {@link Saml2X509Credential}s associated
 	 * with this relying party
@@ -364,6 +420,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 +435,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 +510,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 +544,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 +640,48 @@ public final class RelyingPartyRegistration {
 			return this.singleSignOnServiceBinding;
 		}
 
+		/**
+		 * Get the <a href=
+		 * "https://wiki.shibboleth.net/confluence/display/CONCEPT/MetadataForIdP#MetadataForIdP-Logout">SingleLogoutService</a>
+		 * Location.
+		 *
+		 * <p>
+		 * Equivalent to the value found in &lt;SingleLogoutService Location="..."/&gt; in
+		 * the asserting party's &lt;IDPSSODescriptor&gt;.
+		 * @return the SingleLogoutService Location
+		 */
+		public String getSingleLogoutServiceLocation() {
+			return this.singleLogoutServiceLocation;
+		}
+
+		/**
+		 * Get the <a href=
+		 * "https://wiki.shibboleth.net/confluence/display/CONCEPT/MetadataForIdP#MetadataForIdP-Logout">SingleLogoutService</a>
+		 * ResponseLocation.
+		 *
+		 * <p>
+		 * Equivalent to the value found in &lt;SingleLogoutService Location="..."/&gt; in
+		 * the asserting party's &lt;IDPSSODescriptor&gt;.
+		 * @return the SingleLogoutService Response Location
+		 */
+		public String getSingleLogoutServiceResponseLocation() {
+			return this.singleLogoutServiceResponseLocation;
+		}
+
+		/**
+		 * Get the <a href=
+		 * "https://wiki.shibboleth.net/confluence/display/CONCEPT/MetadataForIdP#MetadataForIdP-Logout">SingleLogoutService</a>
+		 * Binding.
+		 *
+		 * <p>
+		 * Equivalent to the value found in &lt;SingleLogoutService Binding="..."/&gt; in
+		 * the asserting party's &lt;IDPSSODescriptor&gt;.
+		 * @return the SingleLogoutService Binding
+		 */
+		public Saml2MessageBinding getSingleLogoutServiceBinding() {
+			return this.singleLogoutServiceBinding;
+		}
+
 		public static final class Builder {
 
 			private String entityId;
@@ -581,6 +698,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://wiki.shibboleth.net/confluence/display/CONCEPT/EntityNaming">EntityID</a>.
@@ -677,6 +800,55 @@ public final class RelyingPartyRegistration {
 				return this;
 			}
 
+			/**
+			 * Set the <a href=
+			 * "https://wiki.shibboleth.net/confluence/display/CONCEPT/MetadataForIdP#MetadataForIdP-Logout">SingleLogoutService</a>
+			 * Location.
+			 *
+			 * <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.5
+			 */
+			public Builder singleLogoutServiceLocation(String singleLogoutServiceLocation) {
+				this.singleLogoutServiceLocation = singleLogoutServiceLocation;
+				return this;
+			}
+
+			/**
+			 * Set the <a href=
+			 * "https://wiki.shibboleth.net/confluence/display/CONCEPT/MetadataForIdP#MetadataForIdP-Logout">SingleLogoutService</a>
+			 * Response Location.
+			 *
+			 * <p>
+			 * Equivalent to the value found in &lt;SingleLogoutService
+			 * ResponseLocation="..."/&gt; in the asserting party's
+			 * &lt;IDPSSODescriptor&gt;.
+			 * @return the SingleLogoutService Response Location
+			 * @since 5.5
+			 */
+			public Builder singleLogoutServiceResponseLocation(String singleLogoutServiceResponseLocation) {
+				this.singleLogoutServiceResponseLocation = singleLogoutServiceResponseLocation;
+				return this;
+			}
+
+			/**
+			 * Set the <a href=
+			 * "https://wiki.shibboleth.net/confluence/display/CONCEPT/MetadataForIdP#MetadataForIdP-Logout">SingleLogoutService</a>
+			 * Binding.
+			 *
+			 * <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.5
+			 */
+			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 +861,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 +1004,12 @@ public final class RelyingPartyRegistration {
 
 		private Saml2MessageBinding assertionConsumerServiceBinding = Saml2MessageBinding.POST;
 
+		private String singleLogoutServiceLocation;
+
+		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 +1113,54 @@ public final class RelyingPartyRegistration {
 			return this;
 		}
 
+		/**
+		 * Set the <a href=
+		 * "https://wiki.shibboleth.net/confluence/display/CONCEPT/MetadataForIdP#MetadataForIdP-Logout">SingleLogoutService</a>
+		 * Binding.
+		 *
+		 * <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.5
+		 */
+		public Builder singleLogoutServiceBinding(Saml2MessageBinding singleLogoutServiceBinding) {
+			this.singleLogoutServiceBinding = singleLogoutServiceBinding;
+			return this;
+		}
+
+		/**
+		 * Set the <a href=
+		 * "https://wiki.shibboleth.net/confluence/display/CONCEPT/MetadataForIdP#MetadataForIdP-Logout">SingleLogoutService</a>
+		 * Location.
+		 *
+		 * <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.5
+		 */
+		public Builder singleLogoutServiceLocation(String singleLogoutServiceLocation) {
+			this.singleLogoutServiceLocation = singleLogoutServiceLocation;
+			return this;
+		}
+
+		/**
+		 * Set the <a href=
+		 * "https://wiki.shibboleth.net/confluence/display/CONCEPT/MetadataForIdP#MetadataForIdP-Logout">SingleLogoutService</a>
+		 * Response Location.
+		 *
+		 * <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.5
+		 */
+		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 +1303,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 - 11
saml2/saml2-service-provider/core/src/main/java/org/springframework/security/saml2/provider/service/web/DefaultRelyingPartyRegistrationResolver.java

@@ -41,8 +41,7 @@ import org.springframework.web.util.UriComponentsBuilder;
  * @author Josh Cummings
  * @since 5.4
  */
-public final class DefaultRelyingPartyRegistrationResolver
-		implements Converter<HttpServletRequest, RelyingPartyRegistration>, RelyingPartyRegistrationResolver {
+public final class DefaultRelyingPartyRegistrationResolver implements RelyingPartyRegistrationResolver {
 
 	private static final char PATH_DELIMITER = '/';
 
@@ -56,14 +55,6 @@ public final class DefaultRelyingPartyRegistrationResolver
 		this.relyingPartyRegistrationRepository = relyingPartyRegistrationRepository;
 	}
 
-	/**
-	 * {@inheritDoc}
-	 */
-	@Override
-	public RelyingPartyRegistration convert(HttpServletRequest request) {
-		return resolve(request, null);
-	}
-
 	/**
 	 * {@inheritDoc}
 	 */
@@ -86,9 +77,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) {
@@ -96,6 +92,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<>();

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

@@ -0,0 +1,112 @@
+/*
+ * 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 javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import javax.servlet.http.HttpSession;
+
+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.5
+ * @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");
+		String stateParameter = this.getStateParameter(request);
+		if (stateParameter == null) {
+			return null;
+		}
+		Map<String, Saml2LogoutRequest> logoutRequests = this.getLogoutRequests(request);
+		return logoutRequests.get(stateParameter);
+	}
+
+	/**
+	 * {@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) {
+			removeLogoutRequest(request, response);
+			return;
+		}
+		String state = logoutRequest.getRelayState();
+		Assert.hasText(state, "logoutRequest.state cannot be empty");
+		Map<String, Saml2LogoutRequest> logoutRequests = this.getLogoutRequests(request);
+		logoutRequests.put(state, logoutRequest);
+		request.getSession().setAttribute(DEFAULT_LOGOUT_REQUEST_ATTR_NAME, logoutRequests);
+	}
+
+	/**
+	 * {@inheritDoc}
+	 */
+	@Override
+	public Saml2LogoutRequest removeLogoutRequest(HttpServletRequest request, HttpServletResponse response) {
+		Assert.notNull(request, "request cannot be null");
+		Assert.notNull(response, "response cannot be null");
+		String stateParameter = getStateParameter(request);
+		if (stateParameter == null) {
+			return null;
+		}
+		Map<String, Saml2LogoutRequest> logoutRequests = getLogoutRequests(request);
+		Saml2LogoutRequest originalRequest = logoutRequests.remove(stateParameter);
+		if (!logoutRequests.isEmpty()) {
+			request.getSession().setAttribute(DEFAULT_LOGOUT_REQUEST_ATTR_NAME, logoutRequests);
+		}
+		else {
+			request.getSession().removeAttribute(DEFAULT_LOGOUT_REQUEST_ATTR_NAME);
+		}
+		return originalRequest;
+	}
+
+	private String getStateParameter(HttpServletRequest request) {
+		return request.getParameter("RelayState");
+	}
+
+	private Map<String, Saml2LogoutRequest> getLogoutRequests(HttpServletRequest request) {
+		HttpSession session = request.getSession(false);
+		Map<String, Saml2LogoutRequest> logoutRequests = (session != null)
+				? (Map<String, Saml2LogoutRequest>) session.getAttribute(DEFAULT_LOGOUT_REQUEST_ATTR_NAME) : null;
+		if (logoutRequests == null) {
+			return new HashMap<>();
+		}
+		return logoutRequests;
+	}
+
+}

+ 198 - 0
saml2/saml2-service-provider/core/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSamlLogoutRequestHandler.java

@@ -0,0 +1,198 @@
+/*
+ * 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 javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+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.http.HttpMethod;
+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.core.Saml2ResponseValidatorResult;
+import org.springframework.security.saml2.provider.service.authentication.Saml2Authentication;
+import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration;
+import org.springframework.security.saml2.provider.service.web.RelyingPartyRegistrationResolver;
+import org.springframework.security.saml2.provider.service.web.authentication.logout.OpenSamlVerificationUtils.VerifierPartial;
+import org.springframework.security.web.authentication.logout.LogoutHandler;
+import org.springframework.util.Assert;
+
+/**
+ * A {@link LogoutHandler} that handles SAML 2.0 Logout Requests received from a SAML 2.0
+ * Asserting Party.
+ *
+ * @author Josh Cummings
+ * @since 5.5
+ */
+public final class OpenSamlLogoutRequestHandler implements LogoutHandler {
+
+	static {
+		OpenSamlInitializationService.initialize();
+	}
+
+	private final RelyingPartyRegistrationResolver relyingPartyRegistrationResolver;
+
+	private final ParserPool parserPool;
+
+	private final LogoutRequestUnmarshaller unmarshaller;
+
+	/**
+	 * Constructs a {@link OpenSamlLogoutRequestHandler} from the provided parameters
+	 * @param relyingPartyRegistrationResolver the
+	 * {@link RelyingPartyRegistrationResolver} from which to derive the
+	 * {@link RelyingPartyRegistration}
+	 */
+	public OpenSamlLogoutRequestHandler(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);
+	}
+
+	/**
+	 * Processes 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.
+	 *
+	 * If any processing step fails, a {@link Saml2Exception} is thrown, stopping the
+	 * logout process
+	 * @param request the HTTP request
+	 * @param response the HTTP response
+	 * @param authentication the current principal details
+	 */
+	@Override
+	public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
+		String serialized = request.getParameter("SAMLRequest");
+		Assert.notNull(serialized, "SAMLRequest cannot be null");
+		byte[] b = Saml2Utils.samlDecode(serialized);
+		serialized = inflateIfRequired(request, b);
+		RelyingPartyRegistration registration = this.relyingPartyRegistrationResolver.resolve(request,
+				getRegistrationId(authentication));
+		Assert.notNull(registration, "Failed to lookup RelyingPartyRegistration for request");
+		LogoutRequest logoutRequest = parse(serialized);
+		Saml2ResponseValidatorResult result = verifySignature(request, logoutRequest, registration);
+		result = result.concat(validateRequest(logoutRequest, registration, authentication));
+		if (result.hasErrors()) {
+			throw new Saml2Exception("Failed to validate LogoutRequest: " + result.getErrors().iterator().next());
+		}
+		request.setAttribute(LogoutRequest.class.getName(), logoutRequest);
+	}
+
+	private String getRegistrationId(Authentication authentication) {
+		if (authentication instanceof Saml2Authentication) {
+			return ((Saml2Authentication) authentication).getRelyingPartyRegistrationId();
+		}
+		return null;
+	}
+
+	private String inflateIfRequired(HttpServletRequest request, byte[] b) {
+		if (HttpMethod.GET.matches(request.getMethod())) {
+			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 Saml2ResponseValidatorResult verifySignature(HttpServletRequest request, LogoutRequest logoutRequest,
+			RelyingPartyRegistration registration) {
+		VerifierPartial partial = OpenSamlVerificationUtils.verifySignature(logoutRequest, registration);
+		if (logoutRequest.isSigned()) {
+			return partial.post(logoutRequest.getSignature());
+		}
+		return partial.redirect(request, "SAMLRequest");
+	}
+
+	private Saml2ResponseValidatorResult validateRequest(LogoutRequest request, RelyingPartyRegistration registration,
+			Authentication authentication) {
+		Saml2ResponseValidatorResult result = Saml2ResponseValidatorResult.success();
+		return result.concat(validateIssuer(request, registration)).concat(validateDestination(request, registration))
+				.concat(validateName(request, authentication));
+	}
+
+	private Saml2ResponseValidatorResult validateIssuer(LogoutRequest request, RelyingPartyRegistration registration) {
+		if (request.getIssuer() == null) {
+			return Saml2ResponseValidatorResult
+					.failure(new Saml2Error(Saml2ErrorCodes.INVALID_ISSUER, "Failed to find issuer in LogoutResponse"));
+		}
+		String issuer = request.getIssuer().getValue();
+		if (!issuer.equals(registration.getAssertingPartyDetails().getEntityId())) {
+			return Saml2ResponseValidatorResult.failure(
+					new Saml2Error(Saml2ErrorCodes.INVALID_ISSUER, "Failed to match issuer to configured issuer"));
+		}
+		return Saml2ResponseValidatorResult.success();
+	}
+
+	private Saml2ResponseValidatorResult validateDestination(LogoutRequest request,
+			RelyingPartyRegistration registration) {
+		if (request.getDestination() == null) {
+			return Saml2ResponseValidatorResult.failure(new Saml2Error(Saml2ErrorCodes.INVALID_DESTINATION,
+					"Failed to find destination in LogoutResponse"));
+		}
+		String destination = request.getDestination();
+		if (!destination.equals(registration.getSingleLogoutServiceLocation())) {
+			return Saml2ResponseValidatorResult.failure(new Saml2Error(Saml2ErrorCodes.INVALID_DESTINATION,
+					"Failed to match destination to configured destination"));
+		}
+		return Saml2ResponseValidatorResult.success();
+	}
+
+	private Saml2ResponseValidatorResult validateName(LogoutRequest request, Authentication authentication) {
+		if (authentication == null) {
+			return Saml2ResponseValidatorResult.success();
+		}
+		NameID nameId = request.getNameID();
+		if (nameId == null) {
+			return Saml2ResponseValidatorResult.failure(
+					new Saml2Error(Saml2ErrorCodes.SUBJECT_NOT_FOUND, "Failed to find subject in LogoutRequest"));
+		}
+		String name = nameId.getValue();
+		if (!name.equals(authentication.getName())) {
+			return Saml2ResponseValidatorResult.failure(new Saml2Error(Saml2ErrorCodes.INVALID_REQUEST,
+					"Failed to match subject in LogoutRequest with currently logged in user"));
+		}
+		return Saml2ResponseValidatorResult.success();
+	}
+
+}

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

@@ -0,0 +1,243 @@
+/*
+ * 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.Map;
+import java.util.UUID;
+import java.util.function.Consumer;
+
+import javax.servlet.http.HttpServletRequest;
+
+import net.shibboleth.utilities.java.support.xml.SerializeSupport;
+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.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.web.RelyingPartyRegistrationResolver;
+import org.springframework.util.Assert;
+
+/**
+ * A {@link Saml2LogoutRequestResolver} for resolving SAML 2.0 Logout Requests with
+ * OpenSAML
+ *
+ * Note that there are {@link Saml2LogoutRequestResolver} implements that are targeted for
+ * OpenSAML 3 and OpenSAML 4 via {@code OpenSaml3LogoutRequestResolver} and
+ * {@code OpenSaml4LogoutRequestResolver}
+ *
+ * @author Josh Cummings
+ * @since 5.5
+ */
+public final class OpenSamlLogoutRequestResolver implements Saml2LogoutRequestResolver {
+
+	private final RelyingPartyRegistrationResolver relyingPartyRegistrationResolver;
+
+	/**
+	 * Construct a {@link OpenSamlLogoutRequestResolver} using the provided parameters
+	 * @param relyingPartyRegistrationResolver the
+	 * {@link RelyingPartyRegistrationResolver} for selecting the
+	 * {@link RelyingPartyRegistration}
+	 */
+	public OpenSamlLogoutRequestResolver(RelyingPartyRegistrationResolver relyingPartyRegistrationResolver) {
+		this.relyingPartyRegistrationResolver = relyingPartyRegistrationResolver;
+	}
+
+	/**
+	 * 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}.
+	 *
+	 * The {@link Authentication} must be of type {@link Saml2Authentication} in order to
+	 * look up the {@link RelyingPartyRegistration} that holds the signing key.
+	 * @param request the HTTP request
+	 * @param authentication the current principal details
+	 * @return a builder, useful for overriding any aspects of the SAML 2.0 Logout Request
+	 * that the resolver supplied
+	 */
+	@Override
+	public OpenSamlLogoutRequestBuilder resolveLogoutRequest(HttpServletRequest request,
+			Authentication authentication) {
+		Assert.notNull(authentication, "Failed to lookup logged-in user for formulating LogoutRequest");
+		RelyingPartyRegistration registration = this.relyingPartyRegistrationResolver.resolve(request,
+				getRegistrationId(authentication));
+		Assert.notNull(registration, "Failed to lookup RelyingPartyRegistration for formulating LogoutRequest");
+		return new OpenSamlLogoutRequestBuilder(registration)
+				.destination(registration.getAssertingPartyDetails().getSingleLogoutServiceLocation())
+				.issuer(registration.getEntityId()).name(authentication.getName());
+	}
+
+	private String getRegistrationId(Authentication authentication) {
+		if (authentication instanceof Saml2Authentication) {
+			return ((Saml2Authentication) authentication).getRelyingPartyRegistrationId();
+		}
+		return null;
+	}
+
+	/**
+	 * A builder, useful for overriding any aspects of the SAML 2.0 Logout Request that
+	 * the resolver supplied.
+	 *
+	 * The request returned from the {@link #logoutRequest()} method is signed and
+	 * serialized. It will at minimum include an {@code ID} and a {@code RelayState},
+	 * though note that callers should also provide an {@code IssueInstant}. For your
+	 * convenience, {@link OpenSamlLogoutRequestResolver} also sets some default values.
+	 *
+	 * This builder is specifically handy for getting access to the underlying
+	 * {@link LogoutRequest} to make changes before it gets signed and serialized
+	 *
+	 * @see OpenSamlLogoutRequestResolver#resolveLogoutRequest
+	 */
+	public static final class OpenSamlLogoutRequestBuilder
+			implements Saml2LogoutRequestBuilder<OpenSamlLogoutRequestBuilder> {
+
+		static {
+			OpenSamlInitializationService.initialize();
+		}
+
+		private final LogoutRequestMarshaller marshaller;
+
+		private final IssuerBuilder issuerBuilder;
+
+		private final NameIDBuilder nameIdBuilder;
+
+		private final RelyingPartyRegistration registration;
+
+		private final LogoutRequest logoutRequest;
+
+		private String relayState;
+
+		/**
+		 * Construct a {@link OpenSamlLogoutRequestBuilder} using the provided parameters
+		 * @param registration the {@link RelyingPartyRegistration} to use
+		 */
+		public OpenSamlLogoutRequestBuilder(RelyingPartyRegistration registration) {
+			Assert.notNull(registration, "registration cannot be null");
+			this.registration = registration;
+			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");
+			LogoutRequestBuilder logoutRequestBuilder = (LogoutRequestBuilder) registry.getBuilderFactory()
+					.getBuilder(LogoutRequest.DEFAULT_ELEMENT_NAME);
+			Assert.notNull(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");
+			this.logoutRequest = logoutRequestBuilder.buildObject();
+		}
+
+		/**
+		 * {@inheritDoc}
+		 */
+		@Override
+		public OpenSamlLogoutRequestBuilder name(String name) {
+			NameID nameId = this.nameIdBuilder.buildObject();
+			nameId.setValue(name);
+			this.logoutRequest.setNameID(nameId);
+			return this;
+		}
+
+		/**
+		 * {@inheritDoc}
+		 */
+		@Override
+		public OpenSamlLogoutRequestBuilder relayState(String relayState) {
+			this.relayState = relayState;
+			return this;
+		}
+
+		/**
+		 * Mutate the {@link LogoutRequest} using the provided {@link Consumer}
+		 * @param request the Logout Request {@link Consumer} to use
+		 * @return the {@link OpenSamlLogoutRequestBuilder} for further customizations
+		 */
+		public OpenSamlLogoutRequestBuilder logoutRequest(Consumer<LogoutRequest> request) {
+			request.accept(this.logoutRequest);
+			return this;
+		}
+
+		private OpenSamlLogoutRequestBuilder destination(String destination) {
+			this.logoutRequest.setDestination(destination);
+			return this;
+		}
+
+		private OpenSamlLogoutRequestBuilder issuer(String issuer) {
+			Issuer iss = this.issuerBuilder.buildObject();
+			iss.setValue(issuer);
+			this.logoutRequest.setIssuer(iss);
+			return this;
+		}
+
+		/**
+		 * {@inheritDoc}
+		 */
+		@Override
+		public Saml2LogoutRequest logoutRequest() {
+			if (this.logoutRequest.getID() == null) {
+				this.logoutRequest.setID("LR" + UUID.randomUUID());
+			}
+			if (this.relayState == null) {
+				this.relayState = UUID.randomUUID().toString();
+			}
+			Saml2LogoutRequest.Builder result = Saml2LogoutRequest.withRelyingPartyRegistration(this.registration)
+					.id(this.logoutRequest.getID());
+			if (this.registration.getAssertingPartyDetails()
+					.getSingleLogoutServiceBinding() == Saml2MessageBinding.POST) {
+				String xml = serialize(OpenSamlSigningUtils.sign(this.logoutRequest, this.registration));
+				return result.samlRequest(Saml2Utils.samlEncode(xml.getBytes(StandardCharsets.UTF_8))).build();
+			}
+			else {
+				String xml = serialize(this.logoutRequest);
+				String deflatedAndEncoded = Saml2Utils.samlEncode(Saml2Utils.samlDeflate(xml));
+				result.samlRequest(deflatedAndEncoded);
+				Map<String, String> parameters = OpenSamlSigningUtils.sign(this.registration)
+						.param("SAMLRequest", deflatedAndEncoded).param("RelayState", this.relayState).parameters();
+				return result.parameters((params) -> params.putAll(parameters)).build();
+			}
+		}
+
+		private String serialize(LogoutRequest logoutRequest) {
+			try {
+				Element element = this.marshaller.marshall(logoutRequest);
+				return SerializeSupport.nodeToString(element);
+			}
+			catch (MarshallingException ex) {
+				throw new Saml2Exception(ex);
+			}
+		}
+
+	}
+
+}

+ 217 - 0
saml2/saml2-service-provider/core/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSamlLogoutResponseHandler.java

@@ -0,0 +1,217 @@
+/*
+ * 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 javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+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.http.HttpMethod;
+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.core.Saml2ResponseValidatorResult;
+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.security.saml2.provider.service.web.authentication.logout.OpenSamlVerificationUtils.VerifierPartial;
+import org.springframework.security.web.authentication.logout.LogoutHandler;
+import org.springframework.util.Assert;
+
+/**
+ * A {@link LogoutHandler} that handles SAML 2.0 Logout Responses received from a SAML 2.0
+ * Asserting Party.
+ *
+ * @author Josh Cummings
+ * @since 5.5
+ */
+public final class OpenSamlLogoutResponseHandler implements LogoutHandler {
+
+	static {
+		OpenSamlInitializationService.initialize();
+	}
+
+	private final RelyingPartyRegistrationResolver relyingPartyRegistrationResolver;
+
+	private final ParserPool parserPool;
+
+	private final LogoutResponseUnmarshaller unmarshaller;
+
+	private Saml2LogoutRequestRepository logoutRequestRepository = new HttpSessionLogoutRequestRepository();
+
+	/**
+	 * Constructs a {@link OpenSamlLogoutResponseHandler} from the provided parameters
+	 * @param relyingPartyRegistrationResolver the
+	 * {@link RelyingPartyRegistrationResolver} from which to derive the
+	 * {@link RelyingPartyRegistration}
+	 */
+	public OpenSamlLogoutResponseHandler(RelyingPartyRegistrationResolver relyingPartyRegistrationResolver) {
+		this.relyingPartyRegistrationResolver = relyingPartyRegistrationResolver;
+		XMLObjectProviderRegistry registry = ConfigurationService.get(XMLObjectProviderRegistry.class);
+		this.parserPool = registry.getParserPool();
+		this.unmarshaller = (LogoutResponseUnmarshaller) XMLObjectProviderRegistrySupport.getUnmarshallerFactory()
+				.getUnmarshaller(LogoutResponse.DEFAULT_ELEMENT_NAME);
+	}
+
+	/**
+	 * Processes 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.
+	 *
+	 * If any processing step fails, a {@link Saml2Exception} is thrown, stopping the
+	 * logout process
+	 * @param request the HTTP request
+	 * @param response the HTTP response
+	 * @param authentication the current principal details
+	 */
+	@Override
+	public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
+		String serialized = request.getParameter("SAMLResponse");
+		Assert.notNull(serialized, "SAMLResponse cannot be null");
+		byte[] b = Saml2Utils.samlDecode(serialized);
+		serialized = inflateIfRequired(request, b);
+		Saml2LogoutRequest logoutRequest = this.logoutRequestRepository.removeLogoutRequest(request, response);
+		if (logoutRequest == null) {
+			throw new Saml2Exception("Failed to find associated LogoutRequest");
+		}
+		RelyingPartyRegistration registration = this.relyingPartyRegistrationResolver.resolve(request,
+				logoutRequest.getRelyingPartyRegistrationId());
+		LogoutResponse logoutResponse = parse(serialized);
+		Saml2ResponseValidatorResult result = verifySignature(request, logoutResponse, registration)
+				.concat(validateRequest(logoutResponse, registration))
+				.concat(validateLogoutRequest(logoutResponse, logoutRequest.getId()));
+		if (result.hasErrors()) {
+			throw new Saml2Exception("Failed to validate LogoutResponse: " + result.getErrors().iterator().next());
+		}
+	}
+
+	/**
+	 * Use this {@link Saml2LogoutRequestRepository} for looking up the associated 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 String inflateIfRequired(HttpServletRequest request, byte[] b) {
+		if (HttpMethod.GET.matches(request.getMethod())) {
+			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 Saml2ResponseValidatorResult verifySignature(HttpServletRequest request, LogoutResponse response,
+			RelyingPartyRegistration registration) {
+		VerifierPartial partial = OpenSamlVerificationUtils.verifySignature(response, registration);
+		if (response.isSigned()) {
+			return partial.post(response.getSignature());
+		}
+		return partial.redirect(request, "SAMLResponse");
+	}
+
+	private Saml2ResponseValidatorResult validateRequest(LogoutResponse response,
+			RelyingPartyRegistration registration) {
+		Saml2ResponseValidatorResult result = Saml2ResponseValidatorResult.success();
+		return result.concat(validateIssuer(response, registration)).concat(validateDestination(response, registration))
+				.concat(validateStatus(response));
+	}
+
+	private Saml2ResponseValidatorResult validateIssuer(LogoutResponse response,
+			RelyingPartyRegistration registration) {
+		if (response.getIssuer() == null) {
+			return Saml2ResponseValidatorResult
+					.failure(new Saml2Error(Saml2ErrorCodes.INVALID_ISSUER, "Failed to find issuer in LogoutResponse"));
+		}
+		String issuer = response.getIssuer().getValue();
+		if (!issuer.equals(registration.getAssertingPartyDetails().getEntityId())) {
+			return Saml2ResponseValidatorResult.failure(
+					new Saml2Error(Saml2ErrorCodes.INVALID_ISSUER, "Failed to match issuer to configured issuer"));
+		}
+		return Saml2ResponseValidatorResult.success();
+	}
+
+	private Saml2ResponseValidatorResult validateDestination(LogoutResponse response,
+			RelyingPartyRegistration registration) {
+		if (response.getDestination() == null) {
+			return Saml2ResponseValidatorResult.failure(new Saml2Error(Saml2ErrorCodes.INVALID_DESTINATION,
+					"Failed to find destination in LogoutResponse"));
+		}
+		String destination = response.getDestination();
+		if (!destination.equals(registration.getSingleLogoutServiceResponseLocation())) {
+			return Saml2ResponseValidatorResult.failure(new Saml2Error(Saml2ErrorCodes.INVALID_DESTINATION,
+					"Failed to match destination to configured destination"));
+		}
+		return Saml2ResponseValidatorResult.success();
+	}
+
+	private Saml2ResponseValidatorResult validateStatus(LogoutResponse response) {
+		if (response.getStatus() == null) {
+			return Saml2ResponseValidatorResult.success();
+		}
+		if (response.getStatus().getStatusCode() == null) {
+			return Saml2ResponseValidatorResult.success();
+		}
+		if (StatusCode.SUCCESS.equals(response.getStatus().getStatusCode().getValue())) {
+			return Saml2ResponseValidatorResult.success();
+		}
+		if (StatusCode.PARTIAL_LOGOUT.equals(response.getStatus().getStatusCode().getValue())) {
+			return Saml2ResponseValidatorResult.success();
+		}
+		return Saml2ResponseValidatorResult
+				.failure(new Saml2Error(Saml2ErrorCodes.INVALID_RESPONSE, "Response indicated logout failed"));
+	}
+
+	private Saml2ResponseValidatorResult validateLogoutRequest(LogoutResponse response, String id) {
+		if (response.getInResponseTo() == null) {
+			return Saml2ResponseValidatorResult.success();
+		}
+		if (response.getInResponseTo().equals(id)) {
+			return Saml2ResponseValidatorResult.success();
+		}
+		return Saml2ResponseValidatorResult.failure(new Saml2Error(Saml2ErrorCodes.INVALID_RESPONSE,
+				"LogoutResponse InResponseTo doesn't match ID of associated LogoutRequest"));
+	}
+
+}

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

@@ -0,0 +1,268 @@
+/*
+ * 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.Consumer;
+
+import javax.servlet.http.HttpServletRequest;
+
+import net.shibboleth.utilities.java.support.xml.SerializeSupport;
+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.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.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.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.Saml2Authentication;
+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;
+
+/**
+ * A {@link Saml2LogoutRequestResolver} for resolving SAML 2.0 Logout Responses with
+ * OpenSAML
+ *
+ * Note that there are {@link Saml2LogoutRequestResolver} implements that are targeted for
+ * OpenSAML 3 and OpenSAML 4 via {@code OpenSaml3LogoutResponseResolver} and
+ * {@code OpenSaml4LogoutResponseResolver}
+ *
+ * @author Josh Cummings
+ * @since 5.5
+ */
+public final class OpenSamlLogoutResponseResolver implements Saml2LogoutResponseResolver {
+
+	private final RelyingPartyRegistrationResolver relyingPartyRegistrationResolver;
+
+	/**
+	 * Construct a {@link OpenSamlLogoutResponseResolver} using the provided parameters
+	 * @param relyingPartyRegistrationResolver the
+	 * {@link RelyingPartyRegistrationResolver} for selecting the
+	 * {@link RelyingPartyRegistration}
+	 */
+	public OpenSamlLogoutResponseResolver(RelyingPartyRegistrationResolver relyingPartyRegistrationResolver) {
+		this.relyingPartyRegistrationResolver = relyingPartyRegistrationResolver;
+	}
+
+	/**
+	 * 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}.
+	 *
+	 * The {@link Authentication} must be of type {@link Saml2Authentication} in order to
+	 * look up the {@link RelyingPartyRegistration} that holds the signing key.
+	 * @param request the HTTP request
+	 * @param authentication the current principal details
+	 * @return a builder, useful for overriding any aspects of the SAML 2.0 Logout Request
+	 * that the resolver supplied
+	 */
+	@Override
+	public OpenSamlLogoutResponseBuilder resolveLogoutResponse(HttpServletRequest request,
+			Authentication authentication) {
+		LogoutRequest logoutRequest = (LogoutRequest) request.getAttribute(LogoutRequest.class.getName());
+		if (logoutRequest == null) {
+			throw new Saml2Exception("Failed to find associated LogoutRequest");
+		}
+		RelyingPartyRegistration registration = this.relyingPartyRegistrationResolver.resolve(request,
+				getRegistrationId(authentication));
+		Assert.notNull(registration, "Failed to lookup RelyingPartyRegistration for request");
+		return new OpenSamlLogoutResponseBuilder(registration)
+				.destination(registration.getAssertingPartyDetails().getSingleLogoutServiceResponseLocation())
+				.issuer(registration.getEntityId()).status(StatusCode.SUCCESS)
+				.relayState(request.getParameter("RelayState")).inResponseTo(logoutRequest.getID());
+	}
+
+	private String getRegistrationId(Authentication authentication) {
+		if (authentication instanceof Saml2Authentication) {
+			return ((Saml2Authentication) authentication).getRelyingPartyRegistrationId();
+		}
+		return null;
+	}
+
+	/**
+	 * A builder, useful for overriding any aspects of the SAML 2.0 Logout Response that
+	 * the resolver supplied.
+	 *
+	 * The request returned from the {@link #logoutResponse()} method is signed and
+	 * serialized. It will at minimum include an {@code ID}, though note that callers
+	 * should include an {@code InResponseTo} and {@code IssueInstant}. For your
+	 * convenience, {@link OpenSamlLogoutResponseResolver} also sets some default values.
+	 *
+	 * This builder is specifically handy for getting access to the underlying
+	 * {@link LogoutResponse} to make changes before it gets signed and serialized
+	 *
+	 * @see OpenSamlLogoutResponseResolver#resolveLogoutResponse
+	 */
+	public static final class OpenSamlLogoutResponseBuilder
+			implements Saml2LogoutResponseBuilder<OpenSamlLogoutResponseBuilder> {
+
+		static {
+			OpenSamlInitializationService.initialize();
+		}
+
+		private final LogoutResponseMarshaller marshaller;
+
+		private final LogoutResponseBuilder logoutResponseBuilder;
+
+		private final IssuerBuilder issuerBuilder;
+
+		private final StatusBuilder statusBuilder;
+
+		private final StatusCodeBuilder statusCodeBuilder;
+
+		private final RelyingPartyRegistration registration;
+
+		private final LogoutResponse logoutResponse;
+
+		private String relayState;
+
+		/**
+		 * Construct a {@link OpenSamlLogoutResponseBuilder} using the provided parameters
+		 * @param registration the {@link RelyingPartyRegistration} to use
+		 */
+		public OpenSamlLogoutResponseBuilder(RelyingPartyRegistration registration) {
+			Assert.notNull(registration, "registration cannot be null");
+			this.registration = registration;
+			XMLObjectProviderRegistry registry = ConfigurationService.get(XMLObjectProviderRegistry.class);
+			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");
+			this.logoutResponse = this.logoutResponseBuilder.buildObject();
+		}
+
+		/**
+		 * {@inheritDoc}
+		 */
+		@Override
+		public OpenSamlLogoutResponseBuilder inResponseTo(String inResponseTo) {
+			this.logoutResponse.setInResponseTo(inResponseTo);
+			return this;
+		}
+
+		/**
+		 * {@inheritDoc}
+		 */
+		@Override
+		public OpenSamlLogoutResponseBuilder status(String status) {
+			StatusCode code = this.statusCodeBuilder.buildObject();
+			code.setValue(status);
+			Status s = this.statusBuilder.buildObject();
+			s.setStatusCode(code);
+			this.logoutResponse.setStatus(s);
+			return this;
+		}
+
+		/**
+		 * {@inheritDoc}
+		 */
+		@Override
+		public OpenSamlLogoutResponseBuilder relayState(String relayState) {
+			this.relayState = relayState;
+			return this;
+		}
+
+		/**
+		 * Mutate the {@link LogoutResponse} using the provided {@link Consumer}
+		 * @param response the Logout Response {@link Consumer} to use
+		 * @return the {@link OpenSamlLogoutResponseBuilder} for further customizations
+		 */
+		public OpenSamlLogoutResponseBuilder logoutResponse(Consumer<LogoutResponse> response) {
+			response.accept(this.logoutResponse);
+			return this;
+		}
+
+		private OpenSamlLogoutResponseBuilder destination(String destination) {
+			this.logoutResponse.setDestination(destination);
+			return this;
+		}
+
+		private OpenSamlLogoutResponseBuilder issuer(String issuer) {
+			Issuer iss = this.issuerBuilder.buildObject();
+			iss.setValue(issuer);
+			this.logoutResponse.setIssuer(iss);
+			return this;
+		}
+
+		/**
+		 * {@inheritDoc}
+		 */
+		@Override
+		public Saml2LogoutResponse logoutResponse() {
+			Saml2LogoutResponse.Builder result = Saml2LogoutResponse.withRelyingPartyRegistration(this.registration);
+			if (this.logoutResponse.getID() == null) {
+				this.logoutResponse.setID("LR" + UUID.randomUUID());
+			}
+			if (this.registration.getAssertingPartyDetails()
+					.getSingleLogoutServiceBinding() == Saml2MessageBinding.POST) {
+				String xml = serialize(OpenSamlSigningUtils.sign(this.logoutResponse, this.registration));
+				return result.samlResponse(Saml2Utils.samlEncode(xml.getBytes(StandardCharsets.UTF_8))).build();
+			}
+			else {
+				String xml = serialize(this.logoutResponse);
+				String deflatedAndEncoded = Saml2Utils.samlEncode(Saml2Utils.samlDeflate(xml));
+				result.samlResponse(deflatedAndEncoded);
+				QueryParametersPartial partial = OpenSamlSigningUtils.sign(this.registration).param("SAMLResponse",
+						deflatedAndEncoded);
+				if (this.relayState != null) {
+					partial.param("RelayState", this.relayState);
+				}
+				return result.parameters((params) -> params.putAll(partial.parameters())).build();
+			}
+		}
+
+		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/core/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() {
+
+	}
+
+}

+ 218 - 0
saml2/saml2-service-provider/core/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSamlVerificationUtils.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.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.Set;
+
+import javax.servlet.http.HttpServletRequest;
+
+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.Saml2ResponseValidatorResult;
+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);
+		}
+
+		Saml2ResponseValidatorResult redirect(HttpServletRequest request, String objectParameterName) {
+			RedirectSignature signature = new RedirectSignature(request, objectParameterName);
+			if (signature.getAlgorithm() == null) {
+				return Saml2ResponseValidatorResult.failure(new Saml2Error(Saml2ErrorCodes.INVALID_SIGNATURE,
+						"Missing signature algorithm for object [" + this.id + "]"));
+			}
+			if (!signature.hasSignature()) {
+				return Saml2ResponseValidatorResult.failure(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 Saml2ResponseValidatorResult.failure(errors);
+		}
+
+		Saml2ResponseValidatorResult 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 Saml2ResponseValidatorResult.failure(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 HttpServletRequest request;
+
+			private final String objectParameterName;
+
+			RedirectSignature(HttpServletRequest request, String objectParameterName) {
+				this.request = request;
+				this.objectParameterName = objectParameterName;
+			}
+
+			String getAlgorithm() {
+				return this.request.getParameter("SigAlg");
+			}
+
+			byte[] getContent() {
+				if (this.request.getParameter("RelayState") != null) {
+					return String.format("%s=%s&RelayState=%s&SigAlg=%s", this.objectParameterName,
+							UriUtils.encode(this.request.getParameter(this.objectParameterName),
+									StandardCharsets.ISO_8859_1),
+							UriUtils.encode(this.request.getParameter("RelayState"), StandardCharsets.ISO_8859_1),
+							UriUtils.encode(getAlgorithm(), StandardCharsets.ISO_8859_1))
+							.getBytes(StandardCharsets.UTF_8);
+				}
+				else {
+					return String
+							.format("%s=%s&SigAlg=%s", this.objectParameterName,
+									UriUtils.encode(this.request.getParameter(this.objectParameterName),
+											StandardCharsets.ISO_8859_1),
+									UriUtils.encode(getAlgorithm(), StandardCharsets.ISO_8859_1))
+							.getBytes(StandardCharsets.UTF_8);
+				}
+			}
+
+			byte[] getSignature() {
+				return Saml2Utils.samlDecode(this.request.getParameter("Signature"));
+			}
+
+			boolean hasSignature() {
+				return this.request.getParameter("Signature") != null;
+			}
+
+		}
+
+	}
+
+	private OpenSamlVerificationUtils() {
+
+	}
+
+}

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

@@ -0,0 +1,92 @@
+/*
+ * 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.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.web.authentication.logout.LogoutHandler;
+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 logout requests in the form of a &lt;saml2:LogoutRequest&gt; sent
+ * from the asserting party.
+ *
+ * @author Josh Cummings
+ * @since 5.5
+ */
+public final class Saml2LogoutRequestFilter extends OncePerRequestFilter {
+
+	private static final String DEFAULT_LOGOUT_ENDPOINT = "/logout/saml2/slo";
+
+	private RequestMatcher logoutRequestMatcher = new AntPathRequestMatcher(DEFAULT_LOGOUT_ENDPOINT);
+
+	private final LogoutHandler logoutHandler;
+
+	private final LogoutSuccessHandler logoutSuccessHandler;
+
+	/**
+	 * Constructs a {@link Saml2LogoutResponseFilter} for accepting SAML 2.0 Logout
+	 * Requests from the asserting party
+	 * @param logoutSuccessHandler the success handler to be run after the logout request
+	 * passes validation and other logout operations succeed. This success handler will
+	 * typically be one that issues a SAML 2.0 Logout Response to the asserting party,
+	 * like {@link Saml2LogoutResponseSuccessHandler}
+	 * @param logoutHandler the handler for handling the logout request, may be a
+	 * {@link org.springframework.security.web.authentication.logout.CompositeLogoutHandler}
+	 * that handles other logout concerns
+	 */
+	public Saml2LogoutRequestFilter(LogoutSuccessHandler logoutSuccessHandler, LogoutHandler logoutHandler) {
+		this.logoutSuccessHandler = logoutSuccessHandler;
+		this.logoutHandler = logoutHandler;
+	}
+
+	@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();
+		this.logoutHandler.logout(request, response, authentication);
+		this.logoutSuccessHandler.onLogoutSuccess(request, response, authentication);
+	}
+
+	public void setLogoutRequestMatcher(RequestMatcher logoutRequestMatcher) {
+		Assert.notNull(logoutRequestMatcher, "logoutRequestMatcher cannot be null");
+		this.logoutRequestMatcher = logoutRequestMatcher;
+	}
+
+}

+ 68 - 0
saml2/saml2-service-provider/core/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 Saml2LogoutRequestSuccessHandler} for persisting the Logout Request
+ * before it initiates the SAML 2.0 SLO flow. As well, used by
+ * {@link OpenSamlLogoutResponseHandler} for resolving the Logout Request associated with
+ * that Logout Response.
+ *
+ * @author Josh Cummings
+ * @since 5.5
+ * @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);
+
+}

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

@@ -0,0 +1,82 @@
+/*
+ * 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.5
+ * @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 principal details
+	 * @return a builder, useful for overriding any aspects of the SAML 2.0 Logout Request
+	 * that the resolver supplied
+	 */
+	Saml2LogoutRequestBuilder<?> resolveLogoutRequest(HttpServletRequest request, Authentication authentication);
+
+	/**
+	 * A partial application, useful for overriding any aspects of the SAML 2.0 Logout
+	 * Request that the resolver supplied.
+	 *
+	 * The request returned from the {@link #logoutRequest()} method is signed and
+	 * serialized
+	 */
+	interface Saml2LogoutRequestBuilder<P extends Saml2LogoutRequestBuilder<P>> {
+
+		/**
+		 * Use the given name in the SAML 2.0 Logout Request
+		 * @param name the name to use
+		 * @return the {@link Saml2LogoutRequestBuilder} for further customizations
+		 */
+		P name(String name);
+
+		/**
+		 * Use this relay state when sending the logout response
+		 * @param relayState the relay state to use
+		 * @return the {@link Saml2LogoutRequestBuilder} for further customizations
+		 */
+		P relayState(String relayState);
+
+		/**
+		 * Return a signed and serialized SAML 2.0 Logout Request and associated signed
+		 * request parameters
+		 * @return a signed and serialized SAML 2.0 Logout Request
+		 */
+		Saml2LogoutRequest logoutRequest();
+
+	}
+
+}

+ 159 - 0
saml2/saml2-service-provider/core/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutRequestSuccessHandler.java

@@ -0,0 +1,159 @@
+/*
+ * 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 javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+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 Response in response to the SAML 2.0
+ * Logout Request that the SAML 2.0 Asserting Party sent
+ *
+ * @author Josh Cummings
+ * @since 5.5
+ */
+public final class Saml2LogoutRequestSuccessHandler implements LogoutSuccessHandler {
+
+	private final Saml2LogoutRequestResolver logoutRequestResolver;
+
+	private final RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();
+
+	private Saml2LogoutRequestRepository logoutRequestRepository = new HttpSessionLogoutRequestRepository();
+
+	/**
+	 * Constructs a {@link Saml2LogoutRequestSuccessHandler} using the provided parameters
+	 * @param logoutRequestResolver the {@link Saml2LogoutRequestResolver} to use
+	 */
+	public Saml2LogoutRequestSuccessHandler(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 {
+		Saml2LogoutRequestResolver.Saml2LogoutRequestBuilder<?> builder = this.logoutRequestResolver
+				.resolveLogoutRequest(request, authentication);
+		if (builder == null) {
+			return;
+		}
+		Saml2LogoutRequest logoutRequest = builder.logoutRequest();
+		this.logoutRequestRepository.saveLogoutRequest(logoutRequest, request, response);
+		if (logoutRequest.getBinding() == Saml2MessageBinding.REDIRECT) {
+			doRedirect(request, response, logoutRequest);
+		}
+		else {
+			doPost(response, logoutRequest);
+		}
+	}
+
+	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, uriBuilder);
+		addParameter("RelayState", logoutRequest, uriBuilder);
+		addParameter("SigAlg", logoutRequest, uriBuilder);
+		addParameter("Signature", logoutRequest, uriBuilder);
+		this.redirectStrategy.sendRedirect(request, response, uriBuilder.build(true).toUriString());
+	}
+
+	private void addParameter(String name, Saml2LogoutRequest logoutRequest, UriComponentsBuilder builder) {
+		Assert.hasText(name, "name cannot be empty or null");
+		if (StringUtils.hasText(logoutRequest.getParameter(name))) {
+			builder.queryParam(UriUtils.encode(name, StandardCharsets.ISO_8859_1),
+					UriUtils.encode(logoutRequest.getParameter(name), StandardCharsets.ISO_8859_1));
+		}
+	}
+
+	private void doPost(HttpServletResponse response, Saml2LogoutRequest logoutRequest) throws IOException {
+		String html = createSamlPostRequestFormData(logoutRequest);
+		response.setContentType(MediaType.TEXT_HTML_VALUE);
+		response.getWriter().write(html);
+	}
+
+	private String createSamlPostRequestFormData(Saml2LogoutRequest logoutRequest) {
+		String location = logoutRequest.getLocation();
+		String samlRequest = logoutRequest.getSamlRequest();
+		String relayState = logoutRequest.getRelayState();
+		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(samlRequest));
+		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();
+	}
+
+}

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

@@ -0,0 +1,117 @@
+/*
+ * 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.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.web.authentication.logout.CompositeLogoutHandler;
+import org.springframework.security.web.authentication.logout.LogoutHandler;
+import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;
+import org.springframework.security.web.authentication.logout.SimpleUrlLogoutSuccessHandler;
+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 LogoutHandler} or
+ * {@link LogoutSuccessHandler} that rely on the user being logged in.
+ *
+ * @author Josh Cummings
+ * @since 5.5
+ */
+public final class Saml2LogoutResponseFilter extends OncePerRequestFilter {
+
+	private static final String DEFAULT_LOGOUT_ENDPOINT = "/logout/saml2/slo";
+
+	private RequestMatcher logoutRequestMatcher = new AntPathRequestMatcher(DEFAULT_LOGOUT_ENDPOINT);
+
+	private final LogoutHandler logoutHandler;
+
+	private LogoutSuccessHandler logoutSuccessHandler = new SimpleUrlLogoutSuccessHandler();
+
+	/**
+	 * Constructs a {@link Saml2LogoutResponseFilter} for accepting SAML 2.0 Logout
+	 * Responses from the asserting party
+	 * @param logoutHandler the handlers for handling the logout response
+	 */
+	public Saml2LogoutResponseFilter(LogoutHandler logoutHandler) {
+		this.logoutHandler = new CompositeLogoutHandler(logoutHandler);
+	}
+
+	/**
+	 * {@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;
+		}
+
+		Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
+		this.logoutHandler.logout(request, response, authentication);
+		this.logoutSuccessHandler.onLogoutSuccess(request, response, authentication);
+	}
+
+	/**
+	 * Use this {@link RequestMatcher} for requests
+	 *
+	 * This is handy when your asserting party needs it to be a specific endpoint instead
+	 * of the default.
+	 * @param logoutRequestMatcher the {@link RequestMatcher} to use
+	 */
+	public void setLogoutRequestMatcher(RequestMatcher logoutRequestMatcher) {
+		Assert.notNull(logoutRequestMatcher, "logoutRequestMatcher cannot be null");
+		this.logoutRequestMatcher = logoutRequestMatcher;
+	}
+
+	/**
+	 * Use this {@link LogoutSuccessHandler} when complete
+	 *
+	 * Note that when a &lt;saml2:LogoutResponse&gt; is received, the end user is already
+	 * logged out. Any {@link LogoutSuccessHandler} used here should not rely on the
+	 * {@link Authentication}. {@link SimpleUrlLogoutSuccessHandler} is an example of
+	 * this.
+	 * @param logoutSuccessHandler the {@link LogoutSuccessHandler} to use
+	 * @see SimpleUrlLogoutSuccessHandler
+	 */
+	public void setLogoutSuccessHandler(LogoutSuccessHandler logoutSuccessHandler) {
+		Assert.notNull(logoutSuccessHandler, "logoutSuccessHandler cannot be null");
+		this.logoutSuccessHandler = logoutSuccessHandler;
+	}
+
+}

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

@@ -0,0 +1,90 @@
+/*
+ * 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.5
+ * @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 principal details
+	 * @return a builder, useful for overriding any aspects of the SAML 2.0 Logout
+	 * Response that the resolver supplied
+	 */
+	Saml2LogoutResponseBuilder<?> resolveLogoutResponse(HttpServletRequest request, Authentication authentication);
+
+	/**
+	 * A partial application, useful for overriding any aspects of the SAML 2.0 Logout
+	 * Response that the resolver supplied.
+	 *
+	 * The response returned from the {@link #logoutResponse()} method is signed and
+	 * serialized
+	 */
+	interface Saml2LogoutResponseBuilder<P extends Saml2LogoutResponseBuilder<P>> {
+
+		/**
+		 * Use this value as the {@code InResponseTo} identifier for the associated SAML
+		 * 2.0 Logout Request
+		 * @param name the logout request identifier
+		 * @return the {@link Saml2LogoutResponseBuilder} for further customizations
+		 */
+		P inResponseTo(String name);
+
+		/**
+		 * Use this status code in the logout response.
+		 *
+		 * The default is {@code SUCCESS}.
+		 * @param status the status code to use
+		 * @return the {@link Saml2LogoutResponseBuilder} for further customizations
+		 */
+		P status(String status);
+
+		/**
+		 * Use this relay state when sending the logout response
+		 * @param relayState the relay state to use
+		 * @return the {@link Saml2LogoutResponseBuilder} for further customizations
+		 */
+		P relayState(String relayState);
+
+		/**
+		 * Return a signed and serialized SAML 2.0 Logout Response and associated signed
+		 * request parameters
+		 * @return a signed and serialized SAML 2.0 Logout Response
+		 */
+		Saml2LogoutResponse logoutResponse();
+
+	}
+
+}

+ 148 - 0
saml2/saml2-service-provider/core/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutResponseSuccessHandler.java

@@ -0,0 +1,148 @@
+/*
+ * 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 javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+import org.springframework.http.MediaType;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutResponse;
+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 Response in response to the SAML 2.0
+ * Logout Request that the SAML 2.0 Asserting Party sent
+ *
+ * @author Josh Cummings
+ * @since 5.5
+ */
+public final class Saml2LogoutResponseSuccessHandler implements LogoutSuccessHandler {
+
+	private final Saml2LogoutResponseResolver logoutResponseResolver;
+
+	private final RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();
+
+	/**
+	 * Constructs a {@link Saml2LogoutResponseSuccessHandler} using the provided
+	 * parameters
+	 * @param logoutResponseResolver the {@link Saml2LogoutResponseResolver} to use
+	 */
+	public Saml2LogoutResponseSuccessHandler(Saml2LogoutResponseResolver logoutResponseResolver) {
+		this.logoutResponseResolver = logoutResponseResolver;
+	}
+
+	/**
+	 * 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 {
+		Saml2LogoutResponse logoutResponse = this.logoutResponseResolver.resolveLogoutResponse(request, authentication)
+				.logoutResponse();
+		if (logoutResponse.getBinding() == Saml2MessageBinding.REDIRECT) {
+			doRedirect(request, response, logoutResponse);
+		}
+		else {
+			doPost(response, logoutResponse);
+		}
+	}
+
+	private void doRedirect(HttpServletRequest request, HttpServletResponse response,
+			Saml2LogoutResponse logoutResponse) throws IOException {
+		String location = logoutResponse.getResponseLocation();
+		UriComponentsBuilder uriBuilder = UriComponentsBuilder.fromUriString(location);
+		addParameter("SAMLResponse", logoutResponse, uriBuilder);
+		addParameter("RelayState", logoutResponse, uriBuilder);
+		addParameter("SigAlg", logoutResponse, uriBuilder);
+		addParameter("Signature", logoutResponse, uriBuilder);
+		this.redirectStrategy.sendRedirect(request, response, uriBuilder.build(true).toUriString());
+	}
+
+	private void addParameter(String name, Saml2LogoutResponse logoutResponse, UriComponentsBuilder builder) {
+		Assert.hasText(name, "name cannot be empty or null");
+		if (StringUtils.hasText(logoutResponse.getParameter(name))) {
+			builder.queryParam(UriUtils.encode(name, StandardCharsets.ISO_8859_1),
+					UriUtils.encode(logoutResponse.getParameter(name), StandardCharsets.ISO_8859_1));
+		}
+	}
+
+	private void doPost(HttpServletResponse response, Saml2LogoutResponse logoutResponse) throws IOException {
+		String html = createSamlPostRequestFormData(logoutResponse);
+		response.setContentType(MediaType.TEXT_HTML_VALUE);
+		response.getWriter().write(html);
+	}
+
+	private String createSamlPostRequestFormData(Saml2LogoutResponse logoutResponse) {
+		String location = logoutResponse.getResponseLocation();
+		String samlRequest = logoutResponse.getSamlResponse();
+		String relayState = logoutResponse.getRelayState();
+		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(samlRequest));
+		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();
+	}
+
+}

+ 35 - 0
saml2/saml2-service-provider/core/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2RequestAttributeNames.java

@@ -0,0 +1,35 @@
+/*
+ * 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;
+
+/**
+ * Attribute names for coordinating between SAML 2.0 Logout components.
+ *
+ * For internal use only.
+ *
+ * @author Josh Cummings
+ */
+
+final class Saml2RequestAttributeNames {
+
+	static final String LOGOUT_REQUEST_ID = Saml2RequestAttributeNames.class.getName() + "_LOGOUT_REQUEST_ID";
+
+	private Saml2RequestAttributeNames() {
+
+	}
+
+}

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

@@ -0,0 +1,79 @@
+/*
+ * 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.zip.Deflater;
+import java.util.zip.DeflaterOutputStream;
+import java.util.zip.Inflater;
+import java.util.zip.InflaterOutputStream;
+
+import org.apache.commons.codec.binary.Base64;
+
+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 static Base64 BASE64 = new Base64(0, new byte[] { '\n' });
+
+	private Saml2Utils() {
+	}
+
+	static String samlEncode(byte[] b) {
+		return BASE64.encodeAsString(b);
+	}
+
+	static byte[] samlDecode(String s) {
+		return BASE64.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);
+		}
+	}
+
+}

+ 44 - 2
saml2/saml2-service-provider/core/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);
 	}

+ 4 - 2
saml2/saml2-service-provider/core/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/core/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/core/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

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

@@ -0,0 +1,242 @@
+/*
+ * 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.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 {
+
+	private 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);
+	}
+
+	// gh-5110
+	@Test
+	public void loadLogoutRequestWhenMultipleSavedThenReturnMatchingLogoutRequest() {
+		MockHttpServletRequest request = new MockHttpServletRequest();
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		String state1 = "state-1122";
+		Saml2LogoutRequest logoutRequest1 = createLogoutRequest().relayState(state1).build();
+		this.logoutRequestRepository.saveLogoutRequest(logoutRequest1, request, response);
+		String state2 = "state-3344";
+		Saml2LogoutRequest logoutRequest2 = createLogoutRequest().relayState(state2).build();
+		this.logoutRequestRepository.saveLogoutRequest(logoutRequest2, request, response);
+		String state3 = "state-5566";
+		Saml2LogoutRequest logoutRequest3 = createLogoutRequest().relayState(state3).build();
+		this.logoutRequestRepository.saveLogoutRequest(logoutRequest3, request, response);
+		request.addParameter("RelayState", state1);
+		Saml2LogoutRequest loadedLogoutRequest1 = this.logoutRequestRepository.loadLogoutRequest(request);
+		assertThat(loadedLogoutRequest1).isEqualTo(logoutRequest1);
+		request.removeParameter("RelayState");
+		request.addParameter("RelayState", state2);
+		Saml2LogoutRequest loadedLogoutRequest2 = this.logoutRequestRepository.loadLogoutRequest(request);
+		assertThat(loadedLogoutRequest2).isEqualTo(logoutRequest2);
+		request.removeParameter("RelayState");
+		request.addParameter("RelayState", state3);
+		Saml2LogoutRequest loadedLogoutRequest3 = this.logoutRequestRepository.loadLogoutRequest(request);
+		assertThat(loadedLogoutRequest3).isEqualTo(logoutRequest3);
+	}
+
+	@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;
+		}
+
+	}
+
+}

+ 182 - 0
saml2/saml2-service-provider/core/src/test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSamlLogoutRequestHandlerTests.java

@@ -0,0 +1,182 @@
+/*
+ * 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.ArrayList;
+import java.util.HashMap;
+import java.util.Map;
+
+import org.junit.Test;
+import org.opensaml.core.xml.XMLObject;
+import org.opensaml.saml.saml2.core.LogoutRequest;
+
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.saml2.Saml2Exception;
+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.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.OpenSamlSigningUtils.QueryParametersPartial;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.BDDMockito.mock;
+
+/**
+ * Tests for {@link OpenSamlLogoutRequestHandler}
+ *
+ * @author Josh Cummings
+ */
+public class OpenSamlLogoutRequestHandlerTests {
+
+	private final RelyingPartyRegistrationResolver resolver = mock(RelyingPartyRegistrationResolver.class);
+
+	private final OpenSamlLogoutRequestHandler handler = new OpenSamlLogoutRequestHandler(this.resolver);
+
+	@Test
+	public void handleWhenAuthenticatedThenSavesRequestId() {
+		RelyingPartyRegistration registration = registration().build();
+		LogoutRequest logoutRequest = TestOpenSamlObjects.assertingPartyLogoutRequest(registration);
+		sign(logoutRequest, registration);
+		Authentication authentication = authentication(registration);
+		MockHttpServletRequest request = post(logoutRequest);
+		given(this.resolver.resolve(request, registration.getRegistrationId())).willReturn(registration);
+		this.handler.logout(request, null, authentication);
+		String id = ((LogoutRequest) request.getAttribute(LogoutRequest.class.getName())).getID();
+		assertThat(id).isEqualTo(logoutRequest.getID());
+	}
+
+	@Test
+	public void handleWhenRedirectBindingThenValidatesSignatureParameter() {
+		RelyingPartyRegistration registration = registration().build();
+		LogoutRequest logoutRequest = TestOpenSamlObjects.assertingPartyLogoutRequest(registration);
+		Authentication authentication = authentication(registration);
+		MockHttpServletRequest request = redirect(logoutRequest, OpenSamlSigningUtils.sign(registration));
+		given(this.resolver.resolve(request, registration.getRegistrationId())).willReturn(registration);
+		this.handler.logout(request, null, authentication);
+		String id = ((LogoutRequest) request.getAttribute(LogoutRequest.class.getName())).getID();
+		assertThat(id).isEqualTo(logoutRequest.getID());
+	}
+
+	@Test
+	public void handleWhenInvalidIssuerThenInvalidSignatureError() {
+		RelyingPartyRegistration registration = registration().build();
+		LogoutRequest logoutRequest = TestOpenSamlObjects.assertingPartyLogoutRequest(registration);
+		logoutRequest.getIssuer().setValue("wrong");
+		sign(logoutRequest, registration);
+		Authentication authentication = authentication(registration);
+		MockHttpServletRequest request = post(logoutRequest);
+		given(this.resolver.resolve(request, registration.getRegistrationId())).willReturn(registration);
+		assertThatExceptionOfType(Saml2Exception.class)
+				.isThrownBy(() -> this.handler.logout(request, null, authentication))
+				.withMessageContaining(Saml2ErrorCodes.INVALID_SIGNATURE);
+	}
+
+	@Test
+	public void handleWhenMismatchedUserThenInvalidRequestError() {
+		RelyingPartyRegistration registration = registration().build();
+		LogoutRequest logoutRequest = TestOpenSamlObjects.assertingPartyLogoutRequest(registration);
+		logoutRequest.getNameID().setValue("wrong");
+		sign(logoutRequest, registration);
+		Authentication authentication = authentication(registration);
+		MockHttpServletRequest request = post(logoutRequest);
+		given(this.resolver.resolve(request, registration.getRegistrationId())).willReturn(registration);
+		assertThatExceptionOfType(Saml2Exception.class)
+				.isThrownBy(() -> this.handler.logout(request, null, authentication))
+				.withMessageContaining(Saml2ErrorCodes.INVALID_REQUEST);
+	}
+
+	@Test
+	public void handleWhenMissingUserThenSubjectNotFoundError() {
+		RelyingPartyRegistration registration = registration().build();
+		LogoutRequest logoutRequest = TestOpenSamlObjects.assertingPartyLogoutRequest(registration);
+		logoutRequest.setNameID(null);
+		sign(logoutRequest, registration);
+		Authentication authentication = authentication(registration);
+		MockHttpServletRequest request = post(logoutRequest);
+		given(this.resolver.resolve(request, registration.getRegistrationId())).willReturn(registration);
+		assertThatExceptionOfType(Saml2Exception.class)
+				.isThrownBy(() -> this.handler.logout(request, null, authentication))
+				.withMessageContaining(Saml2ErrorCodes.SUBJECT_NOT_FOUND);
+	}
+
+	@Test
+	public void handleWhenMismatchedDestinationThenInvalidDestinationError() {
+		RelyingPartyRegistration registration = registration().build();
+		LogoutRequest logoutRequest = TestOpenSamlObjects.assertingPartyLogoutRequest(registration);
+		logoutRequest.setDestination("wrong");
+		sign(logoutRequest, registration);
+		Authentication authentication = authentication(registration);
+		MockHttpServletRequest request = post(logoutRequest);
+		given(this.resolver.resolve(request, registration.getRegistrationId())).willReturn(registration);
+		assertThatExceptionOfType(Saml2Exception.class)
+				.isThrownBy(() -> this.handler.logout(request, null, authentication))
+				.withMessageContaining(Saml2ErrorCodes.INVALID_DESTINATION);
+	}
+
+	private RelyingPartyRegistration.Builder registration() {
+		return signing(verifying(TestRelyingPartyRegistrations.noCredentials()));
+	}
+
+	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) {
+		return new Saml2Authentication(new DefaultSaml2AuthenticatedPrincipal("user", new HashMap<>()), "response",
+				new ArrayList<>(), registration.getRegistrationId());
+	}
+
+	private MockHttpServletRequest post(LogoutRequest logoutRequest) {
+		MockHttpServletRequest request = new MockHttpServletRequest();
+		request.setMethod("POST");
+		request.setParameter("SAMLRequest",
+				Saml2Utils.samlEncode(serialize(logoutRequest).getBytes(StandardCharsets.UTF_8)));
+		return request;
+	}
+
+	private MockHttpServletRequest redirect(LogoutRequest logoutRequest, QueryParametersPartial partial) {
+		String serialized = Saml2Utils.samlEncode(Saml2Utils.samlDeflate(serialize(logoutRequest)));
+		Map<String, String> parameters = partial.param("SAMLRequest", serialized).parameters();
+		MockHttpServletRequest request = new MockHttpServletRequest();
+		request.setParameters(parameters);
+		request.setMethod("GET");
+		return request;
+	}
+
+	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);
+	}
+
+}

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

@@ -0,0 +1,112 @@
+/*
+ * 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.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.BDDMockito.given;
+import static org.mockito.BDDMockito.mock;
+
+/**
+ * Tests for {@link OpenSamlLogoutRequestResolver}
+ *
+ * @author Josh Cummings
+ */
+public class OpenSamlLogoutRequestResolverTests {
+
+	private final RelyingPartyRegistrationResolver resolver = mock(RelyingPartyRegistrationResolver.class);
+
+	private final OpenSamlLogoutRequestResolver logoutResolver = new OpenSamlLogoutRequestResolver(this.resolver);
+
+	@Test
+	public void resolveRedirectWhenAuthenticatedThenIncludesName() {
+		RelyingPartyRegistration registration = TestRelyingPartyRegistrations.full().build();
+		Saml2Authentication authentication = authentication(registration);
+		HttpServletRequest request = new MockHttpServletRequest();
+		given(this.resolver.resolve(request, registration.getRegistrationId())).willReturn(registration);
+		Saml2LogoutRequest saml2LogoutRequest = this.logoutResolver.resolveLogoutRequest(request, authentication)
+				.logoutRequest();
+		assertThat(saml2LogoutRequest.getParameter("SigAlg")).isNotNull();
+		assertThat(saml2LogoutRequest.getParameter("Signature")).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.resolver.resolve(request, registration.getRegistrationId())).willReturn(registration);
+		Saml2LogoutRequest saml2LogoutRequest = this.logoutResolver.resolveLogoutRequest(request, authentication)
+				.logoutRequest();
+		assertThat(saml2LogoutRequest.getParameter("SigAlg")).isNull();
+		assertThat(saml2LogoutRequest.getParameter("Signature")).isNull();
+		Saml2MessageBinding binding = registration.getAssertingPartyDetails().getSingleLogoutServiceBinding();
+		LogoutRequest logoutRequest = getLogoutRequest(saml2LogoutRequest.getSamlRequest(), binding);
+		assertThat(logoutRequest.getNameID().getValue()).isEqualTo(authentication.getName());
+	}
+
+	private Saml2Authentication authentication(RelyingPartyRegistration registration) {
+		return new Saml2Authentication(new DefaultSaml2AuthenticatedPrincipal("user", new HashMap<>()), "response",
+				new ArrayList<>(), registration.getRegistrationId());
+	}
+
+	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);
+		}
+	}
+
+}

+ 189 - 0
saml2/saml2-service-provider/core/src/test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSamlLogoutResponseHandlerTests.java

@@ -0,0 +1,189 @@
+/*
+ * 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.ArrayList;
+import java.util.HashMap;
+import java.util.Map;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.opensaml.core.xml.XMLObject;
+import org.opensaml.saml.saml2.core.LogoutResponse;
+import org.opensaml.saml.saml2.core.StatusCode;
+
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.saml2.Saml2Exception;
+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.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 org.springframework.security.saml2.provider.service.web.authentication.logout.OpenSamlSigningUtils.QueryParametersPartial;
+
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.BDDMockito.mock;
+
+/**
+ * Tests for {@link OpenSamlLogoutResponseHandler}
+ *
+ * @author Josh Cummings
+ */
+public class OpenSamlLogoutResponseHandlerTests {
+
+	private final RelyingPartyRegistrationResolver resolver = mock(RelyingPartyRegistrationResolver.class);
+
+	private final Saml2LogoutRequestRepository repository = mock(Saml2LogoutRequestRepository.class);
+
+	private final OpenSamlLogoutResponseHandler handler = new OpenSamlLogoutResponseHandler(this.resolver);
+
+	@Before
+	public void setUp() {
+		this.handler.setLogoutRequestRepository(this.repository);
+	}
+
+	@Test
+	public void handleWhenAuthenticatedThenHandles() {
+		RelyingPartyRegistration registration = signing(verifying(registration())).build();
+		Saml2LogoutRequest logoutRequest = Saml2LogoutRequest.withRelyingPartyRegistration(registration).id("id")
+				.build();
+		given(this.repository.removeLogoutRequest(any(), any())).willReturn(logoutRequest);
+		LogoutResponse logoutResponse = TestOpenSamlObjects.assertingPartyLogoutResponse(registration);
+		sign(logoutResponse, registration);
+		Authentication authentication = authentication(registration);
+		MockHttpServletRequest request = post(logoutResponse);
+		given(this.resolver.resolve(request, registration.getRegistrationId())).willReturn(registration);
+		this.handler.logout(request, null, authentication);
+	}
+
+	@Test
+	public void handleWhenRedirectBindingThenValidatesSignatureParameter() {
+		RelyingPartyRegistration registration = signing(verifying(registration())).build();
+		Saml2LogoutRequest logoutRequest = Saml2LogoutRequest.withRelyingPartyRegistration(registration).id("id")
+				.build();
+		given(this.repository.removeLogoutRequest(any(), any())).willReturn(logoutRequest);
+		LogoutResponse logoutResponse = TestOpenSamlObjects.assertingPartyLogoutResponse(registration);
+		Authentication authentication = authentication(registration);
+		MockHttpServletRequest request = redirect(logoutResponse, OpenSamlSigningUtils.sign(registration));
+		given(this.resolver.resolve(request, registration.getRegistrationId())).willReturn(registration);
+		this.handler.logout(request, null, authentication);
+	}
+
+	@Test
+	public void handleWhenInvalidIssuerThenInvalidSignatureError() {
+		RelyingPartyRegistration registration = registration().build();
+		Saml2LogoutRequest logoutRequest = Saml2LogoutRequest.withRelyingPartyRegistration(registration).id("id")
+				.build();
+		given(this.repository.removeLogoutRequest(any(), any())).willReturn(logoutRequest);
+		LogoutResponse logoutResponse = TestOpenSamlObjects.assertingPartyLogoutResponse(registration);
+		logoutResponse.getIssuer().setValue("wrong");
+		sign(logoutResponse, registration);
+		Authentication authentication = authentication(registration);
+		MockHttpServletRequest request = post(logoutResponse);
+		given(this.resolver.resolve(request, registration.getRegistrationId())).willReturn(registration);
+		assertThatExceptionOfType(Saml2Exception.class)
+				.isThrownBy(() -> this.handler.logout(request, null, authentication))
+				.withMessageContaining(Saml2ErrorCodes.INVALID_SIGNATURE);
+	}
+
+	@Test
+	public void handleWhenMismatchedDestinationThenInvalidDestinationError() {
+		RelyingPartyRegistration registration = registration().build();
+		Saml2LogoutRequest logoutRequest = Saml2LogoutRequest.withRelyingPartyRegistration(registration).id("id")
+				.build();
+		given(this.repository.removeLogoutRequest(any(), any())).willReturn(logoutRequest);
+		LogoutResponse logoutResponse = TestOpenSamlObjects.assertingPartyLogoutResponse(registration);
+		logoutResponse.setDestination("wrong");
+		sign(logoutResponse, registration);
+		Authentication authentication = authentication(registration);
+		MockHttpServletRequest request = post(logoutResponse);
+		given(this.resolver.resolve(request, registration.getRegistrationId())).willReturn(registration);
+		assertThatExceptionOfType(Saml2Exception.class)
+				.isThrownBy(() -> this.handler.logout(request, null, authentication))
+				.withMessageContaining(Saml2ErrorCodes.INVALID_DESTINATION);
+	}
+
+	@Test
+	public void handleWhenStatusNotSuccessThenInvalidResponseError() {
+		RelyingPartyRegistration registration = registration().build();
+		Saml2LogoutRequest logoutRequest = Saml2LogoutRequest.withRelyingPartyRegistration(registration).id("id")
+				.build();
+		given(this.repository.removeLogoutRequest(any(), any())).willReturn(logoutRequest);
+		LogoutResponse logoutResponse = TestOpenSamlObjects.assertingPartyLogoutResponse(registration);
+		logoutResponse.getStatus().getStatusCode().setValue(StatusCode.UNKNOWN_PRINCIPAL);
+		sign(logoutResponse, registration);
+		Authentication authentication = authentication(registration);
+		MockHttpServletRequest request = post(logoutResponse);
+		given(this.resolver.resolve(request, registration.getRegistrationId())).willReturn(registration);
+		assertThatExceptionOfType(Saml2Exception.class)
+				.isThrownBy(() -> this.handler.logout(request, null, authentication))
+				.withMessageContaining(Saml2ErrorCodes.INVALID_RESPONSE);
+	}
+
+	private RelyingPartyRegistration.Builder registration() {
+		return signing(verifying(TestRelyingPartyRegistrations.noCredentials()));
+	}
+
+	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) {
+		return new Saml2Authentication(new DefaultSaml2AuthenticatedPrincipal("user", new HashMap<>()), "response",
+				new ArrayList<>(), registration.getRegistrationId());
+	}
+
+	private MockHttpServletRequest post(LogoutResponse logoutResponse) {
+		MockHttpServletRequest request = new MockHttpServletRequest();
+		request.setMethod("POST");
+		request.setParameter("SAMLResponse",
+				Saml2Utils.samlEncode(serialize(logoutResponse).getBytes(StandardCharsets.UTF_8)));
+		return request;
+	}
+
+	private MockHttpServletRequest redirect(LogoutResponse logoutResponse, QueryParametersPartial partial) {
+		String serialized = Saml2Utils.samlEncode(Saml2Utils.samlDeflate(serialize(logoutResponse)));
+		Map<String, String> parameters = partial.param("SAMLResponse", serialized).parameters();
+		MockHttpServletRequest request = new MockHttpServletRequest();
+		request.setParameters(parameters);
+		request.setMethod("GET");
+		return request;
+	}
+
+	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);
+	}
+
+}

+ 119 - 0
saml2/saml2-service-provider/core/src/test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSamlLogoutResponseResolverTests.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.io.ByteArrayInputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.HashMap;
+
+import javax.servlet.http.HttpServletRequest;
+
+import org.junit.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.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.BDDMockito.given;
+import static org.mockito.BDDMockito.mock;
+
+/**
+ * Tests for {@link OpenSamlLogoutResponseResolver}
+ *
+ * @author Josh Cummings
+ */
+public class OpenSamlLogoutResponseResolverTests {
+
+	private final RelyingPartyRegistrationResolver resolver = mock(RelyingPartyRegistrationResolver.class);
+
+	private final OpenSamlLogoutResponseResolver logoutResolver = new OpenSamlLogoutResponseResolver(this.resolver);
+
+	@Test
+	public void resolveRedirectWhenAuthenticatedThenSuccess() {
+		RelyingPartyRegistration registration = TestRelyingPartyRegistrations.full().build();
+		Saml2Authentication authentication = authentication(registration);
+		HttpServletRequest request = new MockHttpServletRequest();
+		LogoutRequest logoutRequest = TestOpenSamlObjects.assertingPartyLogoutRequest(registration);
+		request.setAttribute(LogoutRequest.class.getName(), logoutRequest);
+		given(this.resolver.resolve(request, registration.getRegistrationId())).willReturn(registration);
+		Saml2LogoutResponse saml2LogoutResponse = this.logoutResolver.resolveLogoutResponse(request, authentication)
+				.logoutResponse();
+		assertThat(saml2LogoutResponse.getParameter("SigAlg")).isNotNull();
+		assertThat(saml2LogoutResponse.getParameter("Signature")).isNotNull();
+		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();
+		Saml2Authentication authentication = authentication(registration);
+		HttpServletRequest request = new MockHttpServletRequest();
+		LogoutRequest logoutRequest = TestOpenSamlObjects.assertingPartyLogoutRequest(registration);
+		request.setAttribute(LogoutRequest.class.getName(), logoutRequest);
+		given(this.resolver.resolve(request, registration.getRegistrationId())).willReturn(registration);
+		Saml2LogoutResponse saml2LogoutResponse = this.logoutResolver.resolveLogoutResponse(request, authentication)
+				.logoutResponse();
+		assertThat(saml2LogoutResponse.getParameter("SigAlg")).isNull();
+		assertThat(saml2LogoutResponse.getParameter("Signature")).isNull();
+		Saml2MessageBinding binding = registration.getAssertingPartyDetails().getSingleLogoutServiceBinding();
+		LogoutResponse logoutResponse = getLogoutResponse(saml2LogoutResponse.getSamlResponse(), binding);
+		assertThat(logoutResponse.getStatus().getStatusCode().getValue()).isEqualTo(StatusCode.SUCCESS);
+	}
+
+	private Saml2Authentication authentication(RelyingPartyRegistration registration) {
+		return new Saml2Authentication(new DefaultSaml2AuthenticatedPrincipal("user", new HashMap<>()), "response",
+				new ArrayList<>(), registration.getRegistrationId());
+	}
+
+	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);
+		}
+	}
+
+}

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

@@ -0,0 +1,116 @@
+/*
+ * 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.After;
+import org.junit.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.web.authentication.logout.LogoutHandler;
+import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;
+
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.mockito.BDDMockito.mock;
+import static org.mockito.BDDMockito.verify;
+import static org.mockito.BDDMockito.willThrow;
+import static org.mockito.Mockito.verifyNoInteractions;
+
+public class Saml2LogoutRequestFilterTests {
+
+	private final LogoutHandler handler = mock(LogoutHandler.class);
+
+	private final LogoutSuccessHandler successHandler = mock(LogoutSuccessHandler.class);
+
+	private final Saml2LogoutRequestFilter filter = new Saml2LogoutRequestFilter(this.successHandler, this.handler);
+
+	@After
+	public void tearDown() {
+		SecurityContextHolder.clearContext();
+	}
+
+	@Test
+	public void doFilterWhenSamlRequestMatchesThenLogout() 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("SAMLRequest", "request");
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		this.filter.doFilterInternal(request, response, new MockFilterChain());
+		verify(this.handler).logout(request, response, authentication);
+		verify(this.successHandler).onLogoutSuccess(request, response, authentication);
+	}
+
+	@Test
+	public void doFilterWhenSamlResponseMatchesThenLogout() 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("SAMLRequest", "request");
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		this.filter.doFilterInternal(request, response, new MockFilterChain());
+		verify(this.handler).logout(request, response, authentication);
+		verify(this.successHandler).onLogoutSuccess(request, response, authentication);
+	}
+
+	@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.filter.doFilterInternal(request, response, new MockFilterChain());
+		verifyNoInteractions(this.handler);
+		verifyNoInteractions(this.successHandler);
+	}
+
+	@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.filter.doFilterInternal(request, response, new MockFilterChain());
+		verifyNoInteractions(this.handler);
+		verifyNoInteractions(this.successHandler);
+	}
+
+	@Test
+	public void doFilterWhenLogoutHandlerFailsThenNoSuccessHandler() {
+		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();
+		willThrow(RuntimeException.class).given(this.handler).logout(request, response, authentication);
+		assertThatExceptionOfType(RuntimeException.class)
+				.isThrownBy(() -> this.filter.doFilterInternal(request, response, new MockFilterChain()));
+		verify(this.handler).logout(request, response, authentication);
+		verifyNoInteractions(this.successHandler);
+	}
+
+}

+ 111 - 0
saml2/saml2-service-provider/core/src/test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutRequestSuccessHandlerTests.java

@@ -0,0 +1,111 @@
+/*
+ * 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.After;
+import org.junit.Before;
+import org.junit.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 org.springframework.security.saml2.provider.service.web.authentication.logout.Saml2LogoutRequestResolver.Saml2LogoutRequestBuilder;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.BDDMockito.RETURNS_SELF;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.BDDMockito.mock;
+import static org.mockito.BDDMockito.willReturn;
+
+/**
+ * Tests for {@link Saml2LogoutRequestSuccessHandler}
+ *
+ * @author Josh Cummings
+ */
+public class Saml2LogoutRequestSuccessHandlerTests {
+
+	private final Saml2LogoutRequestResolver resolver = mock(Saml2LogoutRequestResolver.class);
+
+	private final Saml2LogoutRequestRepository repository = mock(Saml2LogoutRequestRepository.class);
+
+	private final Saml2LogoutRequestSuccessHandler handler = new Saml2LogoutRequestSuccessHandler(this.resolver);
+
+	@Before
+	public void setUp() {
+		this.handler.setLogoutRequestRepository(this.repository);
+	}
+
+	@After
+	public void tearDown() {
+		SecurityContextHolder.clearContext();
+	}
+
+	@Test
+	public void doFilterWhenRedirectThenRedirectsToAssertingParty() 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();
+		Saml2LogoutRequestBuilder<?> partial = mock(Saml2LogoutRequestBuilder.class, RETURNS_SELF);
+		given(partial.logoutRequest()).willReturn(logoutRequest);
+		willReturn(partial).given(this.resolver).resolveLogoutRequest(request, authentication);
+		this.handler.onLogoutSuccess(request, response, authentication);
+		String content = response.getHeader("Location");
+		assertThat(content).contains("SAMLRequest");
+		assertThat(content).startsWith(registration.getAssertingPartyDetails().getSingleLogoutServiceLocation());
+	}
+
+	@Test
+	public void doFilterWhenPostThenPostsToAssertingParty() 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();
+		Saml2LogoutRequestBuilder<?> partial = mock(Saml2LogoutRequestBuilder.class, RETURNS_SELF);
+		given(partial.logoutRequest()).willReturn(logoutRequest);
+		willReturn(partial).given(this.resolver).resolveLogoutRequest(request, authentication);
+		this.handler.onLogoutSuccess(request, response, authentication);
+		String content = response.getContentAsString();
+		assertThat(content).contains("SAMLRequest");
+		assertThat(content).contains(registration.getAssertingPartyDetails().getSingleLogoutServiceLocation());
+	}
+
+	private Saml2Authentication authentication(RelyingPartyRegistration registration) {
+		return new Saml2Authentication(new DefaultSaml2AuthenticatedPrincipal("user", new HashMap<>()), "response",
+				new ArrayList<>(), registration.getRegistrationId());
+	}
+
+}

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

@@ -0,0 +1,122 @@
+/*
+ * 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.After;
+import org.junit.Before;
+import org.junit.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.web.authentication.logout.LogoutHandler;
+import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;
+
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.mockito.BDDMockito.mock;
+import static org.mockito.BDDMockito.verify;
+import static org.mockito.BDDMockito.willThrow;
+import static org.mockito.Mockito.verifyNoInteractions;
+
+public class Saml2LogoutResponseFilterTests {
+
+	private final LogoutHandler handler = mock(LogoutHandler.class);
+
+	private final LogoutSuccessHandler successHandler = mock(LogoutSuccessHandler.class);
+
+	private final Saml2LogoutResponseFilter filter = new Saml2LogoutResponseFilter(this.handler);
+
+	@Before
+	public void setUp() {
+		this.filter.setLogoutSuccessHandler(this.successHandler);
+	}
+
+	@After
+	public void tearDown() {
+		SecurityContextHolder.clearContext();
+	}
+
+	@Test
+	public void doFilterWhenSamlRequestMatchesThenLogout() 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();
+		this.filter.doFilterInternal(request, response, new MockFilterChain());
+		verify(this.handler).logout(request, response, authentication);
+		verify(this.successHandler).onLogoutSuccess(request, response, authentication);
+	}
+
+	@Test
+	public void doFilterWhenSamlResponseMatchesThenLogout() 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();
+		this.filter.doFilterInternal(request, response, new MockFilterChain());
+		verify(this.handler).logout(request, response, authentication);
+		verify(this.successHandler).onLogoutSuccess(request, response, authentication);
+	}
+
+	@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.filter.doFilterInternal(request, response, new MockFilterChain());
+		verifyNoInteractions(this.handler);
+		verifyNoInteractions(this.successHandler);
+	}
+
+	@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");
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		this.filter.doFilterInternal(request, response, new MockFilterChain());
+		verifyNoInteractions(this.handler);
+		verifyNoInteractions(this.successHandler);
+	}
+
+	@Test
+	public void doFilterWhenLogoutHandlerFailsThenNoSuccessHandler() {
+		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();
+		willThrow(RuntimeException.class).given(this.handler).logout(request, response, authentication);
+		assertThatExceptionOfType(RuntimeException.class)
+				.isThrownBy(() -> this.filter.doFilterInternal(request, response, new MockFilterChain()));
+		verify(this.handler).logout(request, response, authentication);
+		verifyNoInteractions(this.successHandler);
+	}
+
+}

+ 95 - 0
saml2/saml2-service-provider/core/src/test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutResponseSuccessHandlerTests.java

@@ -0,0 +1,95 @@
+/*
+ * 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.Test;
+
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+import org.springframework.security.core.Authentication;
+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.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.authentication.logout.Saml2LogoutResponseResolver.Saml2LogoutResponseBuilder;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.BDDMockito.RETURNS_SELF;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.BDDMockito.mock;
+import static org.mockito.BDDMockito.willReturn;
+
+/**
+ * Tests for {@link Saml2LogoutResponseSuccessHandler}
+ *
+ * @author Josh Cummings
+ */
+public class Saml2LogoutResponseSuccessHandlerTests {
+
+	private final Saml2LogoutResponseResolver resolver = mock(Saml2LogoutResponseResolver.class);
+
+	private final Saml2LogoutResponseSuccessHandler handler = new Saml2LogoutResponseSuccessHandler(this.resolver);
+
+	@Test
+	public void doFilterWhenRedirectThenRedirectsToAssertingParty() throws Exception {
+		RelyingPartyRegistration registration = TestRelyingPartyRegistrations.full().build();
+		Authentication authentication = authentication(registration);
+		Saml2LogoutResponse logoutResponse = Saml2LogoutResponse.withRelyingPartyRegistration(registration)
+				.samlResponse("response").build();
+		MockHttpServletRequest request = new MockHttpServletRequest();
+		request.setAttribute(Saml2RequestAttributeNames.LOGOUT_REQUEST_ID, "id");
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		Saml2LogoutResponseBuilder<?> partial = mock(Saml2LogoutResponseBuilder.class, RETURNS_SELF);
+		given(partial.logoutResponse()).willReturn(logoutResponse);
+		willReturn(partial).given(this.resolver).resolveLogoutResponse(request, authentication);
+		this.handler.onLogoutSuccess(request, response, authentication);
+		String content = response.getHeader("Location");
+		assertThat(content).contains("SAMLResponse");
+		assertThat(content)
+				.startsWith(registration.getAssertingPartyDetails().getSingleLogoutServiceResponseLocation());
+	}
+
+	@Test
+	public void doFilterWhenPostThenPostsToAssertingParty() throws Exception {
+		RelyingPartyRegistration registration = TestRelyingPartyRegistrations.full()
+				.assertingPartyDetails((party) -> party.singleLogoutServiceBinding(Saml2MessageBinding.POST)).build();
+		Authentication authentication = authentication(registration);
+		Saml2LogoutResponse logoutResponse = Saml2LogoutResponse.withRelyingPartyRegistration(registration)
+				.samlResponse("response").build();
+		MockHttpServletRequest request = new MockHttpServletRequest();
+		request.setAttribute(Saml2RequestAttributeNames.LOGOUT_REQUEST_ID, "id");
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		Saml2LogoutResponseBuilder<?> partial = mock(Saml2LogoutResponseBuilder.class, RETURNS_SELF);
+		given(partial.logoutResponse()).willReturn(logoutResponse);
+		willReturn(partial).given(this.resolver).resolveLogoutResponse(request, authentication);
+		this.handler.onLogoutSuccess(request, response, authentication);
+		String content = response.getContentAsString();
+		assertThat(content).contains("SAMLResponse");
+		assertThat(content).contains(registration.getAssertingPartyDetails().getSingleLogoutServiceResponseLocation());
+	}
+
+	private Saml2Authentication authentication(RelyingPartyRegistration registration) {
+		return new Saml2Authentication(new DefaultSaml2AuthenticatedPrincipal("user", new HashMap<>()), "response",
+				new ArrayList<>(), registration.getRegistrationId());
+	}
+
+}

+ 91 - 0
saml2/saml2-service-provider/opensaml3/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSaml3LogoutRequestResolver.java

@@ -0,0 +1,91 @@
+/*
+ * 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 javax.servlet.http.HttpServletRequest;
+
+import org.joda.time.DateTime;
+
+import org.springframework.security.core.Authentication;
+import org.springframework.security.saml2.provider.service.authentication.Saml2Authentication;
+import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration;
+import org.springframework.security.saml2.provider.service.web.RelyingPartyRegistrationResolver;
+import org.springframework.security.saml2.provider.service.web.authentication.logout.OpenSamlLogoutRequestResolver.OpenSamlLogoutRequestBuilder;
+import org.springframework.util.Assert;
+
+/**
+ * A {@link Saml2LogoutRequestResolver} for resolving SAML 2.0 Logout Requests with
+ * OpenSAML 3
+ *
+ * @author Josh Cummings
+ * @since 5.5
+ * @deprecated Because OpenSAML 3 has reached End-of-Life, please update to
+ * {@code OpenSaml4LogoutRequestResolver}
+ */
+public class OpenSaml3LogoutRequestResolver implements Saml2LogoutRequestResolver {
+
+	private final OpenSamlLogoutRequestResolver logoutRequestResolver;
+
+	private Clock clock = Clock.systemUTC();
+
+	/**
+	 * Construct a {@link OpenSaml3LogoutRequestResolver} with the provided parameters
+	 * @param relyingPartyRegistrationResolver a strategy for resolving a
+	 * {@link RelyingPartyRegistration}
+	 */
+	public OpenSaml3LogoutRequestResolver(RelyingPartyRegistrationResolver relyingPartyRegistrationResolver) {
+		this.logoutRequestResolver = new OpenSamlLogoutRequestResolver(relyingPartyRegistrationResolver);
+	}
+
+	/**
+	 * 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}. The
+	 * request also contains its issued {@link DateTime}.
+	 *
+	 * The {@link Authentication} must be of type {@link Saml2Authentication} in order to
+	 * look up the {@link RelyingPartyRegistration} that holds the signing key.
+	 * @param request the HTTP request
+	 * @param authentication the current principal details
+	 * @return a builder, useful for overriding any aspects of the SAML 2.0 Logout Request
+	 * that the resolver supplied
+	 */
+	@Override
+	public Saml2LogoutRequestBuilder<?> resolveLogoutRequest(HttpServletRequest request,
+			Authentication authentication) {
+		OpenSamlLogoutRequestBuilder builder = this.logoutRequestResolver.resolveLogoutRequest(request, authentication);
+		if (builder == null) {
+			return null;
+		}
+		return builder
+				.logoutRequest((logoutRequest) -> logoutRequest.setIssueInstant(new DateTime(this.clock.millis())));
+	}
+
+	/**
+	 * 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;
+	}
+
+}

+ 89 - 0
saml2/saml2-service-provider/opensaml3/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSaml3LogoutResponseResolver.java

@@ -0,0 +1,89 @@
+/*
+ * 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 javax.servlet.http.HttpServletRequest;
+
+import org.joda.time.DateTime;
+
+import org.springframework.security.core.Authentication;
+import org.springframework.security.saml2.provider.service.authentication.Saml2Authentication;
+import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration;
+import org.springframework.security.saml2.provider.service.web.RelyingPartyRegistrationResolver;
+import org.springframework.security.saml2.provider.service.web.authentication.logout.OpenSamlLogoutResponseResolver.OpenSamlLogoutResponseBuilder;
+import org.springframework.util.Assert;
+
+/**
+ * A {@link Saml2LogoutResponseResolver} for resolving SAML 2.0 Logout Responses with
+ * OpenSAML 3
+ *
+ * @author Josh Cummings
+ * @since 5.5
+ * @deprecated Because OpenSAML 3 has reached End-of-Life, please update to
+ * {@code OpenSaml4LogoutResponseResolver}
+ */
+public class OpenSaml3LogoutResponseResolver implements Saml2LogoutResponseResolver {
+
+	private final OpenSamlLogoutResponseResolver logoutResponseResolver;
+
+	private Clock clock = Clock.systemUTC();
+
+	/**
+	 * Construct a {@link OpenSaml3LogoutResponseResolver} with the provided parameters
+	 * @param relyingPartyRegistrationResolver a strategy for resolving a
+	 * {@link RelyingPartyRegistration}
+	 */
+	public OpenSaml3LogoutResponseResolver(RelyingPartyRegistrationResolver relyingPartyRegistrationResolver) {
+		this.logoutResponseResolver = new OpenSamlLogoutResponseResolver(relyingPartyRegistrationResolver);
+	}
+
+	/**
+	 * 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 also includes an issued {@link DateTime} and is marked as
+	 * {@code SUCCESS}.
+	 *
+	 * The {@link Authentication} must be of type {@link Saml2Authentication} in order to
+	 * look up the {@link RelyingPartyRegistration} that holds the signing key.
+	 * @param request the HTTP request
+	 * @param authentication the current principal details
+	 * @return a builder, useful for overriding any aspects of the SAML 2.0 Logout Request
+	 * that the resolver supplied
+	 */
+	@Override
+	public Saml2LogoutResponseBuilder<?> resolveLogoutResponse(HttpServletRequest request,
+			Authentication authentication) {
+		OpenSamlLogoutResponseBuilder builder = this.logoutResponseResolver.resolveLogoutResponse(request,
+				authentication);
+		if (builder == null) {
+			return null;
+		}
+		return builder
+				.logoutResponse((logoutResponse) -> logoutResponse.setIssueInstant(new DateTime(this.clock.millis())));
+	}
+
+	public void setClock(Clock clock) {
+		Assert.notNull(clock, "clock must not be null");
+		this.clock = clock;
+	}
+
+}

+ 87 - 0
saml2/saml2-service-provider/opensaml4/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSaml4LogoutRequestResolver.java

@@ -0,0 +1,87 @@
+/*
+ * 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 javax.servlet.http.HttpServletRequest;
+
+import org.springframework.security.core.Authentication;
+import org.springframework.security.saml2.provider.service.authentication.Saml2Authentication;
+import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration;
+import org.springframework.security.saml2.provider.service.web.RelyingPartyRegistrationResolver;
+import org.springframework.security.saml2.provider.service.web.authentication.logout.OpenSamlLogoutRequestResolver.OpenSamlLogoutRequestBuilder;
+import org.springframework.util.Assert;
+
+/**
+ * A {@link Saml2LogoutRequestResolver} for resolving SAML 2.0 Logout Requests with
+ * OpenSAML 4
+ *
+ * @author Josh Cummings
+ * @since 5.5
+ */
+public class OpenSaml4LogoutRequestResolver implements Saml2LogoutRequestResolver {
+
+	private final OpenSamlLogoutRequestResolver logoutRequestResolver;
+
+	private Clock clock = Clock.systemUTC();
+
+	/**
+	 * Construct a {@link OpenSaml4LogoutRequestResolver} with the provided parameters
+	 * @param relyingPartyRegistrationResolver a strategy for resolving a
+	 * {@link RelyingPartyRegistration}
+	 */
+	public OpenSaml4LogoutRequestResolver(RelyingPartyRegistrationResolver relyingPartyRegistrationResolver) {
+		this.logoutRequestResolver = new OpenSamlLogoutRequestResolver(relyingPartyRegistrationResolver);
+	}
+
+	/**
+	 * 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}. The
+	 * request also contains its issued {@link Instant}.
+	 *
+	 * The {@link Authentication} must be of type {@link Saml2Authentication} in order to
+	 * look up the {@link RelyingPartyRegistration} that holds the signing key.
+	 * @param request the HTTP request
+	 * @param authentication the current principal details
+	 * @return a builder, useful for overriding any aspects of the SAML 2.0 Logout Request
+	 * that the resolver supplied
+	 */
+	@Override
+	public Saml2LogoutRequestBuilder<?> resolveLogoutRequest(HttpServletRequest request,
+			Authentication authentication) {
+		OpenSamlLogoutRequestBuilder builder = this.logoutRequestResolver.resolveLogoutRequest(request, authentication);
+		if (builder == null) {
+			return null;
+		}
+		return builder.logoutRequest((logoutRequest) -> logoutRequest.setIssueInstant(Instant.now(this.clock)));
+	}
+
+	/**
+	 * 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;
+	}
+
+}

+ 85 - 0
saml2/saml2-service-provider/opensaml4/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSaml4LogoutResponseResolver.java

@@ -0,0 +1,85 @@
+/*
+ * 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 javax.servlet.http.HttpServletRequest;
+
+import org.springframework.security.core.Authentication;
+import org.springframework.security.saml2.provider.service.authentication.Saml2Authentication;
+import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration;
+import org.springframework.security.saml2.provider.service.web.RelyingPartyRegistrationResolver;
+import org.springframework.security.saml2.provider.service.web.authentication.logout.OpenSamlLogoutResponseResolver.OpenSamlLogoutResponseBuilder;
+import org.springframework.util.Assert;
+
+/**
+ * A {@link Saml2LogoutResponseResolver} for resolving SAML 2.0 Logout Responses with
+ * OpenSAML 4
+ *
+ * @author Josh Cummings
+ * @since 5.5
+ */
+public class OpenSaml4LogoutResponseResolver implements Saml2LogoutResponseResolver {
+
+	private final OpenSamlLogoutResponseResolver logoutResponseResolver;
+
+	private Clock clock = Clock.systemUTC();
+
+	/**
+	 * Construct a {@link OpenSaml4LogoutResponseResolver} with the provided parameters
+	 * @param relyingPartyRegistrationResolver the strategy for resolving a
+	 * {@link RelyingPartyRegistration}
+	 */
+	public OpenSaml4LogoutResponseResolver(RelyingPartyRegistrationResolver relyingPartyRegistrationResolver) {
+		this.logoutResponseResolver = new OpenSamlLogoutResponseResolver(relyingPartyRegistrationResolver);
+	}
+
+	/**
+	 * 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 also includes an issued {@link Instant} and is marked as
+	 * {@code SUCCESS}.
+	 *
+	 * The {@link Authentication} must be of type {@link Saml2Authentication} in order to
+	 * look up the {@link RelyingPartyRegistration} that holds the signing key.
+	 * @param request the HTTP request
+	 * @param authentication the current principal details
+	 * @return a builder, useful for overriding any aspects of the SAML 2.0 Logout Request
+	 * that the resolver supplied
+	 */
+	@Override
+	public Saml2LogoutResponseBuilder<?> resolveLogoutResponse(HttpServletRequest request,
+			Authentication authentication) {
+		OpenSamlLogoutResponseBuilder builder = this.logoutResponseResolver.resolveLogoutResponse(request,
+				authentication);
+		if (builder == null) {
+			return null;
+		}
+		return builder.logoutResponse((logoutResponse) -> logoutResponse.setIssueInstant(Instant.now(this.clock)));
+	}
+
+	public void setClock(Clock clock) {
+		Assert.notNull(clock, "clock must not be null");
+		this.clock = clock;
+	}
+
+}