Просмотр исходного кода

Add OIDC Back-Channel Logout Support

Closes gh-12570
Josh Cummings 2 лет назад
Родитель
Сommit
cb33fd7850
51 измененных файлов с 5397 добавлено и 114 удалено
  1. 11 0
      config/src/main/java/org/springframework/security/config/annotation/web/builders/HttpSecurity.java
  2. 1 1
      config/src/main/java/org/springframework/security/config/annotation/web/configurers/SessionManagementConfigurer.java
  3. 35 0
      config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/DefaultOidcLogoutTokenValidatorFactory.java
  4. 11 0
      config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OAuth2ClientConfigurerUtils.java
  5. 136 0
      config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OAuth2LoginConfigurer.java
  6. 66 0
      config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcBackChannelLogoutAuthentication.java
  7. 113 0
      config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcBackChannelLogoutAuthenticationProvider.java
  8. 139 0
      config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcBackChannelLogoutFilter.java
  9. 175 0
      config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcBackChannelLogoutHandler.java
  10. 118 0
      config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcBackChannelLogoutTokenValidator.java
  11. 85 0
      config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutAuthenticationConverter.java
  12. 80 0
      config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutAuthenticationToken.java
  13. 159 0
      config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurer.java
  14. 35 0
      config/src/main/java/org/springframework/security/config/web/server/DefaultOidcLogoutTokenValidatorFactory.java
  15. 66 0
      config/src/main/java/org/springframework/security/config/web/server/OidcBackChannelLogoutAuthentication.java
  16. 112 0
      config/src/main/java/org/springframework/security/config/web/server/OidcBackChannelLogoutReactiveAuthenticationManager.java
  17. 118 0
      config/src/main/java/org/springframework/security/config/web/server/OidcBackChannelLogoutTokenValidator.java
  18. 135 0
      config/src/main/java/org/springframework/security/config/web/server/OidcBackChannelLogoutWebFilter.java
  19. 183 0
      config/src/main/java/org/springframework/security/config/web/server/OidcBackChannelServerLogoutHandler.java
  20. 80 0
      config/src/main/java/org/springframework/security/config/web/server/OidcLogoutAuthenticationToken.java
  21. 88 0
      config/src/main/java/org/springframework/security/config/web/server/OidcLogoutServerAuthenticationConverter.java
  22. 345 2
      config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java
  23. 32 0
      config/src/main/kotlin/org/springframework/security/config/annotation/web/HttpSecurityDsl.kt
  24. 75 0
      config/src/main/kotlin/org/springframework/security/config/annotation/web/OidcLogoutDsl.kt
  25. 34 0
      config/src/main/kotlin/org/springframework/security/config/annotation/web/oauth2/login/OidcBackChannelLogoutDsl.kt
  26. 33 1
      config/src/main/kotlin/org/springframework/security/config/web/server/ServerHttpSecurityDsl.kt
  27. 30 0
      config/src/main/kotlin/org/springframework/security/config/web/server/ServerOidcBackChannelLogoutDsl.kt
  28. 71 0
      config/src/main/kotlin/org/springframework/security/config/web/server/ServerOidcLogoutDsl.kt
  29. 550 0
      config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurerTests.java
  30. 595 0
      config/src/test/java/org/springframework/security/config/web/server/OidcLogoutSpecTests.java
  31. 1 1
      config/src/test/java/org/springframework/security/config/web/server/ServerHttpSecurityTests.java
  32. 87 0
      config/src/test/kotlin/org/springframework/security/config/annotation/web/OidcLogoutDslTests.kt
  33. 97 0
      config/src/test/kotlin/org/springframework/security/config/web/server/ServerOidcLogoutDslTests.kt
  34. 1 107
      docs/modules/ROOT/pages/reactive/oauth2/login/advanced.adoc
  35. 267 0
      docs/modules/ROOT/pages/reactive/oauth2/login/logout.adoc
  36. 3 0
      docs/modules/ROOT/pages/servlet/oauth2/login/advanced.adoc
  37. 267 0
      docs/modules/ROOT/pages/servlet/oauth2/login/logout.adoc
  38. 2 1
      etc/nohttp/allowlist.lines
  39. 96 0
      oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/LogoutTokenClaimAccessor.java
  40. 70 0
      oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/LogoutTokenClaimNames.java
  41. 223 0
      oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/OidcLogoutToken.java
  42. 53 0
      oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/server/session/InMemoryReactiveOidcSessionRegistry.java
  43. 63 0
      oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/server/session/ReactiveOidcSessionRegistry.java
  44. 123 0
      oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/session/InMemoryOidcSessionRegistry.java
  45. 74 0
      oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/session/OidcSessionInformation.java
  46. 59 0
      oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/session/OidcSessionRegistry.java
  47. 50 0
      oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/authentication/logout/TestOidcLogoutTokens.java
  48. 102 0
      oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/session/InMemoryOidcSessionRegistryTests.java
  49. 45 0
      oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/session/TestOidcSessionInformations.java
  50. 2 0
      oauth2/oauth2-core/src/test/java/org/springframework/security/oauth2/core/oidc/TestOidcIdTokens.java
  51. 1 1
      oauth2/oauth2-core/src/test/java/org/springframework/security/oauth2/core/oidc/user/TestOidcUsers.java

+ 11 - 0
config/src/main/java/org/springframework/security/config/annotation/web/builders/HttpSecurity.java

@@ -70,6 +70,7 @@ import org.springframework.security.config.annotation.web.configurers.SessionMan
 import org.springframework.security.config.annotation.web.configurers.X509Configurer;
 import org.springframework.security.config.annotation.web.configurers.oauth2.client.OAuth2ClientConfigurer;
 import org.springframework.security.config.annotation.web.configurers.oauth2.client.OAuth2LoginConfigurer;
+import org.springframework.security.config.annotation.web.configurers.oauth2.client.OidcLogoutConfigurer;
 import org.springframework.security.config.annotation.web.configurers.oauth2.server.resource.OAuth2ResourceServerConfigurer;
 import org.springframework.security.config.annotation.web.configurers.saml2.Saml2LoginConfigurer;
 import org.springframework.security.config.annotation.web.configurers.saml2.Saml2LogoutConfigurer;
@@ -2835,6 +2836,16 @@ public final class HttpSecurity extends AbstractConfiguredSecurityBuilder<Defaul
 		return HttpSecurity.this;
 	}
 
+	public OidcLogoutConfigurer<HttpSecurity> oidcLogout() throws Exception {
+		return getOrApply(new OidcLogoutConfigurer<>());
+	}
+
+	public HttpSecurity oidcLogout(Customizer<OidcLogoutConfigurer<HttpSecurity>> oidcLogoutCustomizer)
+			throws Exception {
+		oidcLogoutCustomizer.customize(getOrApply(new OidcLogoutConfigurer<>()));
+		return HttpSecurity.this;
+	}
+
 	/**
 	 * Configures OAuth 2.0 Client support.
 	 * @return the {@link OAuth2ClientConfigurer} for further customizations

+ 1 - 1
config/src/main/java/org/springframework/security/config/annotation/web/configurers/SessionManagementConfigurer.java

@@ -296,7 +296,7 @@ public final class SessionManagementConfigurer<H extends HttpSecurityBuilder<H>>
 	 * @param sessionAuthenticationStrategy
 	 * @return the {@link SessionManagementConfigurer} for further customizations
 	 */
-	SessionManagementConfigurer<H> addSessionAuthenticationStrategy(
+	public SessionManagementConfigurer<H> addSessionAuthenticationStrategy(
 			SessionAuthenticationStrategy sessionAuthenticationStrategy) {
 		this.sessionAuthenticationStrategies.add(sessionAuthenticationStrategy);
 		return this;

+ 35 - 0
config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/DefaultOidcLogoutTokenValidatorFactory.java

@@ -0,0 +1,35 @@
+/*
+ * Copyright 2002-2023 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.config.annotation.web.configurers.oauth2.client;
+
+import java.util.function.Function;
+
+import org.springframework.security.oauth2.client.registration.ClientRegistration;
+import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
+import org.springframework.security.oauth2.core.OAuth2TokenValidator;
+import org.springframework.security.oauth2.jwt.Jwt;
+import org.springframework.security.oauth2.jwt.JwtTimestampValidator;
+
+final class DefaultOidcLogoutTokenValidatorFactory implements Function<ClientRegistration, OAuth2TokenValidator<Jwt>> {
+
+	@Override
+	public OAuth2TokenValidator<Jwt> apply(ClientRegistration clientRegistration) {
+		return new DelegatingOAuth2TokenValidator<>(new JwtTimestampValidator(),
+				new OidcBackChannelLogoutTokenValidator(clientRegistration));
+	}
+
+}

+ 11 - 0
config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OAuth2ClientConfigurerUtils.java

@@ -25,6 +25,8 @@ import org.springframework.security.config.annotation.web.HttpSecurityBuilder;
 import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
 import org.springframework.security.oauth2.client.InMemoryOAuth2AuthorizedClientService;
 import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService;
+import org.springframework.security.oauth2.client.oidc.session.InMemoryOidcSessionRegistry;
+import org.springframework.security.oauth2.client.oidc.session.OidcSessionRegistry;
 import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
 import org.springframework.security.oauth2.client.web.AuthenticatedPrincipalOAuth2AuthorizedClientRepository;
 import org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepository;
@@ -112,4 +114,13 @@ final class OAuth2ClientConfigurerUtils {
 		return (!authorizedClientServiceMap.isEmpty() ? authorizedClientServiceMap.values().iterator().next() : null);
 	}
 
+	static <B extends HttpSecurityBuilder<B>> OidcSessionRegistry getOidcSessionRegistry(B builder) {
+		OidcSessionRegistry sessionRegistry = builder.getSharedObject(OidcSessionRegistry.class);
+		if (sessionRegistry == null) {
+			sessionRegistry = new InMemoryOidcSessionRegistry();
+			builder.setSharedObject(OidcSessionRegistry.class, sessionRegistry);
+		}
+		return sessionRegistry;
+	}
+
 }

+ 136 - 0
config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OAuth2LoginConfigurer.java

@@ -22,9 +22,18 @@ import java.util.HashMap;
 import java.util.LinkedHashMap;
 import java.util.Map;
 
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import jakarta.servlet.http.HttpSession;
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+
 import org.springframework.beans.factory.BeanFactoryUtils;
 import org.springframework.beans.factory.NoUniqueBeanDefinitionException;
 import org.springframework.context.ApplicationContext;
+import org.springframework.context.ApplicationListener;
+import org.springframework.context.event.GenericApplicationListenerAdapter;
+import org.springframework.context.event.SmartApplicationListener;
 import org.springframework.core.ResolvableType;
 import org.springframework.security.authentication.AuthenticationProvider;
 import org.springframework.security.config.Customizer;
@@ -32,9 +41,14 @@ import org.springframework.security.config.annotation.web.HttpSecurityBuilder;
 import org.springframework.security.config.annotation.web.builders.HttpSecurity;
 import org.springframework.security.config.annotation.web.configurers.AbstractAuthenticationFilterConfigurer;
 import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
+import org.springframework.security.config.annotation.web.configurers.SessionManagementConfigurer;
+import org.springframework.security.context.DelegatingApplicationListener;
 import org.springframework.security.core.Authentication;
 import org.springframework.security.core.AuthenticationException;
 import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper;
+import org.springframework.security.core.session.AbstractSessionEvent;
+import org.springframework.security.core.session.SessionDestroyedEvent;
+import org.springframework.security.core.session.SessionIdChangedEvent;
 import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService;
 import org.springframework.security.oauth2.client.authentication.OAuth2LoginAuthenticationProvider;
 import org.springframework.security.oauth2.client.authentication.OAuth2LoginAuthenticationToken;
@@ -42,6 +56,9 @@ import org.springframework.security.oauth2.client.endpoint.DefaultAuthorizationC
 import org.springframework.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient;
 import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest;
 import org.springframework.security.oauth2.client.oidc.authentication.OidcAuthorizationCodeAuthenticationProvider;
+import org.springframework.security.oauth2.client.oidc.session.InMemoryOidcSessionRegistry;
+import org.springframework.security.oauth2.client.oidc.session.OidcSessionInformation;
+import org.springframework.security.oauth2.client.oidc.session.OidcSessionRegistry;
 import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest;
 import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserService;
 import org.springframework.security.oauth2.client.registration.ClientRegistration;
@@ -67,7 +84,10 @@ import org.springframework.security.web.AuthenticationEntryPoint;
 import org.springframework.security.web.RedirectStrategy;
 import org.springframework.security.web.authentication.DelegatingAuthenticationEntryPoint;
 import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
+import org.springframework.security.web.authentication.session.SessionAuthenticationException;
+import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy;
 import org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter;
+import org.springframework.security.web.csrf.CsrfToken;
 import org.springframework.security.web.savedrequest.RequestCache;
 import org.springframework.security.web.util.matcher.AndRequestMatcher;
 import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
@@ -124,6 +144,7 @@ import org.springframework.util.ReflectionUtils;
  * <li>{@link DefaultLoginPageGeneratingFilter} - if {@link #loginPage(String)} is not
  * configured and {@code DefaultLoginPageGeneratingFilter} is available, then a default
  * login page will be made available</li>
+ * <li>{@link OidcSessionRegistry}</li>
  * </ul>
  *
  * @author Joe Grandja
@@ -202,6 +223,18 @@ public final class OAuth2LoginConfigurer<B extends HttpSecurityBuilder<B>>
 		return this;
 	}
 
+	/**
+	 * Sets the registry for managing the OIDC client-provider session link
+	 * @param oidcSessionRegistry the {@link OidcSessionRegistry} to use
+	 * @return the {@link OAuth2LoginConfigurer} for further configuration
+	 * @since 6.2
+	 */
+	public OAuth2LoginConfigurer<B> oidcSessionRegistry(OidcSessionRegistry oidcSessionRegistry) {
+		Assert.notNull(oidcSessionRegistry, "oidcSessionRegistry cannot be null");
+		getBuilder().setSharedObject(OidcSessionRegistry.class, oidcSessionRegistry);
+		return this;
+	}
+
 	/**
 	 * Returns the {@link AuthorizationEndpointConfig} for configuring the Authorization
 	 * Server's Authorization Endpoint.
@@ -397,6 +430,7 @@ public final class OAuth2LoginConfigurer<B extends HttpSecurityBuilder<B>>
 			authenticationFilter
 					.setAuthorizationRequestRepository(this.authorizationEndpointConfig.authorizationRequestRepository);
 		}
+		configureOidcSessionRegistry(http);
 		super.configure(http);
 	}
 
@@ -546,6 +580,29 @@ public final class OAuth2LoginConfigurer<B extends HttpSecurityBuilder<B>>
 		return AnyRequestMatcher.INSTANCE;
 	}
 
+	private void configureOidcSessionRegistry(B http) {
+		OidcSessionRegistry sessionRegistry = OAuth2ClientConfigurerUtils.getOidcSessionRegistry(http);
+		SessionManagementConfigurer<B> sessionConfigurer = http.getConfigurer(SessionManagementConfigurer.class);
+		if (sessionConfigurer != null) {
+			OidcSessionRegistryAuthenticationStrategy sessionAuthenticationStrategy = new OidcSessionRegistryAuthenticationStrategy();
+			sessionAuthenticationStrategy.setSessionRegistry(sessionRegistry);
+			sessionConfigurer.addSessionAuthenticationStrategy(sessionAuthenticationStrategy);
+		}
+		OidcClientSessionEventListener listener = new OidcClientSessionEventListener();
+		listener.setSessionRegistry(sessionRegistry);
+		registerDelegateApplicationListener(listener);
+	}
+
+	private void registerDelegateApplicationListener(ApplicationListener<?> delegate) {
+		DelegatingApplicationListener delegating = getBeanOrNull(
+				ResolvableType.forType(DelegatingApplicationListener.class));
+		if (delegating == null) {
+			return;
+		}
+		SmartApplicationListener smartListener = new GenericApplicationListenerAdapter(delegate);
+		delegating.addListener(smartListener);
+	}
+
 	/**
 	 * Configuration options for the Authorization Server's Authorization Endpoint.
 	 */
@@ -793,4 +850,83 @@ public final class OAuth2LoginConfigurer<B extends HttpSecurityBuilder<B>>
 
 	}
 
+	private static final class OidcClientSessionEventListener implements ApplicationListener<AbstractSessionEvent> {
+
+		private final Log logger = LogFactory.getLog(OidcClientSessionEventListener.class);
+
+		private OidcSessionRegistry sessionRegistry = new InMemoryOidcSessionRegistry();
+
+		/**
+		 * {@inheritDoc}
+		 */
+		@Override
+		public void onApplicationEvent(AbstractSessionEvent event) {
+			if (event instanceof SessionDestroyedEvent destroyed) {
+				this.logger.debug("Received SessionDestroyedEvent");
+				this.sessionRegistry.removeSessionInformation(destroyed.getId());
+				return;
+			}
+			if (event instanceof SessionIdChangedEvent changed) {
+				this.logger.debug("Received SessionIdChangedEvent");
+				OidcSessionInformation information = this.sessionRegistry.removeSessionInformation(changed.getOldSessionId());
+				if (information == null) {
+					this.logger.debug("Failed to register new session id since old session id was not found in registry");
+					return;
+				}
+				this.sessionRegistry.saveSessionInformation(information.withSessionId(changed.getNewSessionId()));
+			}
+		}
+
+		/**
+		 * The registry where OIDC Provider sessions are linked to the Client session.
+		 * Defaults to in-memory storage.
+		 * @param sessionRegistry the {@link OidcSessionRegistry} to use
+		 */
+		void setSessionRegistry(OidcSessionRegistry sessionRegistry) {
+			Assert.notNull(sessionRegistry, "sessionRegistry cannot be null");
+			this.sessionRegistry = sessionRegistry;
+		}
+
+	}
+
+	private static final class OidcSessionRegistryAuthenticationStrategy implements SessionAuthenticationStrategy {
+
+		private final Log logger = LogFactory.getLog(getClass());
+
+		private OidcSessionRegistry sessionRegistry = new InMemoryOidcSessionRegistry();
+
+		/**
+		 * {@inheritDoc}
+		 */
+		@Override
+		public void onAuthentication(Authentication authentication, HttpServletRequest request, HttpServletResponse response) throws SessionAuthenticationException {
+			HttpSession session = request.getSession(false);
+			if (session == null) {
+				return;
+			}
+			if (!(authentication.getPrincipal() instanceof OidcUser user)) {
+				return;
+			}
+			String sessionId = session.getId();
+			CsrfToken csrfToken = (CsrfToken) request.getAttribute(CsrfToken.class.getName());
+			Map<String, String> headers = (csrfToken != null) ? Map.of(csrfToken.getHeaderName(), csrfToken.getToken()) : Collections.emptyMap();
+			OidcSessionInformation registration = new OidcSessionInformation(sessionId, headers, user);
+			if (this.logger.isTraceEnabled()) {
+				this.logger.trace(String.format("Linking a provider [%s] session to this client's session", user.getIssuer()));
+			}
+			this.sessionRegistry.saveSessionInformation(registration);
+		}
+
+		/**
+		 * The registration for linking OIDC Provider Session information to the Client's
+		 * session. Defaults to in-memory storage.
+		 * @param sessionRegistry the {@link OidcSessionRegistry} to use
+		 */
+		void setSessionRegistry(OidcSessionRegistry sessionRegistry) {
+			Assert.notNull(sessionRegistry, "sessionRegistry cannot be null");
+			this.sessionRegistry = sessionRegistry;
+		}
+
+	}
+
 }

+ 66 - 0
config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcBackChannelLogoutAuthentication.java

@@ -0,0 +1,66 @@
+/*
+ * Copyright 2002-2023 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.config.annotation.web.configurers.oauth2.client;
+
+import java.util.Collections;
+
+import org.springframework.security.authentication.AbstractAuthenticationToken;
+import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcLogoutToken;
+
+/**
+ * An {@link org.springframework.security.core.Authentication} implementation that
+ * represents the result of authenticating an OIDC Logout token for the purposes of
+ * performing Back-Channel Logout.
+ *
+ * @author Josh Cummings
+ * @since 6.2
+ * @see OidcLogoutAuthenticationToken
+ * @see <a target="_blank" href=
+ * "https://openid.net/specs/openid-connect-backchannel-1_0.html">OIDC Back-Channel
+ * Logout</a>
+ */
+class OidcBackChannelLogoutAuthentication extends AbstractAuthenticationToken {
+
+	private final OidcLogoutToken logoutToken;
+
+	/**
+	 * Construct an {@link OidcBackChannelLogoutAuthentication}
+	 * @param logoutToken a deserialized, verified OIDC Logout Token
+	 */
+	OidcBackChannelLogoutAuthentication(OidcLogoutToken logoutToken) {
+		super(Collections.emptyList());
+		this.logoutToken = logoutToken;
+		setAuthenticated(true);
+	}
+
+	/**
+	 * {@inheritDoc}
+	 */
+	@Override
+	public OidcLogoutToken getPrincipal() {
+		return this.logoutToken;
+	}
+
+	/**
+	 * {@inheritDoc}
+	 */
+	@Override
+	public OidcLogoutToken getCredentials() {
+		return this.logoutToken;
+	}
+
+}

+ 113 - 0
config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcBackChannelLogoutAuthenticationProvider.java

@@ -0,0 +1,113 @@
+/*
+ * Copyright 2002-2023 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.config.annotation.web.configurers.oauth2.client;
+
+import org.springframework.security.authentication.AuthenticationProvider;
+import org.springframework.security.authentication.AuthenticationServiceException;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.AuthenticationException;
+import org.springframework.security.oauth2.client.oidc.authentication.OidcIdTokenDecoderFactory;
+import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcLogoutToken;
+import org.springframework.security.oauth2.client.registration.ClientRegistration;
+import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
+import org.springframework.security.oauth2.core.OAuth2Error;
+import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
+import org.springframework.security.oauth2.jwt.BadJwtException;
+import org.springframework.security.oauth2.jwt.Jwt;
+import org.springframework.security.oauth2.jwt.JwtDecoder;
+import org.springframework.security.oauth2.jwt.JwtDecoderFactory;
+import org.springframework.util.Assert;
+
+/**
+ * An {@link AuthenticationProvider} that authenticates an OIDC Logout Token; namely
+ * deserializing it, verifying its signature, and validating its claims.
+ *
+ * <p>
+ * Intended to be included in a
+ * {@link org.springframework.security.authentication.ProviderManager}
+ *
+ * @author Josh Cummings
+ * @since 6.2
+ * @see OidcLogoutAuthenticationToken
+ * @see org.springframework.security.authentication.ProviderManager
+ * @see <a target="_blank" href=
+ * "https://openid.net/specs/openid-connect-backchannel-1_0.html">OIDC Back-Channel
+ * Logout</a>
+ */
+final class OidcBackChannelLogoutAuthenticationProvider implements AuthenticationProvider {
+
+	private JwtDecoderFactory<ClientRegistration> logoutTokenDecoderFactory;
+
+	/**
+	 * Construct an {@link OidcBackChannelLogoutAuthenticationProvider}
+	 */
+	OidcBackChannelLogoutAuthenticationProvider() {
+		OidcIdTokenDecoderFactory logoutTokenDecoderFactory = new OidcIdTokenDecoderFactory();
+		logoutTokenDecoderFactory.setJwtValidatorFactory(new DefaultOidcLogoutTokenValidatorFactory());
+		this.logoutTokenDecoderFactory = logoutTokenDecoderFactory;
+	}
+
+	/**
+	 * {@inheritDoc}
+	 */
+	@Override
+	public Authentication authenticate(Authentication authentication) throws AuthenticationException {
+		if (!(authentication instanceof OidcLogoutAuthenticationToken token)) {
+			return null;
+		}
+		String logoutToken = token.getLogoutToken();
+		ClientRegistration registration = token.getClientRegistration();
+		Jwt jwt = decode(registration, logoutToken);
+		OidcLogoutToken oidcLogoutToken = OidcLogoutToken.withTokenValue(logoutToken)
+				.claims((claims) -> claims.putAll(jwt.getClaims())).build();
+		return new OidcBackChannelLogoutAuthentication(oidcLogoutToken);
+	}
+
+	/**
+	 * {@inheritDoc}
+	 */
+	@Override
+	public boolean supports(Class<?> authentication) {
+		return OidcLogoutAuthenticationToken.class.isAssignableFrom(authentication);
+	}
+
+	private Jwt decode(ClientRegistration registration, String token) {
+		JwtDecoder logoutTokenDecoder = this.logoutTokenDecoderFactory.createDecoder(registration);
+		try {
+			return logoutTokenDecoder.decode(token);
+		}
+		catch (BadJwtException failed) {
+			OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.INVALID_REQUEST, failed.getMessage(),
+					"https://openid.net/specs/openid-connect-backchannel-1_0.html#Validation");
+			throw new OAuth2AuthenticationException(error, failed);
+		}
+		catch (Exception failed) {
+			throw new AuthenticationServiceException(failed.getMessage(), failed);
+		}
+	}
+
+	/**
+	 * Use this {@link JwtDecoderFactory} to generate {@link JwtDecoder}s that correspond
+	 * to the {@link ClientRegistration} associated with the OIDC logout token.
+	 * @param logoutTokenDecoderFactory the {@link JwtDecoderFactory} to use
+	 */
+	void setLogoutTokenDecoderFactory(JwtDecoderFactory<ClientRegistration> logoutTokenDecoderFactory) {
+		Assert.notNull(logoutTokenDecoderFactory, "logoutTokenDecoderFactory cannot be null");
+		this.logoutTokenDecoderFactory = logoutTokenDecoderFactory;
+	}
+
+}

+ 139 - 0
config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcBackChannelLogoutFilter.java

@@ -0,0 +1,139 @@
+/*
+ * Copyright 2002-2023 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.config.annotation.web.configurers.oauth2.client;
+
+import java.io.IOException;
+
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+
+import org.springframework.http.server.ServletServerHttpResponse;
+import org.springframework.security.authentication.AuthenticationManager;
+import org.springframework.security.authentication.AuthenticationServiceException;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.AuthenticationException;
+import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
+import org.springframework.security.oauth2.core.OAuth2Error;
+import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
+import org.springframework.security.oauth2.core.http.converter.OAuth2ErrorHttpMessageConverter;
+import org.springframework.security.web.authentication.AuthenticationConverter;
+import org.springframework.security.web.authentication.logout.LogoutHandler;
+import org.springframework.util.Assert;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+/**
+ * A filter for the Client-side OIDC Back-Channel Logout endpoint
+ *
+ * @author Josh Cummings
+ * @since 6.2
+ * @see <a target="_blank" href=
+ * "https://openid.net/specs/openid-connect-backchannel-1_0.html">OIDC Back-Channel Logout
+ * Spec</a>
+ */
+class OidcBackChannelLogoutFilter extends OncePerRequestFilter {
+
+	private final Log logger = LogFactory.getLog(getClass());
+
+	private final AuthenticationConverter authenticationConverter;
+
+	private final AuthenticationManager authenticationManager;
+
+	private final OAuth2ErrorHttpMessageConverter errorHttpMessageConverter = new OAuth2ErrorHttpMessageConverter();
+
+	private LogoutHandler logoutHandler = new OidcBackChannelLogoutHandler();
+
+	/**
+	 * Construct an {@link OidcBackChannelLogoutFilter}
+	 * @param authenticationConverter the {@link AuthenticationConverter} for deriving
+	 * Logout Token authentication
+	 * @param authenticationManager the {@link AuthenticationManager} for authenticating
+	 * Logout Tokens
+	 */
+	OidcBackChannelLogoutFilter(AuthenticationConverter authenticationConverter,
+			AuthenticationManager authenticationManager) {
+		Assert.notNull(authenticationConverter, "authenticationConverter cannot be null");
+		Assert.notNull(authenticationManager, "authenticationManager cannot be null");
+		this.authenticationConverter = authenticationConverter;
+		this.authenticationManager = authenticationManager;
+	}
+
+	/**
+	 * {@inheritDoc}
+	 */
+	@Override
+	protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
+			throws ServletException, IOException {
+		Authentication token;
+		try {
+			token = this.authenticationConverter.convert(request);
+		}
+		catch (AuthenticationServiceException ex) {
+			this.logger.debug("Failed to process OIDC Back-Channel Logout", ex);
+			throw ex;
+		}
+		catch (AuthenticationException ex) {
+			handleAuthenticationFailure(response, ex);
+			return;
+		}
+		if (token == null) {
+			chain.doFilter(request, response);
+			return;
+		}
+		Authentication authentication;
+		try {
+			authentication = this.authenticationManager.authenticate(token);
+		}
+		catch (AuthenticationServiceException ex) {
+			this.logger.debug("Failed to process OIDC Back-Channel Logout", ex);
+			throw ex;
+		}
+		catch (AuthenticationException ex) {
+			handleAuthenticationFailure(response, ex);
+			return;
+		}
+		this.logoutHandler.logout(request, response, authentication);
+	}
+
+	private void handleAuthenticationFailure(HttpServletResponse response, Exception ex) throws IOException {
+		this.logger.debug("Failed to process OIDC Back-Channel Logout", ex);
+		response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
+		this.errorHttpMessageConverter.write(oauth2Error(ex), null, new ServletServerHttpResponse(response));
+	}
+
+	private OAuth2Error oauth2Error(Exception ex) {
+		if (ex instanceof OAuth2AuthenticationException oauth2) {
+			return oauth2.getError();
+		}
+		return new OAuth2Error(OAuth2ErrorCodes.INVALID_REQUEST, ex.getMessage(),
+				"https://openid.net/specs/openid-connect-backchannel-1_0.html#Validation");
+	}
+
+	/**
+	 * The strategy for expiring all Client sessions indicated by the logout request.
+	 * Defaults to {@link OidcBackChannelLogoutHandler}.
+	 * @param logoutHandler the {@link LogoutHandler} to use
+	 */
+	void setLogoutHandler(LogoutHandler logoutHandler) {
+		Assert.notNull(logoutHandler, "logoutHandler cannot be null");
+		this.logoutHandler = logoutHandler;
+	}
+
+}

+ 175 - 0
config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcBackChannelLogoutHandler.java

@@ -0,0 +1,175 @@
+/*
+ * Copyright 2002-2023 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.config.annotation.web.configurers.oauth2.client;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Map;
+
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.server.ServletServerHttpResponse;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcLogoutToken;
+import org.springframework.security.oauth2.client.oidc.session.InMemoryOidcSessionRegistry;
+import org.springframework.security.oauth2.client.oidc.session.OidcSessionInformation;
+import org.springframework.security.oauth2.client.oidc.session.OidcSessionRegistry;
+import org.springframework.security.oauth2.core.OAuth2Error;
+import org.springframework.security.oauth2.core.http.converter.OAuth2ErrorHttpMessageConverter;
+import org.springframework.security.web.authentication.logout.LogoutHandler;
+import org.springframework.util.Assert;
+import org.springframework.web.client.RestClientException;
+import org.springframework.web.client.RestOperations;
+import org.springframework.web.client.RestTemplate;
+import org.springframework.web.util.UriComponentsBuilder;
+
+/**
+ * A {@link LogoutHandler} that locates the sessions associated with a given OIDC
+ * Back-Channel Logout Token and invalidates each one.
+ *
+ * @author Josh Cummings
+ * @since 6.2
+ * @see <a target="_blank" href=
+ * "https://openid.net/specs/openid-connect-backchannel-1_0.html">OIDC Back-Channel Logout
+ * Spec</a>
+ */
+final class OidcBackChannelLogoutHandler implements LogoutHandler {
+
+	private final Log logger = LogFactory.getLog(getClass());
+
+	private OidcSessionRegistry sessionRegistry = new InMemoryOidcSessionRegistry();
+
+	private RestOperations restOperations = new RestTemplate();
+
+	private String logoutEndpointName = "/logout";
+
+	private String sessionCookieName = "JSESSIONID";
+
+	private final OAuth2ErrorHttpMessageConverter errorHttpMessageConverter = new OAuth2ErrorHttpMessageConverter();
+
+	@Override
+	public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
+		if (!(authentication instanceof OidcBackChannelLogoutAuthentication token)) {
+			if (this.logger.isDebugEnabled()) {
+				String message = "Did not perform OIDC Back-Channel Logout since authentication [%s] was of the wrong type";
+				this.logger.debug(String.format(message, authentication.getClass().getSimpleName()));
+			}
+			return;
+		}
+		Iterable<OidcSessionInformation> sessions = this.sessionRegistry.removeSessionInformation(token.getPrincipal());
+		Collection<String> errors = new ArrayList<>();
+		int totalCount = 0;
+		int invalidatedCount = 0;
+		for (OidcSessionInformation session : sessions) {
+			totalCount++;
+			try {
+				eachLogout(request, session);
+				invalidatedCount++;
+			}
+			catch (RestClientException ex) {
+				this.logger.debug("Failed to invalidate session", ex);
+				errors.add(ex.getMessage());
+				this.sessionRegistry.saveSessionInformation(session);
+			}
+		}
+		if (this.logger.isTraceEnabled()) {
+			this.logger.trace(String.format("Invalidated %d out of %d sessions", invalidatedCount, totalCount));
+		}
+		if (!errors.isEmpty()) {
+			handleLogoutFailure(response, oauth2Error(errors));
+		}
+	}
+
+	private void eachLogout(HttpServletRequest request, OidcSessionInformation session) {
+		HttpHeaders headers = new HttpHeaders();
+		headers.add(HttpHeaders.COOKIE, this.sessionCookieName + "=" + session.getSessionId());
+		for (Map.Entry<String, String> credential : session.getAuthorities().entrySet()) {
+			headers.add(credential.getKey(), credential.getValue());
+		}
+		String url = request.getRequestURL().toString();
+		String logout = UriComponentsBuilder.fromHttpUrl(url).replacePath(this.logoutEndpointName).build()
+				.toUriString();
+		HttpEntity<?> entity = new HttpEntity<>(null, headers);
+		this.restOperations.postForEntity(logout, entity, Object.class);
+	}
+
+	private OAuth2Error oauth2Error(Collection<String> errors) {
+		return new OAuth2Error("partial_logout", "not all sessions were terminated: " + errors,
+				"https://openid.net/specs/openid-connect-backchannel-1_0.html#Validation");
+	}
+
+	private void handleLogoutFailure(HttpServletResponse response, OAuth2Error error) {
+		response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
+		try {
+			this.errorHttpMessageConverter.write(error, null, new ServletServerHttpResponse(response));
+		}
+		catch (IOException ex) {
+			throw new IllegalStateException(ex);
+		}
+	}
+
+	/**
+	 * Use this {@link OidcSessionRegistry} to identify sessions to invalidate. Note that
+	 * this class uses
+	 * {@link OidcSessionRegistry#removeSessionInformation(OidcLogoutToken)} to identify
+	 * sessions.
+	 * @param sessionRegistry the {@link OidcSessionRegistry} to use
+	 */
+	void setSessionRegistry(OidcSessionRegistry sessionRegistry) {
+		Assert.notNull(sessionRegistry, "sessionRegistry cannot be null");
+		this.sessionRegistry = sessionRegistry;
+	}
+
+	/**
+	 * Use this {@link RestOperations} to perform the per-session back-channel logout
+	 * @param restOperations the {@link RestOperations} to use
+	 */
+	void setRestOperations(RestOperations restOperations) {
+		Assert.notNull(restOperations, "restOperations cannot be null");
+		this.restOperations = restOperations;
+	}
+
+	/**
+	 * Use this logout URI for performing per-session logout. Defaults to {@code /logout}
+	 * since that is the default URI for
+	 * {@link org.springframework.security.web.authentication.logout.LogoutFilter}.
+	 * @param logoutUri the URI to use
+	 */
+	void setLogoutUri(String logoutUri) {
+		Assert.hasText(logoutUri, "logoutUri cannot be empty");
+		this.logoutEndpointName = logoutUri;
+	}
+
+	/**
+	 * Use this cookie name for the session identifier. Defaults to {@code JSESSIONID}.
+	 *
+	 * <p>
+	 * Note that if you are using Spring Session, this likely needs to change to SESSION.
+	 * @param sessionCookieName the cookie name to use
+	 */
+	void setSessionCookieName(String sessionCookieName) {
+		Assert.hasText(sessionCookieName, "clientSessionCookieName cannot be empty");
+		this.sessionCookieName = sessionCookieName;
+	}
+
+}

+ 118 - 0
config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcBackChannelLogoutTokenValidator.java

@@ -0,0 +1,118 @@
+/*
+ * Copyright 2002-2023 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.config.annotation.web.configurers.oauth2.client;
+
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+
+import org.springframework.security.oauth2.client.oidc.authentication.logout.LogoutTokenClaimAccessor;
+import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcLogoutToken;
+import org.springframework.security.oauth2.client.registration.ClientRegistration;
+import org.springframework.security.oauth2.core.OAuth2Error;
+import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
+import org.springframework.security.oauth2.core.OAuth2TokenValidator;
+import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
+import org.springframework.security.oauth2.jwt.Jwt;
+
+/**
+ * A {@link OAuth2TokenValidator} that validates OIDC Logout Token claims in conformance
+ * with the OIDC Back-Channel Logout Spec.
+ *
+ * @author Josh Cummings
+ * @since 6.2
+ * @see OidcLogoutToken
+ * @see <a target="_blank" href=
+ * "https://openid.net/specs/openid-connect-backchannel-1_0.html#LogoutToken">Logout
+ * Token</a>
+ * @see <a target="blank" href=
+ * "https://openid.net/specs/openid-connect-backchannel-1_0.html#Validation">the OIDC
+ * Back-Channel Logout spec</a>
+ */
+final class OidcBackChannelLogoutTokenValidator implements OAuth2TokenValidator<Jwt> {
+
+	private static final String LOGOUT_VALIDATION_URL = "https://openid.net/specs/openid-connect-backchannel-1_0.html#Validation";
+
+	private static final String BACK_CHANNEL_LOGOUT_EVENT = "http://schemas.openid.net/event/backchannel-logout";
+
+	private final String audience;
+
+	private final String issuer;
+
+	OidcBackChannelLogoutTokenValidator(ClientRegistration clientRegistration) {
+		this.audience = clientRegistration.getClientId();
+		this.issuer = clientRegistration.getProviderDetails().getIssuerUri();
+	}
+
+	@Override
+	public OAuth2TokenValidatorResult validate(Jwt jwt) {
+		Collection<OAuth2Error> errors = new ArrayList<>();
+
+		LogoutTokenClaimAccessor logoutClaims = jwt::getClaims;
+		Map<String, Object> events = logoutClaims.getEvents();
+		if (events == null) {
+			errors.add(invalidLogoutToken("events claim must not be null"));
+		}
+		else if (events.get(BACK_CHANNEL_LOGOUT_EVENT) == null) {
+			errors.add(invalidLogoutToken("events claim map must contain \"" + BACK_CHANNEL_LOGOUT_EVENT + "\" key"));
+		}
+
+		String issuer = logoutClaims.getIssuer().toExternalForm();
+		if (issuer == null) {
+			errors.add(invalidLogoutToken("iss claim must not be null"));
+		}
+		else if (!this.issuer.equals(issuer)) {
+			errors.add(invalidLogoutToken(
+					"iss claim value must match `ClientRegistration#getProviderDetails#getIssuerUri`"));
+		}
+
+		List<String> audience = logoutClaims.getAudience();
+		if (audience == null) {
+			errors.add(invalidLogoutToken("aud claim must not be null"));
+		}
+		else if (!audience.contains(this.audience)) {
+			errors.add(invalidLogoutToken("aud claim value must include `ClientRegistration#getClientId`"));
+		}
+
+		Instant issuedAt = logoutClaims.getIssuedAt();
+		if (issuedAt == null) {
+			errors.add(invalidLogoutToken("iat claim must not be null"));
+		}
+
+		String jwtId = logoutClaims.getId();
+		if (jwtId == null) {
+			errors.add(invalidLogoutToken("jti claim must not be null"));
+		}
+
+		if (logoutClaims.getSubject() == null && logoutClaims.getSessionId() == null) {
+			errors.add(invalidLogoutToken("sub and sid claims must not both be null"));
+		}
+
+		if (logoutClaims.getClaim("nonce") != null) {
+			errors.add(invalidLogoutToken("nonce claim must not be present"));
+		}
+
+		return OAuth2TokenValidatorResult.failure(errors);
+	}
+
+	private static OAuth2Error invalidLogoutToken(String description) {
+		return new OAuth2Error(OAuth2ErrorCodes.INVALID_TOKEN, description, LOGOUT_VALIDATION_URL);
+	}
+
+}

+ 85 - 0
config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutAuthenticationConverter.java

@@ -0,0 +1,85 @@
+/*
+ * Copyright 2002-2023 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.config.annotation.web.configurers.oauth2.client;
+
+import jakarta.servlet.http.HttpServletRequest;
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+
+import org.springframework.security.core.Authentication;
+import org.springframework.security.oauth2.client.registration.ClientRegistration;
+import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
+import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
+import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
+import org.springframework.security.web.authentication.AuthenticationConverter;
+import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
+import org.springframework.security.web.util.matcher.RequestMatcher;
+import org.springframework.util.Assert;
+
+/**
+ * An {@link AuthenticationConverter} that extracts the OIDC Logout Token authentication
+ * request
+ *
+ * @author Josh Cummings
+ * @since 6.2
+ */
+final class OidcLogoutAuthenticationConverter implements AuthenticationConverter {
+
+	private static final String DEFAULT_LOGOUT_URI = "/logout/connect/back-channel/{registrationId}";
+
+	private final Log logger = LogFactory.getLog(getClass());
+
+	private final ClientRegistrationRepository clientRegistrationRepository;
+
+	private RequestMatcher requestMatcher = new AntPathRequestMatcher(DEFAULT_LOGOUT_URI, "POST");
+
+	OidcLogoutAuthenticationConverter(ClientRegistrationRepository clientRegistrationRepository) {
+		Assert.notNull(clientRegistrationRepository, "clientRegistrationRepository cannot be null");
+		this.clientRegistrationRepository = clientRegistrationRepository;
+	}
+
+	@Override
+	public Authentication convert(HttpServletRequest request) {
+		RequestMatcher.MatchResult result = this.requestMatcher.matcher(request);
+		if (!result.isMatch()) {
+			return null;
+		}
+		String registrationId = result.getVariables().get("registrationId");
+		ClientRegistration clientRegistration = this.clientRegistrationRepository.findByRegistrationId(registrationId);
+		if (clientRegistration == null) {
+			this.logger.debug("Did not process OIDC Back-Channel Logout since no ClientRegistration was found");
+			throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_REQUEST);
+		}
+		String logoutToken = request.getParameter("logout_token");
+		if (logoutToken == null) {
+			this.logger.debug("Failed to process OIDC Back-Channel Logout since no logout token was found");
+			throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_REQUEST);
+		}
+		return new OidcLogoutAuthenticationToken(logoutToken, clientRegistration);
+	}
+
+	/**
+	 * The logout endpoint. Defaults to
+	 * {@code /logout/connect/back-channel/{registrationId}}.
+	 * @param requestMatcher the {@link RequestMatcher} to use
+	 */
+	void setRequestMatcher(RequestMatcher requestMatcher) {
+		Assert.notNull(requestMatcher, "requestMatcher cannot be null");
+		this.requestMatcher = requestMatcher;
+	}
+
+}

+ 80 - 0
config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutAuthenticationToken.java

@@ -0,0 +1,80 @@
+/*
+ * Copyright 2002-2023 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.config.annotation.web.configurers.oauth2.client;
+
+import org.springframework.security.authentication.AbstractAuthenticationToken;
+import org.springframework.security.core.authority.AuthorityUtils;
+import org.springframework.security.oauth2.client.registration.ClientRegistration;
+
+/**
+ * An {@link org.springframework.security.core.Authentication} instance that represents a
+ * request to authenticate an OIDC Logout Token.
+ *
+ * @author Josh Cummings
+ * @since 6.2
+ */
+class OidcLogoutAuthenticationToken extends AbstractAuthenticationToken {
+
+	private final String logoutToken;
+
+	private final ClientRegistration clientRegistration;
+
+	/**
+	 * Construct an {@link OidcLogoutAuthenticationToken}
+	 * @param logoutToken a signed, serialized OIDC Logout token
+	 * @param clientRegistration the {@link ClientRegistration client} associated with
+	 * this token; this is usually derived from material in the logout HTTP request
+	 */
+	OidcLogoutAuthenticationToken(String logoutToken, ClientRegistration clientRegistration) {
+		super(AuthorityUtils.NO_AUTHORITIES);
+		this.logoutToken = logoutToken;
+		this.clientRegistration = clientRegistration;
+	}
+
+	/**
+	 * {@inheritDoc}
+	 */
+	@Override
+	public String getCredentials() {
+		return this.logoutToken;
+	}
+
+	/**
+	 * {@inheritDoc}
+	 */
+	@Override
+	public String getPrincipal() {
+		return this.logoutToken;
+	}
+
+	/**
+	 * Get the signed, serialized OIDC Logout token
+	 * @return the logout token
+	 */
+	String getLogoutToken() {
+		return this.logoutToken;
+	}
+
+	/**
+	 * Get the {@link ClientRegistration} associated with this logout token
+	 * @return the {@link ClientRegistration}
+	 */
+	ClientRegistration getClientRegistration() {
+		return this.clientRegistration;
+	}
+
+}

+ 159 - 0
config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurer.java

@@ -0,0 +1,159 @@
+/*
+ * Copyright 2002-2023 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.config.annotation.web.configurers.oauth2.client;
+
+import java.util.function.Consumer;
+
+import org.springframework.security.authentication.AuthenticationManager;
+import org.springframework.security.authentication.ProviderManager;
+import org.springframework.security.config.Customizer;
+import org.springframework.security.config.annotation.web.HttpSecurityBuilder;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
+import org.springframework.security.oauth2.client.oidc.session.OidcSessionRegistry;
+import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
+import org.springframework.security.web.authentication.AuthenticationConverter;
+import org.springframework.security.web.authentication.logout.LogoutHandler;
+import org.springframework.security.web.csrf.CsrfFilter;
+import org.springframework.util.Assert;
+
+/**
+ * An {@link AbstractHttpConfigurer} for OIDC Logout flows
+ *
+ * <p>
+ * OIDC Logout provides an application with the capability to have users log out by using
+ * their existing account at an OAuth 2.0 or OpenID Connect 1.0 Provider.
+ *
+ *
+ * <h2>Security Filters</h2>
+ *
+ * The following {@code Filter} is populated:
+ *
+ * <ul>
+ * <li>{@link OidcBackChannelLogoutFilter}</li>
+ * </ul>
+ *
+ * <h2>Shared Objects Used</h2>
+ *
+ * The following shared objects are used:
+ *
+ * <ul>
+ * <li>{@link ClientRegistrationRepository}</li>
+ * </ul>
+ *
+ * @author Josh Cummings
+ * @since 6.2
+ * @see HttpSecurity#oidcLogout()
+ * @see OidcBackChannelLogoutFilter
+ * @see ClientRegistrationRepository
+ */
+public final class OidcLogoutConfigurer<B extends HttpSecurityBuilder<B>>
+		extends AbstractHttpConfigurer<OidcLogoutConfigurer<B>, B> {
+
+	private BackChannelLogoutConfigurer backChannel;
+
+	/**
+	 * Sets the repository of client registrations.
+	 * @param clientRegistrationRepository the repository of client registrations
+	 * @return the {@link OAuth2LoginConfigurer} for further configuration
+	 */
+	public OidcLogoutConfigurer<B> clientRegistrationRepository(
+			ClientRegistrationRepository clientRegistrationRepository) {
+		Assert.notNull(clientRegistrationRepository, "clientRegistrationRepository cannot be null");
+		this.getBuilder().setSharedObject(ClientRegistrationRepository.class, clientRegistrationRepository);
+		return this;
+	}
+
+	/**
+	 * Sets the registry for managing the OIDC client-provider session link
+	 * @param oidcSessionRegistry the {@link OidcSessionRegistry} to use
+	 * @return the {@link OAuth2LoginConfigurer} for further configuration
+	 */
+	public OidcLogoutConfigurer<B> oidcSessionRegistry(OidcSessionRegistry oidcSessionRegistry) {
+		Assert.notNull(oidcSessionRegistry, "oidcSessionRegistry cannot be null");
+		getBuilder().setSharedObject(OidcSessionRegistry.class, oidcSessionRegistry);
+		return this;
+	}
+
+	/**
+	 * Configure OIDC Back-Channel Logout using the provided {@link Consumer}
+	 * @return the {@link OidcLogoutConfigurer} for further configuration
+	 */
+	public OidcLogoutConfigurer<B> backChannel(Customizer<BackChannelLogoutConfigurer> backChannelLogoutConfigurer) {
+		if (this.backChannel == null) {
+			this.backChannel = new BackChannelLogoutConfigurer();
+		}
+		backChannelLogoutConfigurer.customize(this.backChannel);
+		return this;
+	}
+
+	@Deprecated(forRemoval = true, since = "6.2")
+	public B and() {
+		return getBuilder();
+	}
+
+	@Override
+	public void configure(B builder) throws Exception {
+		if (this.backChannel != null) {
+			this.backChannel.configure(builder);
+		}
+	}
+
+	/**
+	 * A configurer for configuring OIDC Back-Channel Logout
+	 */
+	public final class BackChannelLogoutConfigurer {
+
+		private AuthenticationConverter authenticationConverter;
+
+		private final AuthenticationManager authenticationManager = new ProviderManager(
+				new OidcBackChannelLogoutAuthenticationProvider());
+
+		private LogoutHandler logoutHandler;
+
+		private AuthenticationConverter authenticationConverter(B http) {
+			if (this.authenticationConverter == null) {
+				ClientRegistrationRepository clientRegistrationRepository = OAuth2ClientConfigurerUtils
+						.getClientRegistrationRepository(http);
+				this.authenticationConverter = new OidcLogoutAuthenticationConverter(clientRegistrationRepository);
+			}
+			return this.authenticationConverter;
+		}
+
+		private AuthenticationManager authenticationManager() {
+			return this.authenticationManager;
+		}
+
+		private LogoutHandler logoutHandler(B http) {
+			if (this.logoutHandler == null) {
+				OidcBackChannelLogoutHandler logoutHandler = new OidcBackChannelLogoutHandler();
+				logoutHandler.setSessionRegistry(OAuth2ClientConfigurerUtils.getOidcSessionRegistry(http));
+				this.logoutHandler = logoutHandler;
+			}
+			return this.logoutHandler;
+		}
+
+		void configure(B http) {
+			OidcBackChannelLogoutFilter filter = new OidcBackChannelLogoutFilter(authenticationConverter(http),
+					authenticationManager());
+			filter.setLogoutHandler(logoutHandler(http));
+			http.addFilterBefore(filter, CsrfFilter.class);
+		}
+
+	}
+
+}

+ 35 - 0
config/src/main/java/org/springframework/security/config/web/server/DefaultOidcLogoutTokenValidatorFactory.java

@@ -0,0 +1,35 @@
+/*
+ * Copyright 2002-2023 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.config.web.server;
+
+import java.util.function.Function;
+
+import org.springframework.security.oauth2.client.registration.ClientRegistration;
+import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
+import org.springframework.security.oauth2.core.OAuth2TokenValidator;
+import org.springframework.security.oauth2.jwt.Jwt;
+import org.springframework.security.oauth2.jwt.JwtTimestampValidator;
+
+final class DefaultOidcLogoutTokenValidatorFactory implements Function<ClientRegistration, OAuth2TokenValidator<Jwt>> {
+
+	@Override
+	public OAuth2TokenValidator<Jwt> apply(ClientRegistration clientRegistration) {
+		return new DelegatingOAuth2TokenValidator<>(new JwtTimestampValidator(),
+				new OidcBackChannelLogoutTokenValidator(clientRegistration));
+	}
+
+}

+ 66 - 0
config/src/main/java/org/springframework/security/config/web/server/OidcBackChannelLogoutAuthentication.java

@@ -0,0 +1,66 @@
+/*
+ * Copyright 2002-2023 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.config.web.server;
+
+import java.util.Collections;
+
+import org.springframework.security.authentication.AbstractAuthenticationToken;
+import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcLogoutToken;
+
+/**
+ * An {@link org.springframework.security.core.Authentication} implementation that
+ * represents the result of authenticating an OIDC Logout token for the purposes of
+ * performing Back-Channel Logout.
+ *
+ * @author Josh Cummings
+ * @since 6.2
+ * @see OidcLogoutAuthenticationToken
+ * @see <a target="_blank" href=
+ * "https://openid.net/specs/openid-connect-backchannel-1_0.html">OIDC Back-Channel
+ * Logout</a>
+ */
+class OidcBackChannelLogoutAuthentication extends AbstractAuthenticationToken {
+
+	private final OidcLogoutToken logoutToken;
+
+	/**
+	 * Construct an {@link OidcBackChannelLogoutAuthentication}
+	 * @param logoutToken a deserialized, verified OIDC Logout Token
+	 */
+	OidcBackChannelLogoutAuthentication(OidcLogoutToken logoutToken) {
+		super(Collections.emptyList());
+		this.logoutToken = logoutToken;
+		setAuthenticated(true);
+	}
+
+	/**
+	 * {@inheritDoc}
+	 */
+	@Override
+	public OidcLogoutToken getPrincipal() {
+		return this.logoutToken;
+	}
+
+	/**
+	 * {@inheritDoc}
+	 */
+	@Override
+	public OidcLogoutToken getCredentials() {
+		return this.logoutToken;
+	}
+
+}

+ 112 - 0
config/src/main/java/org/springframework/security/config/web/server/OidcBackChannelLogoutReactiveAuthenticationManager.java

@@ -0,0 +1,112 @@
+/*
+ * Copyright 2002-2023 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.config.web.server;
+
+import reactor.core.publisher.Mono;
+
+import org.springframework.security.authentication.AuthenticationProvider;
+import org.springframework.security.authentication.AuthenticationServiceException;
+import org.springframework.security.authentication.ReactiveAuthenticationManager;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.AuthenticationException;
+import org.springframework.security.oauth2.client.oidc.authentication.ReactiveOidcIdTokenDecoderFactory;
+import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcLogoutToken;
+import org.springframework.security.oauth2.client.registration.ClientRegistration;
+import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
+import org.springframework.security.oauth2.core.OAuth2Error;
+import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
+import org.springframework.security.oauth2.jwt.BadJwtException;
+import org.springframework.security.oauth2.jwt.Jwt;
+import org.springframework.security.oauth2.jwt.JwtDecoder;
+import org.springframework.security.oauth2.jwt.JwtDecoderFactory;
+import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder;
+import org.springframework.security.oauth2.jwt.ReactiveJwtDecoderFactory;
+import org.springframework.util.Assert;
+
+/**
+ * An {@link AuthenticationProvider} that authenticates an OIDC Logout Token; namely
+ * deserializing it, verifying its signature, and validating its claims.
+ *
+ * <p>
+ * Intended to be included in a
+ * {@link org.springframework.security.authentication.ProviderManager}
+ *
+ * @author Josh Cummings
+ * @since 6.2
+ * @see OidcLogoutAuthenticationToken
+ * @see org.springframework.security.authentication.ProviderManager
+ * @see <a target="_blank" href=
+ * "https://openid.net/specs/openid-connect-backchannel-1_0.html">OIDC Back-Channel
+ * Logout</a>
+ */
+final class OidcBackChannelLogoutReactiveAuthenticationManager implements ReactiveAuthenticationManager {
+
+	private ReactiveJwtDecoderFactory<ClientRegistration> logoutTokenDecoderFactory;
+
+	/**
+	 * Construct an {@link OidcBackChannelLogoutReactiveAuthenticationManager}
+	 */
+	OidcBackChannelLogoutReactiveAuthenticationManager() {
+		ReactiveOidcIdTokenDecoderFactory logoutTokenDecoderFactory = new ReactiveOidcIdTokenDecoderFactory();
+		logoutTokenDecoderFactory.setJwtValidatorFactory(new DefaultOidcLogoutTokenValidatorFactory());
+		this.logoutTokenDecoderFactory = logoutTokenDecoderFactory;
+	}
+
+	/**
+	 * {@inheritDoc}
+	 */
+	@Override
+	public Mono<Authentication> authenticate(Authentication authentication) throws AuthenticationException {
+		if (!(authentication instanceof OidcLogoutAuthenticationToken token)) {
+			return Mono.empty();
+		}
+		String logoutToken = token.getLogoutToken();
+		ClientRegistration registration = token.getClientRegistration();
+		return decode(registration, logoutToken)
+				.map((jwt) -> OidcLogoutToken
+						.withTokenValue(logoutToken)
+						.claims((claims) -> claims.putAll(jwt.getClaims())).build()
+				)
+				.map(OidcBackChannelLogoutAuthentication::new);
+	}
+
+	private Mono<Jwt> decode(ClientRegistration registration, String token) {
+		ReactiveJwtDecoder logoutTokenDecoder = this.logoutTokenDecoderFactory.createDecoder(registration);
+		try {
+			return logoutTokenDecoder.decode(token);
+		}
+		catch (BadJwtException failed) {
+			OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.INVALID_REQUEST, failed.getMessage(),
+					"https://openid.net/specs/openid-connect-backchannel-1_0.html#Validation");
+			return Mono.error(new OAuth2AuthenticationException(error, failed));
+		}
+		catch (Exception failed) {
+			return Mono.error(new AuthenticationServiceException(failed.getMessage(), failed));
+		}
+	}
+
+	/**
+	 * Use this {@link ReactiveJwtDecoderFactory} to generate {@link JwtDecoder}s that
+	 * correspond to the {@link ClientRegistration} associated with the OIDC logout token.
+	 * @param logoutTokenDecoderFactory the {@link JwtDecoderFactory} to use
+	 */
+	void setLogoutTokenDecoderFactory(ReactiveJwtDecoderFactory<ClientRegistration> logoutTokenDecoderFactory) {
+		Assert.notNull(logoutTokenDecoderFactory, "logoutTokenDecoderFactory cannot be null");
+		this.logoutTokenDecoderFactory = logoutTokenDecoderFactory;
+	}
+
+}

+ 118 - 0
config/src/main/java/org/springframework/security/config/web/server/OidcBackChannelLogoutTokenValidator.java

@@ -0,0 +1,118 @@
+/*
+ * Copyright 2002-2023 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.config.web.server;
+
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+
+import org.springframework.security.oauth2.client.oidc.authentication.logout.LogoutTokenClaimAccessor;
+import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcLogoutToken;
+import org.springframework.security.oauth2.client.registration.ClientRegistration;
+import org.springframework.security.oauth2.core.OAuth2Error;
+import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
+import org.springframework.security.oauth2.core.OAuth2TokenValidator;
+import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
+import org.springframework.security.oauth2.jwt.Jwt;
+
+/**
+ * A {@link OAuth2TokenValidator} that validates OIDC Logout Token claims in conformance
+ * with the OIDC Back-Channel Logout Spec.
+ *
+ * @author Josh Cummings
+ * @since 6.2
+ * @see OidcLogoutToken
+ * @see <a target="_blank" href=
+ * "https://openid.net/specs/openid-connect-backchannel-1_0.html#LogoutToken">Logout
+ * Token</a>
+ * @see <a target="blank" href=
+ * "https://openid.net/specs/openid-connect-backchannel-1_0.html#Validation">the OIDC
+ * Back-Channel Logout spec</a>
+ */
+final class OidcBackChannelLogoutTokenValidator implements OAuth2TokenValidator<Jwt> {
+
+	private static final String LOGOUT_VALIDATION_URL = "https://openid.net/specs/openid-connect-backchannel-1_0.html#Validation";
+
+	private static final String BACK_CHANNEL_LOGOUT_EVENT = "http://schemas.openid.net/event/backchannel-logout";
+
+	private final String audience;
+
+	private final String issuer;
+
+	OidcBackChannelLogoutTokenValidator(ClientRegistration clientRegistration) {
+		this.audience = clientRegistration.getClientId();
+		this.issuer = clientRegistration.getProviderDetails().getIssuerUri();
+	}
+
+	@Override
+	public OAuth2TokenValidatorResult validate(Jwt jwt) {
+		Collection<OAuth2Error> errors = new ArrayList<>();
+
+		LogoutTokenClaimAccessor logoutClaims = jwt::getClaims;
+		Map<String, Object> events = logoutClaims.getEvents();
+		if (events == null) {
+			errors.add(invalidLogoutToken("events claim must not be null"));
+		}
+		else if (events.get(BACK_CHANNEL_LOGOUT_EVENT) == null) {
+			errors.add(invalidLogoutToken("events claim map must contain \"" + BACK_CHANNEL_LOGOUT_EVENT + "\" key"));
+		}
+
+		String issuer = logoutClaims.getIssuer().toExternalForm();
+		if (issuer == null) {
+			errors.add(invalidLogoutToken("iss claim must not be null"));
+		}
+		else if (!this.issuer.equals(issuer)) {
+			errors.add(invalidLogoutToken(
+					"iss claim value must match `ClientRegistration#getProviderDetails#getIssuerUri`"));
+		}
+
+		List<String> audience = logoutClaims.getAudience();
+		if (audience == null) {
+			errors.add(invalidLogoutToken("aud claim must not be null"));
+		}
+		else if (!audience.contains(this.audience)) {
+			errors.add(invalidLogoutToken("aud claim value must include `ClientRegistration#getClientId`"));
+		}
+
+		Instant issuedAt = logoutClaims.getIssuedAt();
+		if (issuedAt == null) {
+			errors.add(invalidLogoutToken("iat claim must not be null"));
+		}
+
+		String jwtId = logoutClaims.getId();
+		if (jwtId == null) {
+			errors.add(invalidLogoutToken("jti claim must not be null"));
+		}
+
+		if (logoutClaims.getSubject() == null && logoutClaims.getSessionId() == null) {
+			errors.add(invalidLogoutToken("sub and sid claims must not both be null"));
+		}
+
+		if (logoutClaims.getClaim("nonce") != null) {
+			errors.add(invalidLogoutToken("nonce claim must not be present"));
+		}
+
+		return OAuth2TokenValidatorResult.failure(errors);
+	}
+
+	private static OAuth2Error invalidLogoutToken(String description) {
+		return new OAuth2Error(OAuth2ErrorCodes.INVALID_TOKEN, description, LOGOUT_VALIDATION_URL);
+	}
+
+}

+ 135 - 0
config/src/main/java/org/springframework/security/config/web/server/OidcBackChannelLogoutWebFilter.java

@@ -0,0 +1,135 @@
+/*
+ * Copyright 2002-2023 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.config.web.server;
+
+import java.nio.charset.StandardCharsets;
+
+import jakarta.servlet.http.HttpServletResponse;
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+
+import org.springframework.core.io.buffer.DataBuffer;
+import org.springframework.http.server.reactive.ServerHttpResponse;
+import org.springframework.security.authentication.AuthenticationManager;
+import org.springframework.security.authentication.AuthenticationServiceException;
+import org.springframework.security.authentication.ReactiveAuthenticationManager;
+import org.springframework.security.core.AuthenticationException;
+import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
+import org.springframework.security.oauth2.core.OAuth2Error;
+import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
+import org.springframework.security.web.authentication.AuthenticationConverter;
+import org.springframework.security.web.authentication.logout.LogoutHandler;
+import org.springframework.security.web.server.WebFilterExchange;
+import org.springframework.security.web.server.authentication.ServerAuthenticationConverter;
+import org.springframework.security.web.server.authentication.logout.ServerLogoutHandler;
+import org.springframework.util.Assert;
+import org.springframework.web.server.ServerWebExchange;
+import org.springframework.web.server.WebFilter;
+import org.springframework.web.server.WebFilterChain;
+
+/**
+ * A filter for the Client-side OIDC Back-Channel Logout endpoint
+ *
+ * @author Josh Cummings
+ * @since 6.2
+ * @see <a target="_blank" href=
+ * "https://openid.net/specs/openid-connect-backchannel-1_0.html">OIDC Back-Channel Logout
+ * Spec</a>
+ */
+class OidcBackChannelLogoutWebFilter implements WebFilter {
+
+	private final Log logger = LogFactory.getLog(getClass());
+
+	private final ServerAuthenticationConverter authenticationConverter;
+
+	private final ReactiveAuthenticationManager authenticationManager;
+
+	private ServerLogoutHandler logoutHandler = new OidcBackChannelServerLogoutHandler();
+
+	/**
+	 * Construct an {@link OidcBackChannelLogoutWebFilter}
+	 * @param authenticationConverter the {@link AuthenticationConverter} for deriving
+	 * Logout Token authentication
+	 * @param authenticationManager the {@link AuthenticationManager} for authenticating
+	 * Logout Tokens
+	 */
+	OidcBackChannelLogoutWebFilter(ServerAuthenticationConverter authenticationConverter,
+			ReactiveAuthenticationManager authenticationManager) {
+		Assert.notNull(authenticationConverter, "authenticationConverter cannot be null");
+		Assert.notNull(authenticationManager, "authenticationManager cannot be null");
+		this.authenticationConverter = authenticationConverter;
+		this.authenticationManager = authenticationManager;
+	}
+
+	@Override
+	public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
+		return this.authenticationConverter.convert(exchange).onErrorResume(AuthenticationException.class, (ex) -> {
+			this.logger.debug("Failed to process OIDC Back-Channel Logout", ex);
+			if (ex instanceof AuthenticationServiceException) {
+				return Mono.error(ex);
+			}
+			return handleAuthenticationFailure(exchange.getResponse(), ex).then(Mono.empty());
+		}).switchIfEmpty(chain.filter(exchange).then(Mono.empty())).flatMap(this.authenticationManager::authenticate)
+				.onErrorResume(AuthenticationException.class, (ex) -> {
+					this.logger.debug("Failed to process OIDC Back-Channel Logout", ex);
+					if (ex instanceof AuthenticationServiceException) {
+						return Mono.error(ex);
+					}
+					return handleAuthenticationFailure(exchange.getResponse(), ex).then(Mono.empty());
+				}).flatMap((authentication) -> {
+					WebFilterExchange webFilterExchange = new WebFilterExchange(exchange, chain);
+					return this.logoutHandler.logout(webFilterExchange, authentication);
+				});
+	}
+
+	private Mono<Void> handleAuthenticationFailure(ServerHttpResponse response, Exception ex) {
+		this.logger.debug("Failed to process OIDC Back-Channel Logout", ex);
+		response.setRawStatusCode(HttpServletResponse.SC_BAD_REQUEST);
+		OAuth2Error error = oauth2Error(ex);
+		byte[] bytes = String.format("""
+				{
+					"error_code": "%s",
+					"error_description": "%s",
+					"error_uri: "%s"
+				}
+				""", error.getErrorCode(), error.getDescription(), error.getUri())
+				.getBytes(StandardCharsets.UTF_8);
+		DataBuffer buffer = response.bufferFactory().wrap(bytes);
+		return response.writeWith(Flux.just(buffer));
+	}
+
+	private OAuth2Error oauth2Error(Exception ex) {
+		if (ex instanceof OAuth2AuthenticationException oauth2) {
+			return oauth2.getError();
+		}
+		return new OAuth2Error(OAuth2ErrorCodes.INVALID_REQUEST, ex.getMessage(),
+				"https://openid.net/specs/openid-connect-backchannel-1_0.html#Validation");
+	}
+
+	/**
+	 * The strategy for expiring all Client sessions indicated by the logout request.
+	 * Defaults to {@link OidcBackChannelServerLogoutHandler}.
+	 * @param logoutHandler the {@link LogoutHandler} to use
+	 */
+	void setLogoutHandler(ServerLogoutHandler logoutHandler) {
+		Assert.notNull(logoutHandler, "logoutHandler cannot be null");
+		this.logoutHandler = logoutHandler;
+	}
+
+}

+ 183 - 0
config/src/main/java/org/springframework/security/config/web/server/OidcBackChannelServerLogoutHandler.java

@@ -0,0 +1,183 @@
+/*
+ * Copyright 2002-2023 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.config.web.server;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Collection;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import jakarta.servlet.http.HttpServletResponse;
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+
+import org.springframework.core.io.buffer.DataBuffer;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.ResponseEntity;
+import org.springframework.http.server.reactive.ServerHttpResponse;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcLogoutToken;
+import org.springframework.security.oauth2.client.oidc.server.session.InMemoryReactiveOidcSessionRegistry;
+import org.springframework.security.oauth2.client.oidc.server.session.ReactiveOidcSessionRegistry;
+import org.springframework.security.oauth2.client.oidc.session.OidcSessionInformation;
+import org.springframework.security.oauth2.client.oidc.session.OidcSessionRegistry;
+import org.springframework.security.oauth2.core.OAuth2Error;
+import org.springframework.security.web.server.WebFilterExchange;
+import org.springframework.security.web.server.authentication.logout.ServerLogoutHandler;
+import org.springframework.util.Assert;
+import org.springframework.web.reactive.function.client.WebClient;
+import org.springframework.web.util.UriComponentsBuilder;
+
+/**
+ * A {@link ServerLogoutHandler} that locates the sessions associated with a given OIDC
+ * Back-Channel Logout Token and invalidates each one.
+ *
+ * @author Josh Cummings
+ * @since 6.2
+ * @see <a target="_blank" href=
+ * "https://openid.net/specs/openid-connect-backchannel-1_0.html">OIDC Back-Channel Logout
+ * Spec</a>
+ */
+final class OidcBackChannelServerLogoutHandler implements ServerLogoutHandler {
+
+	private final Log logger = LogFactory.getLog(getClass());
+
+	private ReactiveOidcSessionRegistry sessionRegistry = new InMemoryReactiveOidcSessionRegistry();
+
+	private WebClient web = WebClient.create();
+
+	private String logoutEndpointName = "/logout";
+
+	private String sessionCookieName = "SESSION";
+
+	@Override
+	public Mono<Void> logout(WebFilterExchange exchange, Authentication authentication) {
+		if (!(authentication instanceof OidcBackChannelLogoutAuthentication token)) {
+			return Mono.defer(() -> {
+				if (this.logger.isDebugEnabled()) {
+					String message = "Did not perform OIDC Back-Channel Logout since authentication [%s] was of the wrong type";
+					this.logger.debug(String.format(message, authentication.getClass().getSimpleName()));
+				}
+				return Mono.empty();
+			});
+		}
+		AtomicInteger totalCount = new AtomicInteger(0);
+		AtomicInteger invalidatedCount = new AtomicInteger(0);
+		return this.sessionRegistry.removeSessionInformation(token.getPrincipal())
+			.concatMap((session) -> {
+				totalCount.incrementAndGet();
+				return eachLogout(exchange, session)
+						.flatMap((response) -> {
+							invalidatedCount.incrementAndGet();
+							return Mono.empty();
+						})
+						.onErrorResume((ex) -> {
+							this.logger.debug("Failed to invalidate session", ex);
+							return this.sessionRegistry.saveSessionInformation(session)
+									.then(Mono.just(ex.getMessage()));
+						});
+			}).collectList().flatMap((list) -> {
+				if (this.logger.isTraceEnabled()) {
+					this.logger.trace(String.format("Invalidated %d out of %d sessions", invalidatedCount.intValue(), totalCount.intValue()));
+				}
+				if (!list.isEmpty()) {
+					return handleLogoutFailure(exchange.getExchange().getResponse(), oauth2Error(list));
+				}
+				else {
+					return Mono.empty();
+				}
+			});
+	}
+
+	private Mono<ResponseEntity<Void>> eachLogout(WebFilterExchange exchange, OidcSessionInformation session) {
+		HttpHeaders headers = new HttpHeaders();
+		headers.add(HttpHeaders.COOKIE, this.sessionCookieName + "=" + session.getSessionId());
+		for (Map.Entry<String, String> credential : session.getAuthorities().entrySet()) {
+			headers.add(credential.getKey(), credential.getValue());
+		}
+		String url = exchange.getExchange().getRequest().getURI().toString();
+		String logout = UriComponentsBuilder.fromHttpUrl(url).replacePath(this.logoutEndpointName).build()
+				.toUriString();
+		return this.web.post().uri(logout).headers((h) -> h.putAll(headers)).retrieve().toBodilessEntity();
+	}
+
+	private OAuth2Error oauth2Error(Collection<?> errors) {
+		return new OAuth2Error("partial_logout", "not all sessions were terminated: " + errors,
+				"https://openid.net/specs/openid-connect-backchannel-1_0.html#Validation");
+	}
+
+	private Mono<Void> handleLogoutFailure(ServerHttpResponse response, OAuth2Error error) {
+		response.setRawStatusCode(HttpServletResponse.SC_BAD_REQUEST);
+		byte[] bytes = String.format("""
+				{
+					"error_code": "%s",
+					"error_description": "%s",
+					"error_uri: "%s"
+				}
+				""", error.getErrorCode(), error.getDescription(), error.getUri())
+				.getBytes(StandardCharsets.UTF_8);
+		DataBuffer buffer = response.bufferFactory().wrap(bytes);
+		return response.writeWith(Flux.just(buffer));
+	}
+
+	/**
+	 * Use this {@link OidcSessionRegistry} to identify sessions to invalidate. Note that
+	 * this class uses
+	 * {@link OidcSessionRegistry#removeSessionInformation(OidcLogoutToken)} to identify
+	 * sessions.
+	 * @param sessionRegistry the {@link OidcSessionRegistry} to use
+	 */
+	void setSessionRegistry(ReactiveOidcSessionRegistry sessionRegistry) {
+		Assert.notNull(sessionRegistry, "sessionRegistry cannot be null");
+		this.sessionRegistry = sessionRegistry;
+	}
+
+	/**
+	 * Use this {@link WebClient} to perform the per-session back-channel logout
+	 * @param web the {@link WebClient} to use
+	 */
+	void setWebClient(WebClient web) {
+		Assert.notNull(web, "web cannot be null");
+		this.web = web;
+	}
+
+	/**
+	 * Use this logout URI for performing per-session logout. Defaults to {@code /logout}
+	 * since that is the default URI for
+	 * {@link org.springframework.security.web.authentication.logout.LogoutFilter}.
+	 * @param logoutUri the URI to use
+	 */
+	void setLogoutUri(String logoutUri) {
+		Assert.hasText(logoutUri, "logoutUri cannot be empty");
+		this.logoutEndpointName = logoutUri;
+	}
+
+	/**
+	 * Use this cookie name for the session identifier. Defaults to {@code JSESSIONID}.
+	 *
+	 * <p>
+	 * Note that if you are using Spring Session, this likely needs to change to SESSION.
+	 * @param sessionCookieName the cookie name to use
+	 */
+	void setSessionCookieName(String sessionCookieName) {
+		Assert.hasText(sessionCookieName, "clientSessionCookieName cannot be empty");
+		this.sessionCookieName = sessionCookieName;
+	}
+
+}

+ 80 - 0
config/src/main/java/org/springframework/security/config/web/server/OidcLogoutAuthenticationToken.java

@@ -0,0 +1,80 @@
+/*
+ * Copyright 2002-2023 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.config.web.server;
+
+import org.springframework.security.authentication.AbstractAuthenticationToken;
+import org.springframework.security.core.authority.AuthorityUtils;
+import org.springframework.security.oauth2.client.registration.ClientRegistration;
+
+/**
+ * An {@link org.springframework.security.core.Authentication} instance that represents a
+ * request to authenticate an OIDC Logout Token.
+ *
+ * @author Josh Cummings
+ * @since 6.2
+ */
+class OidcLogoutAuthenticationToken extends AbstractAuthenticationToken {
+
+	private final String logoutToken;
+
+	private final ClientRegistration clientRegistration;
+
+	/**
+	 * Construct an {@link OidcLogoutAuthenticationToken}
+	 * @param logoutToken a signed, serialized OIDC Logout token
+	 * @param clientRegistration the {@link ClientRegistration client} associated with
+	 * this token; this is usually derived from material in the logout HTTP request
+	 */
+	OidcLogoutAuthenticationToken(String logoutToken, ClientRegistration clientRegistration) {
+		super(AuthorityUtils.NO_AUTHORITIES);
+		this.logoutToken = logoutToken;
+		this.clientRegistration = clientRegistration;
+	}
+
+	/**
+	 * {@inheritDoc}
+	 */
+	@Override
+	public String getCredentials() {
+		return this.logoutToken;
+	}
+
+	/**
+	 * {@inheritDoc}
+	 */
+	@Override
+	public String getPrincipal() {
+		return this.logoutToken;
+	}
+
+	/**
+	 * Get the signed, serialized OIDC Logout token
+	 * @return the logout token
+	 */
+	String getLogoutToken() {
+		return this.logoutToken;
+	}
+
+	/**
+	 * Get the {@link ClientRegistration} associated with this logout token
+	 * @return the {@link ClientRegistration}
+	 */
+	ClientRegistration getClientRegistration() {
+		return this.clientRegistration;
+	}
+
+}

+ 88 - 0
config/src/main/java/org/springframework/security/config/web/server/OidcLogoutServerAuthenticationConverter.java

@@ -0,0 +1,88 @@
+/*
+ * Copyright 2002-2023 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.config.web.server;
+
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+import reactor.core.publisher.Mono;
+
+import org.springframework.http.HttpMethod;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.oauth2.client.registration.ReactiveClientRegistrationRepository;
+import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
+import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
+import org.springframework.security.web.authentication.AuthenticationConverter;
+import org.springframework.security.web.server.authentication.ServerAuthenticationConverter;
+import org.springframework.security.web.server.util.matcher.PathPatternParserServerWebExchangeMatcher;
+import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher;
+import org.springframework.util.Assert;
+import org.springframework.web.server.ServerWebExchange;
+
+/**
+ * An {@link AuthenticationConverter} that extracts the OIDC Logout Token authentication
+ * request
+ *
+ * @author Josh Cummings
+ * @since 6.2
+ */
+final class OidcLogoutServerAuthenticationConverter implements ServerAuthenticationConverter {
+
+	private static final String DEFAULT_LOGOUT_URI = "/logout/connect/back-channel/{registrationId}";
+
+	private final Log logger = LogFactory.getLog(getClass());
+
+	private final ReactiveClientRegistrationRepository clientRegistrationRepository;
+
+	private ServerWebExchangeMatcher exchangeMatcher = new PathPatternParserServerWebExchangeMatcher(DEFAULT_LOGOUT_URI,
+			HttpMethod.POST);
+
+	OidcLogoutServerAuthenticationConverter(ReactiveClientRegistrationRepository clientRegistrationRepository) {
+		Assert.notNull(clientRegistrationRepository, "clientRegistrationRepository cannot be null");
+		this.clientRegistrationRepository = clientRegistrationRepository;
+	}
+
+	@Override
+	public Mono<Authentication> convert(ServerWebExchange exchange) {
+		return this.exchangeMatcher.matches(exchange).filter(ServerWebExchangeMatcher.MatchResult::isMatch)
+				.flatMap((match) -> {
+					String registrationId = (String) match.getVariables().get("registrationId");
+					return this.clientRegistrationRepository.findByRegistrationId(registrationId)
+							.switchIfEmpty(Mono.error(() -> {
+								this.logger.debug(
+										"Did not process OIDC Back-Channel Logout since no ClientRegistration was found");
+								return new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_REQUEST);
+							}));
+				}).flatMap((clientRegistration) -> exchange.getFormData().map((data) -> {
+					String logoutToken = data.getFirst("logout_token");
+					return new OidcLogoutAuthenticationToken(logoutToken, clientRegistration);
+				}).switchIfEmpty(Mono.error(() -> {
+					this.logger.debug("Failed to process OIDC Back-Channel Logout since no logout token was found");
+					return new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_REQUEST);
+				})));
+	}
+
+	/**
+	 * The logout endpoint. Defaults to
+	 * {@code /logout/connect/back-channel/{registrationId}}.
+	 * @param exchangeMatcher the {@link ServerWebExchangeMatcher} to use
+	 */
+	void setExchangeMatcher(ServerWebExchangeMatcher exchangeMatcher) {
+		Assert.notNull(exchangeMatcher, "exchangeMatcher cannot be null");
+		this.exchangeMatcher = exchangeMatcher;
+	}
+
+}

+ 345 - 2
config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java

@@ -21,6 +21,7 @@ import java.io.PrintWriter;
 import java.io.StringWriter;
 import java.security.interfaces.RSAPublicKey;
 import java.time.Duration;
+import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
@@ -28,10 +29,13 @@ import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.UUID;
+import java.util.function.Consumer;
 import java.util.function.Function;
 import java.util.function.Supplier;
 
 import io.micrometer.observation.ObservationRegistry;
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
 import reactor.core.publisher.Mono;
 import reactor.util.context.Context;
 
@@ -67,6 +71,9 @@ import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCo
 import org.springframework.security.oauth2.client.endpoint.ReactiveOAuth2AccessTokenResponseClient;
 import org.springframework.security.oauth2.client.endpoint.WebClientReactiveAuthorizationCodeTokenResponseClient;
 import org.springframework.security.oauth2.client.oidc.authentication.OidcAuthorizationCodeReactiveAuthenticationManager;
+import org.springframework.security.oauth2.client.oidc.server.session.InMemoryReactiveOidcSessionRegistry;
+import org.springframework.security.oauth2.client.oidc.server.session.ReactiveOidcSessionRegistry;
+import org.springframework.security.oauth2.client.oidc.session.OidcSessionInformation;
 import org.springframework.security.oauth2.client.oidc.userinfo.OidcReactiveOAuth2UserService;
 import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest;
 import org.springframework.security.oauth2.client.registration.ClientRegistration;
@@ -113,6 +120,7 @@ import org.springframework.security.web.server.MatcherSecurityWebFilterChain;
 import org.springframework.security.web.server.SecurityWebFilterChain;
 import org.springframework.security.web.server.ServerAuthenticationEntryPoint;
 import org.springframework.security.web.server.ServerRedirectStrategy;
+import org.springframework.security.web.server.WebFilterExchange;
 import org.springframework.security.web.server.authentication.AnonymousAuthenticationWebFilter;
 import org.springframework.security.web.server.authentication.AuthenticationConverterServerWebExchangeMatcher;
 import org.springframework.security.web.server.authentication.AuthenticationWebFilter;
@@ -147,6 +155,7 @@ import org.springframework.security.web.server.context.SecurityContextServerWebE
 import org.springframework.security.web.server.context.ServerSecurityContextRepository;
 import org.springframework.security.web.server.context.WebSessionServerSecurityContextRepository;
 import org.springframework.security.web.server.csrf.CsrfServerLogoutHandler;
+import org.springframework.security.web.server.csrf.CsrfToken;
 import org.springframework.security.web.server.csrf.CsrfWebFilter;
 import org.springframework.security.web.server.csrf.ServerCsrfTokenRepository;
 import org.springframework.security.web.server.csrf.ServerCsrfTokenRequestHandler;
@@ -193,8 +202,10 @@ import org.springframework.web.cors.reactive.CorsWebFilter;
 import org.springframework.web.cors.reactive.DefaultCorsProcessor;
 import org.springframework.web.reactive.result.method.annotation.RequestMappingHandlerMapping;
 import org.springframework.web.server.ServerWebExchange;
+import org.springframework.web.server.ServerWebExchangeDecorator;
 import org.springframework.web.server.WebFilter;
 import org.springframework.web.server.WebFilterChain;
+import org.springframework.web.server.WebSession;
 import org.springframework.web.util.pattern.PathPatternParser;
 
 /**
@@ -295,6 +306,8 @@ public class ServerHttpSecurity {
 
 	private OAuth2ClientSpec client;
 
+	private OidcLogoutSpec oidcLogout;
+
 	private LogoutSpec logout = new LogoutSpec();
 
 	private LoginPageSpec loginPage = new LoginPageSpec();
@@ -1093,6 +1106,33 @@ public class ServerHttpSecurity {
 		return this;
 	}
 
+	/**
+	 * Configures OIDC Connect 1.0 Logout support.
+	 *
+	 * <pre class="code">
+	 *  &#064;Bean
+	 *  public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
+	 *      http
+	 *          // ...
+	 *          .oidcLogout((logout) -&gt; logout
+	 *              .backChannel(Customizer.withDefaults())
+	 *          );
+	 *      return http.build();
+	 *  }
+	 * </pre>
+	 * @param oidcLogoutCustomizer the {@link Customizer} to provide more options for the
+	 * {@link OidcLogoutSpec}
+	 * @return the {@link ServerHttpSecurity} to customize
+	 * @since 6.2
+	 */
+	public ServerHttpSecurity oidcLogout(Customizer<OidcLogoutSpec> oidcLogoutCustomizer) {
+		if (this.oidcLogout == null) {
+			this.oidcLogout = new OidcLogoutSpec();
+		}
+		oidcLogoutCustomizer.customize(this.oidcLogout);
+		return this;
+	}
+
 	/**
 	 * Configures HTTP Response Headers. The default headers are:
 	 *
@@ -1537,6 +1577,9 @@ public class ServerHttpSecurity {
 		if (this.resourceServer != null) {
 			this.resourceServer.configure(this);
 		}
+		if (this.oidcLogout != null) {
+			this.oidcLogout.configure(this);
+		}
 		if (this.client != null) {
 			this.client.configure(this);
 		}
@@ -3689,6 +3732,8 @@ public class ServerHttpSecurity {
 
 		private ServerWebExchangeMatcher authenticationMatcher;
 
+		private ReactiveOidcSessionRegistry oidcSessionRegistry;
+
 		private ServerAuthenticationSuccessHandler authenticationSuccessHandler;
 
 		private ServerAuthenticationFailureHandler authenticationFailureHandler;
@@ -3720,6 +3765,20 @@ public class ServerHttpSecurity {
 			return this;
 		}
 
+		/**
+		 * Configures the {@link ReactiveOidcSessionRegistry} to use when logins use OIDC.
+		 * Default is to look the value up as a Bean, or else use an
+		 * {@link InMemoryReactiveOidcSessionRegistry}.
+		 * @param oidcSessionRegistry the registry to use
+		 * @return the {@link OidcLogoutSpec} to customize
+		 * @since 6.2
+		 */
+		public OAuth2LoginSpec oidcSessionRegistry(ReactiveOidcSessionRegistry oidcSessionRegistry) {
+			Assert.notNull(oidcSessionRegistry, "oidcSessionRegistry cannot be null");
+			this.oidcSessionRegistry = oidcSessionRegistry;
+			return this;
+		}
+
 		/**
 		 * The {@link ServerAuthenticationSuccessHandler} used after authentication
 		 * success. Defaults to {@link RedirectServerAuthenticationSuccessHandler}
@@ -3913,8 +3972,9 @@ public class ServerHttpSecurity {
 			oauthRedirectFilter.setRequestCache(http.requestCache.requestCache);
 
 			ReactiveAuthenticationManager manager = getAuthenticationManager();
-			AuthenticationWebFilter authenticationFilter = new OAuth2LoginAuthenticationWebFilter(manager,
-					authorizedClientRepository);
+			ReactiveOidcSessionRegistry sessionRegistry = getOidcSessionRegistry();
+			AuthenticationWebFilter authenticationFilter = new OidcSessionRegistryAuthenticationWebFilter(manager,
+					authorizedClientRepository, sessionRegistry);
 			authenticationFilter.setRequiresAuthenticationMatcher(getAuthenticationMatcher());
 			authenticationFilter
 					.setServerAuthenticationConverter(getAuthenticationConverter(clientRegistrationRepository));
@@ -3923,6 +3983,8 @@ public class ServerHttpSecurity {
 			authenticationFilter.setSecurityContextRepository(this.securityContextRepository);
 
 			setDefaultEntryPoints(http);
+			http.addFilterAfter(new OidcSessionRegistryWebFilter(sessionRegistry),
+					SecurityWebFiltersOrder.HTTP_HEADERS_WRITER);
 			http.addFilterAt(oauthRedirectFilter, SecurityWebFiltersOrder.HTTP_BASIC);
 			http.addFilterAt(authenticationFilter, SecurityWebFiltersOrder.AUTHENTICATION);
 		}
@@ -3967,6 +4029,16 @@ public class ServerHttpSecurity {
 			http.defaultEntryPoints.add(new DelegateEntry(defaultEntryPointMatcher, defaultEntryPoint));
 		}
 
+		private ReactiveOidcSessionRegistry getOidcSessionRegistry() {
+			if (this.oidcSessionRegistry == null) {
+				this.oidcSessionRegistry = getBeanOrNull(ReactiveOidcSessionRegistry.class);
+			}
+			if (this.oidcSessionRegistry == null) {
+				this.oidcSessionRegistry = new InMemoryReactiveOidcSessionRegistry();
+			}
+			return this.oidcSessionRegistry;
+		}
+
 		private ServerAuthenticationSuccessHandler getAuthenticationSuccessHandler(ServerHttpSecurity http) {
 			if (this.authenticationSuccessHandler == null) {
 				RedirectServerAuthenticationSuccessHandler handler = new RedirectServerAuthenticationSuccessHandler();
@@ -4083,6 +4155,154 @@ public class ServerHttpSecurity {
 			return new InMemoryReactiveOAuth2AuthorizedClientService(getClientRegistrationRepository());
 		}
 
+		private static final class OidcSessionRegistryWebFilter implements WebFilter {
+
+			private final ReactiveOidcSessionRegistry oidcSessionRegistry;
+
+			OidcSessionRegistryWebFilter(ReactiveOidcSessionRegistry oidcSessionRegistry) {
+				this.oidcSessionRegistry = oidcSessionRegistry;
+			}
+
+			@Override
+			public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
+				return chain.filter(new OidcSessionRegistryServerWebExchange(exchange));
+			}
+
+			private final class OidcSessionRegistryServerWebExchange extends ServerWebExchangeDecorator {
+
+				private final Mono<WebSession> sessionMono;
+
+				protected OidcSessionRegistryServerWebExchange(ServerWebExchange delegate) {
+					super(delegate);
+					this.sessionMono = delegate.getSession().map(OidcSessionRegistryWebSession::new);
+				}
+
+				@Override
+				public Mono<WebSession> getSession() {
+					return this.sessionMono;
+				}
+
+				private final class OidcSessionRegistryWebSession implements WebSession {
+
+					private final WebSession session;
+
+					OidcSessionRegistryWebSession(WebSession session) {
+						this.session = session;
+					}
+
+					@Override
+					public String getId() {
+						return this.session.getId();
+					}
+
+					@Override
+					public Map<String, Object> getAttributes() {
+						return this.session.getAttributes();
+					}
+
+					@Override
+					public void start() {
+						this.session.start();
+					}
+
+					@Override
+					public boolean isStarted() {
+						return this.session.isStarted();
+					}
+
+					@Override
+					public Mono<Void> changeSessionId() {
+						String currentId = this.session.getId();
+						return this.session.changeSessionId()
+								.then(Mono.defer(() -> OidcSessionRegistryWebFilter.this.oidcSessionRegistry
+										.removeSessionInformation(currentId).flatMap((information) -> {
+											information = information.withSessionId(this.session.getId());
+											return OidcSessionRegistryWebFilter.this.oidcSessionRegistry
+													.saveSessionInformation(information);
+										})));
+					}
+
+					@Override
+					public Mono<Void> invalidate() {
+						String currentId = this.session.getId();
+						return this.session.invalidate()
+								.then(Mono.defer(() -> OidcSessionRegistryWebFilter.this.oidcSessionRegistry
+										.removeSessionInformation(currentId).then(Mono.empty())));
+					}
+
+					@Override
+					public Mono<Void> save() {
+						return this.session.save();
+					}
+
+					@Override
+					public boolean isExpired() {
+						return this.session.isExpired();
+					}
+
+					@Override
+					public Instant getCreationTime() {
+						return this.session.getCreationTime();
+					}
+
+					@Override
+					public Instant getLastAccessTime() {
+						return this.session.getLastAccessTime();
+					}
+
+					@Override
+					public void setMaxIdleTime(Duration maxIdleTime) {
+						this.session.setMaxIdleTime(maxIdleTime);
+					}
+
+					@Override
+					public Duration getMaxIdleTime() {
+						return this.session.getMaxIdleTime();
+					}
+
+				}
+
+			}
+
+		}
+
+		private static final class OidcSessionRegistryAuthenticationWebFilter
+				extends OAuth2LoginAuthenticationWebFilter {
+
+			private final Log logger = LogFactory.getLog(getClass());
+
+			private final ReactiveOidcSessionRegistry oidcSessionRegistry;
+
+			OidcSessionRegistryAuthenticationWebFilter(ReactiveAuthenticationManager authenticationManager,
+					ServerOAuth2AuthorizedClientRepository authorizedClientRepository,
+					ReactiveOidcSessionRegistry oidcSessionRegistry) {
+				super(authenticationManager, authorizedClientRepository);
+				this.oidcSessionRegistry = oidcSessionRegistry;
+			}
+
+			@Override
+			protected Mono<Void> onAuthenticationSuccess(Authentication authentication, WebFilterExchange webFilterExchange) {
+				if (!(authentication.getPrincipal() instanceof OidcUser user)) {
+					return super.onAuthenticationSuccess(authentication, webFilterExchange);
+				}
+				return webFilterExchange.getExchange().getSession()
+						.doOnNext((session) -> {
+							if (this.logger.isTraceEnabled()) {
+								this.logger.trace(String.format("Linking a provider [%s] session to this client's session", user.getIssuer()));
+							}
+						})
+						.flatMap((session) -> {
+							Mono<CsrfToken> csrfToken = webFilterExchange.getExchange().getAttribute(CsrfToken.class.getName());
+							return (csrfToken != null) ?
+									csrfToken.map((token) -> new OidcSessionInformation(session.getId(), Map.of(token.getHeaderName(), token.getToken()), user)) :
+									Mono.just(new OidcSessionInformation(session.getId(), Map.of(), user));
+						})
+						.flatMap(this.oidcSessionRegistry::saveSessionInformation)
+						.then(super.onAuthenticationSuccess(authentication, webFilterExchange));
+			}
+
+		}
+
 	}
 
 	public final class OAuth2ClientSpec {
@@ -4755,6 +4975,129 @@ public class ServerHttpSecurity {
 
 	}
 
+	/**
+	 * Configures OIDC 1.0 Logout support
+	 *
+	 * @author Josh Cummings
+	 * @since 6.2
+	 */
+	public final class OidcLogoutSpec {
+
+		private ReactiveClientRegistrationRepository clientRegistrationRepository;
+
+		private ReactiveOidcSessionRegistry sessionRegistry;
+
+		private BackChannelLogoutConfigurer backChannel;
+
+		/**
+		 * Configures the {@link ReactiveClientRegistrationRepository}. Default is to look
+		 * the value up as a Bean.
+		 * @param clientRegistrationRepository the repository to use
+		 * @return the {@link OidcLogoutSpec} to customize
+		 */
+		public OidcLogoutSpec clientRegistrationRepository(
+				ReactiveClientRegistrationRepository clientRegistrationRepository) {
+			Assert.notNull(clientRegistrationRepository, "clientRegistrationRepository cannot be null");
+			this.clientRegistrationRepository = clientRegistrationRepository;
+			return this;
+		}
+
+		/**
+		 * Configures the {@link ReactiveOidcSessionRegistry}. Default is to use the value
+		 * from {@link OAuth2LoginSpec#oidcSessionRegistry}, then look the value up as a
+		 * Bean, or else use an {@link InMemoryReactiveOidcSessionRegistry}.
+		 * @param sessionRegistry the registry to use
+		 * @return the {@link OidcLogoutSpec} to customize
+		 */
+		public OidcLogoutSpec oidcSessionRegistry(ReactiveOidcSessionRegistry sessionRegistry) {
+			Assert.notNull(sessionRegistry, "sessionRegistry cannot be null");
+			this.sessionRegistry = sessionRegistry;
+			return this;
+		}
+
+		/**
+		 * Configure OIDC Back-Channel Logout using the provided {@link Consumer}
+		 * @return the {@link OidcLogoutSpec} for further configuration
+		 */
+		public OidcLogoutSpec backChannel(Customizer<BackChannelLogoutConfigurer> backChannelLogoutConfigurer) {
+			if (this.backChannel == null) {
+				this.backChannel = new OidcLogoutSpec.BackChannelLogoutConfigurer();
+			}
+			backChannelLogoutConfigurer.customize(this.backChannel);
+			return this;
+		}
+
+		@Deprecated(forRemoval = true, since = "6.2")
+		public ServerHttpSecurity and() {
+			return ServerHttpSecurity.this;
+		}
+
+		void configure(ServerHttpSecurity http) {
+			if (this.backChannel != null) {
+				this.backChannel.configure(http);
+			}
+		}
+
+		private ReactiveClientRegistrationRepository getClientRegistrationRepository() {
+			if (this.clientRegistrationRepository == null) {
+				this.clientRegistrationRepository = getBeanOrNull(ReactiveClientRegistrationRepository.class);
+			}
+			return this.clientRegistrationRepository;
+		}
+
+		private ReactiveOidcSessionRegistry getSessionRegistry() {
+			if (this.sessionRegistry == null && ServerHttpSecurity.this.oauth2Login == null) {
+				return new InMemoryReactiveOidcSessionRegistry();
+			}
+			if (this.sessionRegistry == null) {
+				return ServerHttpSecurity.this.oauth2Login.oidcSessionRegistry;
+			}
+			return this.sessionRegistry;
+		}
+
+		/**
+		 * A configurer for configuring OIDC Back-Channel Logout
+		 */
+		public final class BackChannelLogoutConfigurer {
+
+			private ServerAuthenticationConverter authenticationConverter;
+
+			private final ReactiveAuthenticationManager authenticationManager = new OidcBackChannelLogoutReactiveAuthenticationManager();
+
+			private ServerLogoutHandler logoutHandler;
+
+			private ServerAuthenticationConverter authenticationConverter() {
+				if (this.authenticationConverter == null) {
+					this.authenticationConverter = new OidcLogoutServerAuthenticationConverter(
+							OidcLogoutSpec.this.getClientRegistrationRepository());
+				}
+				return this.authenticationConverter;
+			}
+
+			private ReactiveAuthenticationManager authenticationManager() {
+				return this.authenticationManager;
+			}
+
+			private ServerLogoutHandler logoutHandler() {
+				if (this.logoutHandler == null) {
+					OidcBackChannelServerLogoutHandler logoutHandler = new OidcBackChannelServerLogoutHandler();
+					logoutHandler.setSessionRegistry(OidcLogoutSpec.this.getSessionRegistry());
+					this.logoutHandler = logoutHandler;
+				}
+				return this.logoutHandler;
+			}
+
+			void configure(ServerHttpSecurity http) {
+				OidcBackChannelLogoutWebFilter filter = new OidcBackChannelLogoutWebFilter(authenticationConverter(),
+						authenticationManager());
+				filter.setLogoutHandler(logoutHandler());
+				http.addFilterBefore(filter, SecurityWebFiltersOrder.CSRF);
+			}
+
+		}
+
+	}
+
 	/**
 	 * Configures anonymous authentication
 	 *

+ 32 - 0
config/src/main/kotlin/org/springframework/security/config/annotation/web/HttpSecurityDsl.kt

@@ -868,6 +868,38 @@ class HttpSecurityDsl(private val http: HttpSecurity, private val init: HttpSecu
         this.http.oauth2ResourceServer(oauth2ResourceServerCustomizer)
     }
 
+    /**
+     * Configures OIDC 1.0 logout support.
+     *
+     * Example:
+     *
+     * ```
+     * @Configuration
+     * @EnableWebSecurity
+     * class SecurityConfig {
+     *
+     *     @Bean
+     *     fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
+     *         http {
+     *             oauth2Login { }
+     *             oidcLogout {
+     *                 backChannel { }
+     *             }
+     *         }
+     *         return http.build()
+     *     }
+     * }
+     * ```
+     *
+     * @param oidcLogoutConfiguration custom configuration to configure the
+     * OIDC 1.0 logout support
+     * @see [OidcLogoutDsl]
+     */
+    fun oidcLogout(oidcLogoutConfiguration: OidcLogoutDsl.() -> Unit) {
+        val oidcLogoutCustomizer = OidcLogoutDsl().apply(oidcLogoutConfiguration).get()
+        this.http.oidcLogout(oidcLogoutCustomizer)
+    }
+
     /**
      * Configures Remember Me authentication.
      *

+ 75 - 0
config/src/main/kotlin/org/springframework/security/config/annotation/web/OidcLogoutDsl.kt

@@ -0,0 +1,75 @@
+/*
+ * Copyright 2002-2023 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.config.annotation.web
+
+import org.springframework.security.config.annotation.web.builders.HttpSecurity
+import org.springframework.security.config.annotation.web.configurers.oauth2.client.OidcLogoutConfigurer
+import org.springframework.security.config.annotation.web.oauth2.login.OidcBackChannelLogoutDsl
+import org.springframework.security.oauth2.client.oidc.session.OidcSessionRegistry
+import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository
+
+/**
+ * A Kotlin DSL to configure [HttpSecurity] OAuth 1.0 Logout using idiomatic Kotlin code.
+ *
+ * @author Josh Cummings
+ * @since 6.2
+ */
+@SecurityMarker
+class OidcLogoutDsl {
+    var clientRegistrationRepository: ClientRegistrationRepository? = null
+    var oidcSessionRegistry: OidcSessionRegistry? = null
+
+    private var backChannel: ((OidcLogoutConfigurer<HttpSecurity>.BackChannelLogoutConfigurer) -> Unit)? = null
+
+    /**
+     * Configures the OIDC 1.0 Back-Channel endpoint.
+     *
+     * Example:
+     *
+     * ```
+     * @Configuration
+     * @EnableWebSecurity
+     * class SecurityConfig {
+     *
+     *     @Bean
+     *     fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
+     *         http {
+     *             oauth2Login { }
+     *             oidcLogout {
+     *                 backChannel { }
+     *             }
+     *         }
+     *         return http.build()
+     *     }
+     * }
+     * ```
+     *
+     * @param backChannelConfig custom configurations to configure the back-channel endpoint
+     * @see [OidcBackChannelLogoutDsl]
+     */
+    fun backChannel(backChannelConfig: OidcBackChannelLogoutDsl.() -> Unit) {
+        this.backChannel = OidcBackChannelLogoutDsl().apply(backChannelConfig).get()
+    }
+
+    internal fun get(): (OidcLogoutConfigurer<HttpSecurity>) -> Unit {
+        return { oidcLogout ->
+            clientRegistrationRepository?.also { oidcLogout.clientRegistrationRepository(clientRegistrationRepository) }
+            oidcSessionRegistry?.also { oidcLogout.oidcSessionRegistry(oidcSessionRegistry) }
+            backChannel?.also { oidcLogout.backChannel(backChannel) }
+        }
+    }
+}

+ 34 - 0
config/src/main/kotlin/org/springframework/security/config/annotation/web/oauth2/login/OidcBackChannelLogoutDsl.kt

@@ -0,0 +1,34 @@
+/*
+ * Copyright 2002-2023 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.config.annotation.web.oauth2.login
+
+import org.springframework.security.config.annotation.web.builders.HttpSecurity
+import org.springframework.security.config.annotation.web.configurers.oauth2.client.OidcLogoutConfigurer
+
+/**
+ * A Kotlin DSL to configure the OIDC 1.0 Back-Channel configuration using
+ * idiomatic Kotlin code.
+ *
+ * @author Josh Cummings
+ * @since 6.2
+ */
+@OAuth2LoginSecurityMarker
+class OidcBackChannelLogoutDsl {
+    internal fun get(): (OidcLogoutConfigurer<HttpSecurity>.BackChannelLogoutConfigurer) -> Unit {
+        return { backChannel -> }
+    }
+}

+ 33 - 1
config/src/main/kotlin/org/springframework/security/config/web/server/ServerHttpSecurityDsl.kt

@@ -1,5 +1,5 @@
 /*
- * Copyright 2002-2021 the original author or authors.
+ * Copyright 2002-2023 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.
@@ -650,6 +650,38 @@ class ServerHttpSecurityDsl(private val http: ServerHttpSecurity, private val in
         this.http.oauth2ResourceServer(oauth2ResourceServerCustomizer)
     }
 
+    /**
+     * Configures logout support using an OpenID Connect 1.0 Provider.
+     * A [ReactiveClientRegistrationRepository] is required and must be registered as a Bean or
+     * configured via [ServerOidcLogoutDsl.clientRegistrationRepository].
+     *
+     * Example:
+     *
+     * ```
+     * @Configuration
+     * @EnableWebFluxSecurity
+     * class SecurityConfig {
+     *
+     *  @Bean
+     *  fun springWebFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain {
+     *      return http {
+     *          oauth2Login { }
+     *          oidcLogout {
+     *              backChannel { }
+     *          }
+     *       }
+     *   }
+     * }
+     * ```
+     *
+     * @param oidcLogoutConfiguration custom configuration to configure the OIDC 1.0 Logout
+     * @see [ServerOidcLogoutDsl]
+     */
+    fun oidcLogout(oidcLogoutConfiguration: ServerOidcLogoutDsl.() -> Unit) {
+        val oidcLogoutCustomizer = ServerOidcLogoutDsl().apply(oidcLogoutConfiguration).get()
+        this.http.oidcLogout(oidcLogoutCustomizer)
+    }
+
     /**
      * Apply all configurations to the provided [ServerHttpSecurity]
      */

+ 30 - 0
config/src/main/kotlin/org/springframework/security/config/web/server/ServerOidcBackChannelLogoutDsl.kt

@@ -0,0 +1,30 @@
+/*
+ * Copyright 2002-2023 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.config.web.server
+
+/**
+ * A Kotlin DSL to configure [ServerHttpSecurity] OIDC 1.0 Back-Channel Logout support using idiomatic Kotlin code.
+ *
+ * @author Josh Cummings
+ * @since 6.2
+ */
+@ServerSecurityMarker
+class ServerOidcBackChannelLogoutDsl {
+    internal fun get(): (ServerHttpSecurity.OidcLogoutSpec.BackChannelLogoutConfigurer) -> Unit {
+        return { backChannel -> }
+    }
+}

+ 71 - 0
config/src/main/kotlin/org/springframework/security/config/web/server/ServerOidcLogoutDsl.kt

@@ -0,0 +1,71 @@
+/*
+ * Copyright 2002-2023 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.config.web.server
+
+import org.springframework.security.oauth2.client.oidc.server.session.ReactiveOidcSessionRegistry
+import org.springframework.security.oauth2.client.registration.ReactiveClientRegistrationRepository
+
+/**
+ * A Kotlin DSL to configure [ServerHttpSecurity] OIDC 1.0 login using idiomatic Kotlin code.
+ *
+ * @author Josh Cummings
+ * @since 6.2
+ */
+@ServerSecurityMarker
+class ServerOidcLogoutDsl {
+    var clientRegistrationRepository: ReactiveClientRegistrationRepository? = null
+    var oidcSessionRegistry: ReactiveOidcSessionRegistry? = null
+
+    private var backChannel: ((ServerHttpSecurity.OidcLogoutSpec.BackChannelLogoutConfigurer) -> Unit)? = null
+
+    /**
+     * Enables OIDC 1.0 Back-Channel Logout support.
+     *
+     * Example:
+     *
+     * ```
+     * @Configuration
+     * @EnableWebFluxSecurity
+     * class SecurityConfig {
+     *
+     *  @Bean
+     *  fun springWebFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain {
+     *      return http {
+     *          oauth2Login { }
+     *          oidcLogout {
+     *              backChannel { }
+     *          }
+     *       }
+     *   }
+     * }
+     * ```
+     *
+     * @param backChannelConfig custom configurations to configure OIDC 1.0 Back-Channel Logout support
+     * @see [ServerOidcBackChannelLogoutDsl]
+     */
+    fun backChannel(backChannelConfig: ServerOidcBackChannelLogoutDsl.() -> Unit) {
+        this.backChannel = ServerOidcBackChannelLogoutDsl().apply(backChannelConfig).get()
+    }
+
+    internal fun get(): (ServerHttpSecurity.OidcLogoutSpec) -> Unit {
+        return { oidcLogout ->
+            clientRegistrationRepository?.also { oidcLogout.clientRegistrationRepository(clientRegistrationRepository) }
+            oidcSessionRegistry?.also { oidcLogout.oidcSessionRegistry(oidcSessionRegistry) }
+            backChannel?.also { oidcLogout.backChannel(backChannel) }
+        }
+    }
+}

+ 550 - 0
config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurerTests.java

@@ -0,0 +1,550 @@
+/*
+ * Copyright 2002-2023 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.config.annotation.web.configurers.oauth2.client;
+
+import java.io.IOException;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.interfaces.RSAPublicKey;
+import java.time.Instant;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+import com.gargoylesoftware.htmlunit.util.UrlUtils;
+import com.nimbusds.jose.jwk.JWKSet;
+import com.nimbusds.jose.jwk.RSAKey;
+import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
+import com.nimbusds.jose.jwk.source.JWKSource;
+import com.nimbusds.jose.proc.SecurityContext;
+import com.nimbusds.oauth2.sdk.Scope;
+import com.nimbusds.oauth2.sdk.token.BearerAccessToken;
+import com.nimbusds.openid.connect.sdk.token.OIDCTokens;
+import jakarta.annotation.PreDestroy;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpSession;
+import okhttp3.mockwebserver.Dispatcher;
+import okhttp3.mockwebserver.MockResponse;
+import okhttp3.mockwebserver.MockWebServer;
+import okhttp3.mockwebserver.RecordedRequest;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+
+import org.springframework.beans.factory.ObjectProvider;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Import;
+import org.springframework.core.annotation.Order;
+import org.springframework.mock.web.MockHttpServletResponse;
+import org.springframework.mock.web.MockHttpSession;
+import org.springframework.mock.web.MockServletContext;
+import org.springframework.security.config.Customizer;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
+import org.springframework.security.config.test.SpringTestContext;
+import org.springframework.security.config.test.SpringTestContextExtension;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.security.core.userdetails.User;
+import org.springframework.security.core.userdetails.UserDetailsService;
+import org.springframework.security.oauth2.client.oidc.authentication.logout.LogoutTokenClaimNames;
+import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcLogoutToken;
+import org.springframework.security.oauth2.client.oidc.authentication.logout.TestOidcLogoutTokens;
+import org.springframework.security.oauth2.client.oidc.session.InMemoryOidcSessionRegistry;
+import org.springframework.security.oauth2.client.oidc.session.OidcSessionRegistry;
+import org.springframework.security.oauth2.client.registration.ClientRegistration;
+import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
+import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository;
+import org.springframework.security.oauth2.client.registration.TestClientRegistrations;
+import org.springframework.security.oauth2.core.oidc.OidcIdToken;
+import org.springframework.security.oauth2.core.oidc.TestOidcIdTokens;
+import org.springframework.security.oauth2.core.oidc.user.OidcUser;
+import org.springframework.security.oauth2.jwt.JwtClaimsSet;
+import org.springframework.security.oauth2.jwt.JwtEncoder;
+import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
+import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
+import org.springframework.security.provisioning.InMemoryUserDetailsManager;
+import org.springframework.security.web.SecurityFilterChain;
+import org.springframework.security.web.authentication.logout.LogoutHandler;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.MvcResult;
+import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.servlet.config.annotation.EnableWebMvc;
+
+import static org.hamcrest.Matchers.containsString;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.BDDMockito.willThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
+import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
+import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * Tests for {@link OidcLogoutConfigurer}
+ */
+@ExtendWith(SpringTestContextExtension.class)
+public class OidcLogoutConfigurerTests {
+
+	@Autowired
+	private MockMvc mvc;
+
+	@Autowired(required = false)
+	private MockWebServer web;
+
+	@Autowired
+	private ClientRegistration clientRegistration;
+
+	public final SpringTestContext spring = new SpringTestContext(this);
+
+	@Test
+	void logoutWhenDefaultsThenRemotelyInvalidatesSessions() throws Exception {
+		this.spring.register(WebServerConfig.class, OidcProviderConfig.class, DefaultConfig.class).autowire();
+		String registrationId = this.clientRegistration.getRegistrationId();
+		MockHttpSession session = login();
+		String logoutToken = this.mvc.perform(get("/token/logout").session(session)).andExpect(status().isOk())
+				.andReturn().getResponse().getContentAsString();
+		this.mvc.perform(post(this.web.url("/logout/connect/back-channel/" + registrationId).toString())
+				.param("logout_token", logoutToken)).andExpect(status().isOk());
+		this.mvc.perform(get("/token/logout").session(session)).andExpect(status().isUnauthorized());
+	}
+
+	@Test
+	void logoutWhenInvalidLogoutTokenThenBadRequest() throws Exception {
+		this.spring.register(WebServerConfig.class, OidcProviderConfig.class, DefaultConfig.class).autowire();
+		this.mvc.perform(get("/token/logout")).andExpect(status().isUnauthorized());
+		String registrationId = this.clientRegistration.getRegistrationId();
+		MvcResult result = this.mvc.perform(get("/oauth2/authorization/" + registrationId))
+				.andExpect(status().isFound()).andReturn();
+		MockHttpSession session = (MockHttpSession) result.getRequest().getSession();
+		String redirectUrl = UrlUtils.decode(result.getResponse().getRedirectedUrl());
+		String state = this.mvc
+				.perform(get(redirectUrl).with(
+						httpBasic(this.clientRegistration.getClientId(), this.clientRegistration.getClientSecret())))
+				.andReturn().getResponse().getContentAsString();
+		result = this.mvc.perform(get("/login/oauth2/code/" + registrationId).param("code", "code")
+				.param("state", state).session(session)).andExpect(status().isFound()).andReturn();
+		session = (MockHttpSession) result.getRequest().getSession();
+		this.mvc.perform(post(this.web.url("/logout/connect/back-channel/" + registrationId).toString())
+				.param("logout_token", "invalid")).andExpect(status().isBadRequest());
+		this.mvc.perform(get("/token/logout").session(session)).andExpect(status().isOk());
+	}
+
+	@Test
+	void logoutWhenLogoutTokenSpecifiesOneSessionThenRemotelyInvalidatesOnlyThatSession() throws Exception {
+		this.spring.register(WebServerConfig.class, OidcProviderConfig.class, DefaultConfig.class).autowire();
+		String registrationId = this.clientRegistration.getRegistrationId();
+		MockHttpSession one = login();
+		MockHttpSession two = login();
+		MockHttpSession three = login();
+		String logoutToken = this.mvc.perform(get("/token/logout").session(one)).andExpect(status().isOk()).andReturn()
+				.getResponse().getContentAsString();
+		this.mvc.perform(post(this.web.url("/logout/connect/back-channel/" + registrationId).toString())
+				.param("logout_token", logoutToken)).andExpect(status().isOk());
+		this.mvc.perform(get("/token/logout").session(one)).andExpect(status().isUnauthorized());
+		this.mvc.perform(get("/token/logout").session(two)).andExpect(status().isOk());
+		logoutToken = this.mvc.perform(get("/token/logout/all").session(three)).andExpect(status().isOk()).andReturn()
+				.getResponse().getContentAsString();
+		this.mvc.perform(post(this.web.url("/logout/connect/back-channel/" + registrationId).toString())
+				.param("logout_token", logoutToken)).andExpect(status().isOk());
+		this.mvc.perform(get("/token/logout").session(two)).andExpect(status().isUnauthorized());
+		this.mvc.perform(get("/token/logout").session(three)).andExpect(status().isUnauthorized());
+	}
+
+	@Test
+	void logoutWhenRemoteLogoutFailsThenReportsPartialLogout() throws Exception {
+		this.spring.register(WebServerConfig.class, OidcProviderConfig.class, WithBrokenLogoutConfig.class).autowire();
+		LogoutHandler logoutHandler = this.spring.getContext().getBean(LogoutHandler.class);
+		willThrow(IllegalStateException.class).given(logoutHandler).logout(any(), any(), any());
+		String registrationId = this.clientRegistration.getRegistrationId();
+		MockHttpSession one = login();
+		String logoutToken = this.mvc.perform(get("/token/logout/all").session(one)).andExpect(status().isOk())
+				.andReturn().getResponse().getContentAsString();
+		this.mvc.perform(post(this.web.url("/logout/connect/back-channel/" + registrationId).toString())
+				.param("logout_token", logoutToken)).andExpect(status().isBadRequest())
+				.andExpect(content().string(containsString("partial_logout")));
+		this.mvc.perform(get("/token/logout").session(one)).andExpect(status().isOk());
+	}
+
+	@Test
+	void logoutWhenCustomComponentsThenUses() throws Exception {
+		this.spring.register(WebServerConfig.class, OidcProviderConfig.class, WithCustomComponentsConfig.class)
+				.autowire();
+		String registrationId = this.clientRegistration.getRegistrationId();
+		MockHttpSession session = login();
+		String logoutToken = this.mvc.perform(get("/token/logout").session(session)).andExpect(status().isOk())
+				.andReturn().getResponse().getContentAsString();
+		this.mvc.perform(post(this.web.url("/logout/connect/back-channel/" + registrationId).toString())
+				.param("logout_token", logoutToken)).andExpect(status().isOk());
+		this.mvc.perform(get("/token/logout").session(session)).andExpect(status().isUnauthorized());
+		OidcSessionRegistry sessionRegistry = this.spring.getContext().getBean(OidcSessionRegistry.class);
+		verify(sessionRegistry).saveSessionInformation(any());
+		verify(sessionRegistry).removeSessionInformation(any(OidcLogoutToken.class));
+	}
+
+	private MockHttpSession login() throws Exception {
+		MockMvcDispatcher dispatcher = (MockMvcDispatcher) this.web.getDispatcher();
+		this.mvc.perform(get("/token/logout")).andExpect(status().isUnauthorized());
+		String registrationId = this.clientRegistration.getRegistrationId();
+		MvcResult result = this.mvc.perform(get("/oauth2/authorization/" + registrationId))
+				.andExpect(status().isFound()).andReturn();
+		MockHttpSession session = (MockHttpSession) result.getRequest().getSession();
+		String redirectUrl = UrlUtils.decode(result.getResponse().getRedirectedUrl());
+		String state = this.mvc
+				.perform(get(redirectUrl).with(
+						httpBasic(this.clientRegistration.getClientId(), this.clientRegistration.getClientSecret())))
+				.andReturn().getResponse().getContentAsString();
+		result = this.mvc.perform(get("/login/oauth2/code/" + registrationId).param("code", "code")
+				.param("state", state).session(session)).andExpect(status().isFound()).andReturn();
+		session = (MockHttpSession) result.getRequest().getSession();
+		dispatcher.registerSession(session);
+		return session;
+	}
+
+	@Configuration
+	static class RegistrationConfig {
+
+		@Autowired(required = false)
+		MockWebServer web;
+
+		@Bean
+		ClientRegistration clientRegistration() {
+			if (this.web == null) {
+				return TestClientRegistrations.clientRegistration().build();
+			}
+			String issuer = this.web.url("/").toString();
+			return TestClientRegistrations.clientRegistration().issuerUri(issuer).jwkSetUri(issuer + "jwks")
+					.tokenUri(issuer + "token").userInfoUri(issuer + "user").scope("openid").build();
+		}
+
+		@Bean
+		ClientRegistrationRepository clientRegistrationRepository(ClientRegistration clientRegistration) {
+			return new InMemoryClientRegistrationRepository(clientRegistration);
+		}
+
+	}
+
+	@Configuration
+	@EnableWebSecurity
+	@Import(RegistrationConfig.class)
+	static class DefaultConfig {
+
+		@Bean
+		@Order(1)
+		SecurityFilterChain filters(HttpSecurity http) throws Exception {
+			// @formatter:off
+			http
+				.authorizeHttpRequests((authorize) -> authorize.anyRequest().authenticated())
+				.oauth2Login(Customizer.withDefaults())
+				.oidcLogout((oidc) -> oidc.backChannel(Customizer.withDefaults()));
+			// @formatter:on
+
+			return http.build();
+		}
+
+	}
+
+	@Configuration
+	@EnableWebSecurity
+	@Import(RegistrationConfig.class)
+	static class WithCustomComponentsConfig {
+
+		OidcSessionRegistry sessionRegistry = spy(new InMemoryOidcSessionRegistry());
+
+		@Bean
+		@Order(1)
+		SecurityFilterChain filters(HttpSecurity http) throws Exception {
+			// @formatter:off
+			http
+				.authorizeHttpRequests((authorize) -> authorize.anyRequest().authenticated())
+				.oauth2Login((oauth2) -> oauth2.oidcSessionRegistry(this.sessionRegistry))
+				.oidcLogout((oidc) -> oidc.backChannel(Customizer.withDefaults()));
+			// @formatter:on
+
+			return http.build();
+		}
+
+		@Bean
+		OidcSessionRegistry sessionRegistry() {
+			return this.sessionRegistry;
+		}
+
+	}
+
+	@Configuration
+	@EnableWebSecurity
+	@Import(RegistrationConfig.class)
+	static class WithBrokenLogoutConfig {
+
+		private final LogoutHandler logoutHandler = mock(LogoutHandler.class);
+
+		@Bean
+		@Order(1)
+		SecurityFilterChain filters(HttpSecurity http) throws Exception {
+			// @formatter:off
+			http
+					.authorizeHttpRequests((authorize) -> authorize.anyRequest().authenticated())
+					.logout((logout) -> logout.addLogoutHandler(this.logoutHandler))
+					.oauth2Login(Customizer.withDefaults())
+					.oidcLogout((oidc) -> oidc.backChannel(Customizer.withDefaults()));
+			// @formatter:on
+
+			return http.build();
+		}
+
+		@Bean
+		LogoutHandler logoutHandler() {
+			return this.logoutHandler;
+		}
+
+	}
+
+	@Configuration
+	@EnableWebSecurity
+	@EnableWebMvc
+	@RestController
+	static class OidcProviderConfig {
+
+		private static final RSAKey key = key();
+
+		private static final JWKSource<SecurityContext> jwks = jwks(key);
+
+		private static RSAKey key() {
+			try {
+				KeyPair pair = KeyPairGenerator.getInstance("RSA").generateKeyPair();
+				return new RSAKey.Builder((RSAPublicKey) pair.getPublic()).privateKey(pair.getPrivate()).build();
+			}
+			catch (Exception ex) {
+				throw new RuntimeException(ex);
+			}
+		}
+
+		private static JWKSource<SecurityContext> jwks(RSAKey key) {
+			try {
+				return new ImmutableJWKSet<>(new JWKSet(key));
+			}
+			catch (Exception ex) {
+				throw new RuntimeException(ex);
+			}
+		}
+
+		private final String username = "user";
+
+		private final JwtEncoder encoder = new NimbusJwtEncoder(jwks);
+
+		private String nonce;
+
+		@Autowired
+		ClientRegistration registration;
+
+		@Bean
+		@Order(0)
+		SecurityFilterChain authorizationServer(HttpSecurity http, ClientRegistration registration) throws Exception {
+			// @formatter:off
+			http
+				.securityMatcher("/jwks", "/login/oauth/authorize", "/nonce", "/token", "/token/logout", "/user")
+				.authorizeHttpRequests((authorize) -> authorize
+					.requestMatchers("/jwks").permitAll()
+					.anyRequest().authenticated()
+				)
+				.httpBasic(Customizer.withDefaults())
+				.oauth2ResourceServer((oauth2) -> oauth2
+					.jwt((jwt) -> jwt.jwkSetUri(registration.getProviderDetails().getJwkSetUri()))
+				);
+			// @formatter:off
+
+			return http.build();
+		}
+
+		@Bean
+		UserDetailsService users(ClientRegistration registration) {
+			return new InMemoryUserDetailsManager(User.withUsername(registration.getClientId())
+					.password("{noop}" + registration.getClientSecret()).authorities("APP").build());
+		}
+
+		@GetMapping("/login/oauth/authorize")
+		String nonce(@RequestParam("nonce") String nonce, @RequestParam("state") String state) {
+			this.nonce = nonce;
+			return state;
+		}
+
+		@PostMapping("/token")
+		Map<String, Object> accessToken(HttpServletRequest request) {
+			HttpSession session = request.getSession();
+			JwtEncoderParameters parameters = JwtEncoderParameters
+					.from(JwtClaimsSet.builder().id("id").subject(this.username)
+							.issuer(this.registration.getProviderDetails().getIssuerUri()).issuedAt(Instant.now())
+							.expiresAt(Instant.now().plusSeconds(86400)).claim("scope", "openid").build());
+			String token = this.encoder.encode(parameters).getTokenValue();
+			return new OIDCTokens(idToken(session.getId()), new BearerAccessToken(token, 86400, new Scope("openid")), null)
+					.toJSONObject();
+		}
+
+		String idToken(String sessionId) {
+			OidcIdToken token = TestOidcIdTokens.idToken().issuer(this.registration.getProviderDetails().getIssuerUri())
+					.subject(this.username).expiresAt(Instant.now().plusSeconds(86400))
+					.audience(List.of(this.registration.getClientId())).nonce(this.nonce)
+					.claim(LogoutTokenClaimNames.SID, sessionId).build();
+			JwtEncoderParameters parameters = JwtEncoderParameters
+					.from(JwtClaimsSet.builder().claims((claims) -> claims.putAll(token.getClaims())).build());
+			return this.encoder.encode(parameters).getTokenValue();
+		}
+
+		@GetMapping("/user")
+		Map<String, Object> userinfo() {
+			return Map.of("sub", this.username, "id", this.username);
+		}
+
+		@GetMapping("/jwks")
+		String jwks() {
+			return new JWKSet(key).toString();
+		}
+
+		@GetMapping("/token/logout")
+		String logoutToken(@AuthenticationPrincipal OidcUser user) {
+			OidcLogoutToken token = TestOidcLogoutTokens.withUser(user)
+					.audience(List.of(this.registration.getClientId())).build();
+			JwtEncoderParameters parameters = JwtEncoderParameters
+					.from(JwtClaimsSet.builder().claims((claims) -> claims.putAll(token.getClaims())).build());
+			return this.encoder.encode(parameters).getTokenValue();
+		}
+
+		@GetMapping("/token/logout/all")
+		String logoutTokenAll(@AuthenticationPrincipal OidcUser user) {
+			OidcLogoutToken token = TestOidcLogoutTokens.withUser(user)
+					.audience(List.of(this.registration.getClientId()))
+					.claims((claims) -> claims.remove(LogoutTokenClaimNames.SID)).build();
+			JwtEncoderParameters parameters = JwtEncoderParameters
+					.from(JwtClaimsSet.builder().claims((claims) -> claims.putAll(token.getClaims())).build());
+			return this.encoder.encode(parameters).getTokenValue();
+		}
+	}
+
+	@Configuration
+	static class WebServerConfig {
+
+		private final MockWebServer server = new MockWebServer();
+
+		@Bean
+		MockWebServer web(ObjectProvider<MockMvc> mvc) {
+			this.server.setDispatcher(new MockMvcDispatcher(mvc));
+			return this.server;
+		}
+
+		@PreDestroy
+		void shutdown() throws IOException {
+			this.server.shutdown();
+		}
+
+	}
+
+	private static class MockMvcDispatcher extends Dispatcher {
+
+		private final Map<String, MockHttpSession> session = new ConcurrentHashMap<>();
+
+		private final ObjectProvider<MockMvc> mvcProvider;
+
+		private MockMvc mvc;
+
+		MockMvcDispatcher(ObjectProvider<MockMvc> mvc) {
+			this.mvcProvider = mvc;
+		}
+
+		@Override
+		public MockResponse dispatch(RecordedRequest request) throws InterruptedException {
+			this.mvc = this.mvcProvider.getObject();
+			String method = request.getMethod();
+			String path = request.getPath();
+			String csrf = request.getHeader("X-CSRF-TOKEN");
+			MockHttpSession session = session(request);
+			MockHttpServletRequestBuilder builder;
+			if ("GET".equals(method)) {
+				builder = get(path);
+			}
+			else {
+				builder = post(path).content(request.getBody().readUtf8());
+				if (csrf != null) {
+					builder.header("X-CSRF-TOKEN", csrf);
+				}
+				else {
+					builder.with(csrf());
+				}
+			}
+			for (Map.Entry<String, List<String>> header : request.getHeaders().toMultimap().entrySet()) {
+				builder.header(header.getKey(), header.getValue().iterator().next());
+			}
+			try {
+				MockHttpServletResponse mvcResponse = this.mvc.perform(builder.session(session)).andReturn().getResponse();
+				return toMockResponse(mvcResponse);
+			}
+			catch (Exception ex) {
+				MockResponse response = new MockResponse();
+				response.setResponseCode(500);
+				return response;
+			}
+		}
+
+		void registerSession(MockHttpSession session) {
+			this.session.put(session.getId(), session);
+		}
+
+		private MockHttpSession session(RecordedRequest request) {
+			String cookieHeaderValue = request.getHeader("Cookie");
+			if (cookieHeaderValue == null) {
+				return new MockHttpSession();
+			}
+			String[] cookies = cookieHeaderValue.split(";");
+			for (String cookie : cookies) {
+				String[] parts = cookie.split("=");
+				if ("JSESSIONID".equals(parts[0])) {
+					return this.session.computeIfAbsent(parts[1],
+							(k) -> new MockHttpSession(new MockServletContext(), parts[1]));
+				}
+			}
+			return new MockHttpSession();
+		}
+
+		private MockResponse toMockResponse(MockHttpServletResponse mvcResponse) {
+			MockResponse response = new MockResponse();
+			response.setResponseCode(mvcResponse.getStatus());
+			for (String name : mvcResponse.getHeaderNames()) {
+				response.addHeader(name, mvcResponse.getHeaderValue(name));
+			}
+			response.setBody(getContentAsString(mvcResponse));
+			return response;
+		}
+
+		private String getContentAsString(MockHttpServletResponse response) {
+			try {
+				return response.getContentAsString();
+			}
+			catch (Exception ex) {
+				throw new RuntimeException(ex);
+			}
+		}
+
+	}
+
+}

+ 595 - 0
config/src/test/java/org/springframework/security/config/web/server/OidcLogoutSpecTests.java

@@ -0,0 +1,595 @@
+/*
+ * 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.config.web.server;
+
+import java.io.IOException;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.interfaces.RSAPublicKey;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+import com.gargoylesoftware.htmlunit.util.UrlUtils;
+import com.nimbusds.jose.jwk.JWKSet;
+import com.nimbusds.jose.jwk.RSAKey;
+import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
+import com.nimbusds.jose.jwk.source.JWKSource;
+import com.nimbusds.jose.proc.SecurityContext;
+import com.nimbusds.oauth2.sdk.Scope;
+import com.nimbusds.oauth2.sdk.token.BearerAccessToken;
+import com.nimbusds.openid.connect.sdk.token.OIDCTokens;
+import jakarta.annotation.PreDestroy;
+import okhttp3.mockwebserver.Dispatcher;
+import okhttp3.mockwebserver.MockResponse;
+import okhttp3.mockwebserver.MockWebServer;
+import okhttp3.mockwebserver.RecordedRequest;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import reactor.core.publisher.Mono;
+
+import org.springframework.beans.factory.ObjectProvider;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.ApplicationContext;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Import;
+import org.springframework.core.annotation.Order;
+import org.springframework.http.ResponseCookie;
+import org.springframework.http.client.reactive.ClientHttpConnector;
+import org.springframework.security.authentication.TestingAuthenticationToken;
+import org.springframework.security.config.Customizer;
+import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
+import org.springframework.security.config.test.SpringTestContext;
+import org.springframework.security.config.test.SpringTestContextExtension;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.security.core.userdetails.MapReactiveUserDetailsService;
+import org.springframework.security.core.userdetails.ReactiveUserDetailsService;
+import org.springframework.security.core.userdetails.User;
+import org.springframework.security.oauth2.client.oidc.authentication.logout.LogoutTokenClaimNames;
+import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcLogoutToken;
+import org.springframework.security.oauth2.client.oidc.authentication.logout.TestOidcLogoutTokens;
+import org.springframework.security.oauth2.client.oidc.server.session.InMemoryReactiveOidcSessionRegistry;
+import org.springframework.security.oauth2.client.oidc.server.session.ReactiveOidcSessionRegistry;
+import org.springframework.security.oauth2.client.registration.ClientRegistration;
+import org.springframework.security.oauth2.client.registration.InMemoryReactiveClientRegistrationRepository;
+import org.springframework.security.oauth2.client.registration.ReactiveClientRegistrationRepository;
+import org.springframework.security.oauth2.client.registration.TestClientRegistrations;
+import org.springframework.security.oauth2.core.oidc.OidcIdToken;
+import org.springframework.security.oauth2.core.oidc.TestOidcIdTokens;
+import org.springframework.security.oauth2.core.oidc.user.OidcUser;
+import org.springframework.security.oauth2.jwt.JwtClaimsSet;
+import org.springframework.security.oauth2.jwt.JwtEncoder;
+import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
+import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
+import org.springframework.security.web.server.SecurityWebFilterChain;
+import org.springframework.security.web.server.authentication.logout.ServerLogoutHandler;
+import org.springframework.security.web.server.util.matcher.OrServerWebExchangeMatcher;
+import org.springframework.security.web.server.util.matcher.PathPatternParserServerWebExchangeMatcher;
+import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher;
+import org.springframework.test.web.reactive.server.FluxExchangeResult;
+import org.springframework.test.web.reactive.server.WebTestClient;
+import org.springframework.test.web.reactive.server.WebTestClientConfigurer;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.context.ConfigurableWebApplicationContext;
+import org.springframework.web.reactive.config.EnableWebFlux;
+import org.springframework.web.reactive.function.BodyInserters;
+import org.springframework.web.server.WebSession;
+import org.springframework.web.server.adapter.WebHttpHandlerBuilder;
+
+import static org.hamcrest.Matchers.containsString;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.atLeastOnce;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
+import static org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers.csrf;
+import static org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers.mockAuthentication;
+import static org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers.springSecurity;
+
+/**
+ * Tests for
+ * {@link ServerHttpSecurity.OAuth2ResourceServerSpec}
+ */
+@ExtendWith({ SpringTestContextExtension.class })
+public class OidcLogoutSpecTests {
+
+	private static final String SESSION_COOKIE_NAME = "SESSION";
+
+	private WebTestClient test;
+
+	@Autowired(required = false)
+	private MockWebServer web;
+
+	@Autowired
+	private ClientRegistration clientRegistration;
+
+	public final SpringTestContext spring = new SpringTestContext(this);
+
+	@Autowired
+	public void setApplicationContext(ApplicationContext context) {
+		this.test = WebTestClient.bindToApplicationContext(context)
+				.apply(springSecurity())
+				.configureClient().responseTimeout(Duration.ofDays(1))
+				.build();
+		if (context instanceof ConfigurableWebApplicationContext configurable) {
+			configurable.getBeanFactory().registerResolvableDependency(WebTestClient.class, this.test);
+		}
+	}
+
+	@Test
+	void logoutWhenDefaultsThenRemotelyInvalidatesSessions() {
+		this.spring.register(WebServerConfig.class, OidcProviderConfig.class, DefaultConfig.class).autowire();
+		String registrationId = this.clientRegistration.getRegistrationId();
+		String session = login();
+		String logoutToken = this.test.mutateWith(session(session)).get().uri("/token/logout").exchange().expectStatus()
+				.isOk().returnResult(String.class).getResponseBody().blockFirst();
+		this.test.post().uri(this.web.url("/logout/connect/back-channel/" + registrationId).toString())
+				.body(BodyInserters.fromFormData("logout_token", logoutToken)).exchange().expectStatus().isOk();
+		this.test.mutateWith(session(session)).get().uri("/token/logout").exchange().expectStatus().isUnauthorized();
+	}
+
+	@Test
+	void logoutWhenInvalidLogoutTokenThenBadRequest() {
+		this.spring.register(WebServerConfig.class, OidcProviderConfig.class, DefaultConfig.class).autowire();
+		this.test.get().uri("/token/logout").exchange().expectStatus().isUnauthorized();
+		String registrationId = this.clientRegistration.getRegistrationId();
+		FluxExchangeResult<String> result = this.test.get().uri("/oauth2/authorization/" + registrationId).exchange()
+				.expectStatus().isFound().returnResult(String.class);
+		String session = sessionId(result);
+		String redirectUrl = UrlUtils.decode(result.getResponseHeaders().getLocation().toString());
+		String state = this.test
+				.mutateWith(mockAuthentication(new TestingAuthenticationToken(this.clientRegistration.getClientId(),
+						this.clientRegistration.getClientSecret(), "APP")))
+				.get().uri(redirectUrl).exchange().returnResult(String.class).getResponseBody().blockFirst();
+		result = this.test.get().uri("/login/oauth2/code/" + registrationId + "?code=code&state=" + state)
+				.cookie("SESSION", session).exchange().expectStatus().isFound().returnResult(String.class);
+		session = sessionId(result);
+		this.test.post().uri(this.web.url("/logout/connect/back-channel/" + registrationId).toString())
+				.body(BodyInserters.fromFormData("logout_token", "invalid")).exchange().expectStatus().isBadRequest();
+		this.test.get().uri("/token/logout").cookie("SESSION", session).exchange().expectStatus().isOk();
+	}
+
+	@Test
+	void logoutWhenLogoutTokenSpecifiesOneSessionThenRemotelyInvalidatesOnlyThatSession() throws Exception {
+		this.spring.register(WebServerConfig.class, OidcProviderConfig.class, DefaultConfig.class).autowire();
+		String registrationId = this.clientRegistration.getRegistrationId();
+		String one = login();
+		String two = login();
+		String three = login();
+		String logoutToken = this.test.get().uri("/token/logout").cookie("SESSION", one).exchange().expectStatus()
+				.isOk().returnResult(String.class).getResponseBody().blockFirst();
+		this.test.post().uri(this.web.url("/logout/connect/back-channel/" + registrationId).toString())
+				.body(BodyInserters.fromFormData("logout_token", logoutToken)).exchange().expectStatus().isOk();
+		this.test.get().uri("/token/logout").cookie("SESSION", one).exchange().expectStatus().isUnauthorized();
+		this.test.get().uri("/token/logout").cookie("SESSION", two).exchange().expectStatus().isOk();
+		logoutToken = this.test.get().uri("/token/logout/all").cookie("SESSION", three).exchange().expectStatus().isOk()
+				.returnResult(String.class).getResponseBody().blockFirst();
+		this.test.post().uri(this.web.url("/logout/connect/back-channel/" + registrationId).toString())
+				.body(BodyInserters.fromFormData("logout_token", logoutToken)).exchange().expectStatus().isOk();
+		this.test.get().uri("/token/logout").cookie("SESSION", two).exchange().expectStatus().isUnauthorized();
+		this.test.get().uri("/token/logout").cookie("SESSION", three).exchange().expectStatus().isUnauthorized();
+	}
+
+	@Test
+	void logoutWhenRemoteLogoutFailsThenReportsPartialLogout() {
+		this.spring.register(WebServerConfig.class, OidcProviderConfig.class, WithBrokenLogoutConfig.class).autowire();
+		ServerLogoutHandler logoutHandler = this.spring.getContext().getBean(ServerLogoutHandler.class);
+		given(logoutHandler.logout(any(), any())).willReturn(Mono.error(() -> new IllegalStateException("illegal")));
+		String registrationId = this.clientRegistration.getRegistrationId();
+		String one = login();
+		String logoutToken = this.test.get().uri("/token/logout/all").cookie("SESSION", one).exchange().expectStatus()
+				.isOk().returnResult(String.class).getResponseBody().blockFirst();
+		this.test.post().uri(this.web.url("/logout/connect/back-channel/" + registrationId).toString())
+				.body(BodyInserters.fromFormData("logout_token", logoutToken)).exchange().expectStatus().isBadRequest()
+				.expectBody(String.class).value(containsString("partial_logout"));
+		this.test.get().uri("/token/logout").cookie("SESSION", one).exchange().expectStatus().isOk();
+	}
+
+	@Test
+	void logoutWhenCustomComponentsThenUses() {
+		this.spring.register(WebServerConfig.class, OidcProviderConfig.class, WithCustomComponentsConfig.class)
+				.autowire();
+		String registrationId = this.clientRegistration.getRegistrationId();
+		String sessionId = login();
+		String logoutToken = this.test.get().uri("/token/logout").cookie("SESSION", sessionId).exchange().expectStatus()
+				.isOk().returnResult(String.class).getResponseBody().blockFirst();
+		this.test.post().uri(this.web.url("/logout/connect/back-channel/" + registrationId).toString())
+				.body(BodyInserters.fromFormData("logout_token", logoutToken)).exchange().expectStatus().isOk();
+		this.test.get().uri("/token/logout").cookie("SESSION", sessionId).exchange().expectStatus().isUnauthorized();
+		ReactiveOidcSessionRegistry sessionRegistry = this.spring.getContext()
+				.getBean(ReactiveOidcSessionRegistry.class);
+		verify(sessionRegistry, atLeastOnce()).saveSessionInformation(any());
+		verify(sessionRegistry, atLeastOnce()).removeSessionInformation(any(OidcLogoutToken.class));
+	}
+
+	private String login() {
+		this.test.get().uri("/token/logout").exchange().expectStatus().isUnauthorized();
+		String registrationId = this.clientRegistration.getRegistrationId();
+		FluxExchangeResult<String> result = this.test.get().uri("/oauth2/authorization/" + registrationId).exchange()
+				.expectStatus().isFound().returnResult(String.class);
+		String sessionId = sessionId(result);
+		String redirectUrl = UrlUtils.decode(result.getResponseHeaders().getLocation().toString());
+		result = this.test
+				.mutateWith(mockAuthentication(new TestingAuthenticationToken(this.clientRegistration.getClientId(),
+						this.clientRegistration.getClientSecret(), "APP")))
+				.get().uri(redirectUrl).exchange().returnResult(String.class);
+		String state = result.getResponseBody().blockFirst();
+		result = this.test.mutateWith(session(sessionId)).get()
+				.uri("/login/oauth2/code/" + registrationId + "?code=code&state=" + state).exchange().expectStatus()
+				.isFound().returnResult(String.class);
+		return sessionId(result);
+	}
+
+	private String sessionId(FluxExchangeResult<?> result) {
+		List<ResponseCookie> cookies = result.getResponseCookies().get(SESSION_COOKIE_NAME);
+		if (cookies == null || cookies.isEmpty()) {
+			return null;
+		}
+		return cookies.get(0).getValue();
+	}
+
+	static SessionMutator session(String session) {
+		return new SessionMutator(session);
+	}
+
+	private record SessionMutator(String session) implements WebTestClientConfigurer {
+
+	@Override
+	public void afterConfigurerAdded(WebTestClient.Builder builder, WebHttpHandlerBuilder httpHandlerBuilder,
+			ClientHttpConnector connector) {
+		builder.defaultCookie(SESSION_COOKIE_NAME, this.session);
+	}
+
+}
+
+@Configuration
+static class RegistrationConfig {
+
+	@Autowired(required = false)
+	MockWebServer web;
+
+	@Bean
+	ClientRegistration clientRegistration() {
+		if (this.web == null) {
+			return TestClientRegistrations.clientRegistration().build();
+		}
+		String issuer = this.web.url("/").toString();
+		return TestClientRegistrations.clientRegistration().issuerUri(issuer).jwkSetUri(issuer + "jwks")
+				.tokenUri(issuer + "token").userInfoUri(issuer + "user").scope("openid").build();
+	}
+
+	@Bean
+	ReactiveClientRegistrationRepository clientRegistrationRepository(ClientRegistration clientRegistration) {
+		return new InMemoryReactiveClientRegistrationRepository(clientRegistration);
+	}
+
+}
+
+@Configuration
+@EnableWebFluxSecurity
+@Import(RegistrationConfig.class)
+static class DefaultConfig {
+
+	@Bean
+	@Order(1)
+	SecurityWebFilterChain filters(ServerHttpSecurity http) throws Exception {
+		// @formatter:off
+			http
+					.authorizeExchange((authorize) -> authorize.anyExchange().authenticated())
+					.oauth2Login(Customizer.withDefaults())
+					.oidcLogout((oidc) -> oidc.backChannel(Customizer.withDefaults()));
+			// @formatter:on
+
+		return http.build();
+	}
+
+}
+
+@Configuration
+@EnableWebFluxSecurity
+@Import(RegistrationConfig.class)
+static class WithCustomComponentsConfig {
+
+	ReactiveOidcSessionRegistry sessionRegistry = spy(new InMemoryReactiveOidcSessionRegistry());
+
+	@Bean
+	@Order(1)
+	SecurityWebFilterChain filters(ServerHttpSecurity http) throws Exception {
+		// @formatter:off
+			http
+					.authorizeExchange((authorize) -> authorize.anyExchange().authenticated())
+					.oauth2Login((oauth2) -> oauth2.oidcSessionRegistry(this.sessionRegistry))
+					.oidcLogout((oidc) -> oidc.backChannel(Customizer.withDefaults()));
+			// @formatter:on
+
+		return http.build();
+	}
+
+	@Bean
+	ReactiveOidcSessionRegistry sessionRegistry() {
+		return this.sessionRegistry;
+	}
+
+}
+
+@Configuration
+@EnableWebFluxSecurity
+@Import(RegistrationConfig.class)
+static class WithBrokenLogoutConfig {
+
+	private final ServerLogoutHandler logoutHandler = mock(ServerLogoutHandler.class);
+
+	@Bean
+	@Order(1)
+	SecurityWebFilterChain filters(ServerHttpSecurity http) throws Exception {
+		// @formatter:off
+			http
+					.authorizeExchange((authorize) -> authorize.anyExchange().authenticated())
+					.logout((logout) -> logout.logoutHandler(this.logoutHandler))
+					.oauth2Login(Customizer.withDefaults())
+					.oidcLogout((oidc) -> oidc.backChannel(Customizer.withDefaults()));
+			// @formatter:on
+
+		return http.build();
+	}
+
+	@Bean
+	ServerLogoutHandler logoutHandler() {
+		return this.logoutHandler;
+	}
+
+}
+
+@Configuration
+@EnableWebFluxSecurity
+@EnableWebFlux
+@RestController
+static class OidcProviderConfig {
+
+	private static final RSAKey key = key();
+
+	private static final JWKSource<SecurityContext> jwks = jwks(key);
+
+	private static RSAKey key() {
+		try {
+			KeyPair pair = KeyPairGenerator.getInstance("RSA").generateKeyPair();
+			return new RSAKey.Builder((RSAPublicKey) pair.getPublic()).privateKey(pair.getPrivate()).build();
+		}
+		catch (Exception ex) {
+			throw new RuntimeException(ex);
+		}
+	}
+
+	private static JWKSource<SecurityContext> jwks(RSAKey key) {
+		try {
+			return new ImmutableJWKSet<>(new JWKSet(key));
+		}
+		catch (Exception ex) {
+			throw new RuntimeException(ex);
+		}
+	}
+
+	private final String username = "user";
+
+	private final JwtEncoder encoder = new NimbusJwtEncoder(jwks);
+
+	private String nonce;
+
+	@Autowired
+	ClientRegistration registration;
+
+	static ServerWebExchangeMatcher or(String... patterns) {
+		List<ServerWebExchangeMatcher> matchers = new ArrayList<>();
+		for (String pattern : patterns) {
+			matchers.add(new PathPatternParserServerWebExchangeMatcher(pattern));
+		}
+		return new OrServerWebExchangeMatcher(matchers);
+	}
+
+	@Bean
+	@Order(0)
+	SecurityWebFilterChain authorizationServer(ServerHttpSecurity http, ClientRegistration registration)
+			throws Exception {
+		// @formatter:off
+			http
+					.securityMatcher(or("/jwks", "/login/oauth/authorize", "/nonce", "/token", "/token/logout", "/user"))
+					.authorizeExchange((authorize) -> authorize
+							.pathMatchers("/jwks").permitAll()
+							.anyExchange().authenticated()
+					)
+					.httpBasic(Customizer.withDefaults())
+					.oauth2ResourceServer((oauth2) -> oauth2
+							.jwt((jwt) -> jwt.jwkSetUri(registration.getProviderDetails().getJwkSetUri()))
+					);
+			// @formatter:off
+
+			return http.build();
+		}
+
+		@Bean
+		ReactiveUserDetailsService users(ClientRegistration registration) {
+			return new MapReactiveUserDetailsService(User.withUsername(registration.getClientId())
+					.password("{noop}" + registration.getClientSecret()).authorities("APP").build());
+		}
+
+		@GetMapping("/login/oauth/authorize")
+		String nonce(@RequestParam("nonce") String nonce, @RequestParam("state") String state) {
+			this.nonce = nonce;
+			return state;
+		}
+
+		@PostMapping("/token")
+		Map<String, Object> accessToken(WebSession session) {
+			JwtEncoderParameters parameters = JwtEncoderParameters
+					.from(JwtClaimsSet.builder().id("id").subject(this.username)
+							.issuer(this.registration.getProviderDetails().getIssuerUri()).issuedAt(Instant.now())
+							.expiresAt(Instant.now().plusSeconds(86400)).claim("scope", "openid").build());
+			String token = this.encoder.encode(parameters).getTokenValue();
+			return new OIDCTokens(idToken(session.getId()), new BearerAccessToken(token, 86400, new Scope("openid")), null)
+					.toJSONObject();
+		}
+
+		String idToken(String sessionId) {
+			OidcIdToken token = TestOidcIdTokens.idToken().issuer(this.registration.getProviderDetails().getIssuerUri())
+					.subject(this.username).expiresAt(Instant.now().plusSeconds(86400))
+					.audience(List.of(this.registration.getClientId())).nonce(this.nonce)
+					.claim(LogoutTokenClaimNames.SID, sessionId).build();
+			JwtEncoderParameters parameters = JwtEncoderParameters
+					.from(JwtClaimsSet.builder().claims((claims) -> claims.putAll(token.getClaims())).build());
+			return this.encoder.encode(parameters).getTokenValue();
+		}
+
+		@GetMapping("/user")
+		Map<String, Object> userinfo() {
+			return Map.of("sub", this.username, "id", this.username);
+		}
+
+		@GetMapping("/jwks")
+		String jwks() {
+			return new JWKSet(key).toString();
+		}
+
+		@GetMapping("/token/logout")
+		String logoutToken(@AuthenticationPrincipal OidcUser user) {
+			OidcLogoutToken token = TestOidcLogoutTokens.withUser(user)
+					.audience(List.of(this.registration.getClientId())).build();
+			JwtEncoderParameters parameters = JwtEncoderParameters
+					.from(JwtClaimsSet.builder().claims((claims) -> claims.putAll(token.getClaims())).build());
+			return this.encoder.encode(parameters).getTokenValue();
+		}
+
+		@GetMapping("/token/logout/all")
+		String logoutTokenAll(@AuthenticationPrincipal OidcUser user) {
+			OidcLogoutToken token = TestOidcLogoutTokens.withUser(user)
+					.audience(List.of(this.registration.getClientId()))
+					.claims((claims) -> claims.remove(LogoutTokenClaimNames.SID)).build();
+			JwtEncoderParameters parameters = JwtEncoderParameters
+					.from(JwtClaimsSet.builder().claims((claims) -> claims.putAll(token.getClaims())).build());
+			return this.encoder.encode(parameters).getTokenValue();
+		}
+	}
+
+	@Configuration
+	static class WebServerConfig {
+
+		private final MockWebServer server = new MockWebServer();
+
+		@Bean
+		MockWebServer web(ObjectProvider<WebTestClient> web) {
+			this.server.setDispatcher(new WebTestClientDispatcher(web));
+			return this.server;
+		}
+
+		@PreDestroy
+		void shutdown() throws IOException {
+			this.server.shutdown();
+		}
+
+	}
+
+	private static class WebTestClientDispatcher extends Dispatcher {
+
+		private final ObjectProvider<WebTestClient> webProvider;
+
+		private WebTestClient web;
+
+		WebTestClientDispatcher(ObjectProvider<WebTestClient> web) {
+			this.webProvider = web;
+		}
+
+		@Override
+		public MockResponse dispatch(RecordedRequest request) throws InterruptedException {
+			this.web = this.webProvider.getObject();
+			String method = request.getMethod();
+			String path = request.getPath();
+			String csrf = request.getHeader("X-CSRF-TOKEN");
+			String sessionId = session(request);
+			WebTestClient.RequestHeadersSpec<?> r;
+			if ("GET".equals(method)) {
+				r = this.web.get().uri(path);
+			}
+			else {
+				WebTestClient.RequestBodySpec body;
+				if (csrf == null) {
+					body = this.web.mutateWith(csrf()).post().uri(path);
+				}
+				else {
+					body = this.web.post().uri(path).header("X-CSRF-TOKEN", csrf);
+				}
+				body.body(BodyInserters.fromValue(request.getBody().readUtf8()));
+				r = body;
+			}
+			for (Map.Entry<String, List<String>> header : request.getHeaders().toMultimap().entrySet()) {
+				if (header.getKey().equalsIgnoreCase("Cookie")) {
+					continue;
+				}
+				r.header(header.getKey(), header.getValue().iterator().next());
+			}
+			if (sessionId != null) {
+				r.cookie(SESSION_COOKIE_NAME, sessionId);
+			}
+
+			try {
+				FluxExchangeResult<String> result = r.exchange().returnResult(String.class);
+				return toMockResponse(result);
+			}
+			catch (Exception ex) {
+				MockResponse response = new MockResponse();
+				response.setResponseCode(500);
+				response.setBody(ex.getMessage());
+				return response;
+			}
+		}
+
+		private String session(RecordedRequest request) {
+			String cookieHeaderValue = request.getHeader("Cookie");
+			if (cookieHeaderValue == null) {
+				return null;
+			}
+			String[] cookies = cookieHeaderValue.split(";");
+			for (String cookie : cookies) {
+				String[] parts = cookie.split("=");
+				if (SESSION_COOKIE_NAME.equals(parts[0])) {
+					return parts[1];
+				}
+			}
+			return null;
+		}
+
+		private MockResponse toMockResponse(FluxExchangeResult<String> result) {
+			MockResponse response = new MockResponse();
+			response.setResponseCode(result.getStatus().value());
+			for (String name : result.getResponseHeaders().keySet()) {
+				response.addHeader(name, result.getResponseHeaders().getFirst(name));
+			}
+			String body = result.getResponseBody().blockFirst();
+			if (body != null) {
+				response.setBody(body);
+			}
+			return response;
+		}
+
+	}
+
+}

+ 1 - 1
config/src/test/java/org/springframework/security/config/web/server/ServerHttpSecurityTests.java

@@ -717,7 +717,7 @@ public class ServerHttpSecurityTests {
 
 	private <T extends WebFilter> Optional<T> getWebFilter(SecurityWebFilterChain filterChain, Class<T> filterClass) {
 		return (Optional<T>) filterChain.getWebFilters().filter(Objects::nonNull)
-				.filter((filter) -> filter.getClass().isAssignableFrom(filterClass)).singleOrEmpty().blockOptional();
+				.filter((filter) -> filterClass.isAssignableFrom(filter.getClass())).singleOrEmpty().blockOptional();
 	}
 
 	private WebTestClient buildClient() {

+ 87 - 0
config/src/test/kotlin/org/springframework/security/config/annotation/web/OidcLogoutDslTests.kt

@@ -0,0 +1,87 @@
+/*
+ * Copyright 2002-2022 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.config.annotation.web
+
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.extension.ExtendWith
+import org.springframework.beans.factory.annotation.Autowired
+import org.springframework.context.annotation.Bean
+import org.springframework.context.annotation.Configuration
+import org.springframework.security.config.annotation.web.builders.HttpSecurity
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
+import org.springframework.security.config.test.SpringTestContext
+import org.springframework.security.config.test.SpringTestContextExtension
+import org.springframework.security.oauth2.client.registration.ClientRegistration
+import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository
+import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository
+import org.springframework.security.oauth2.client.registration.TestClientRegistrations
+import org.springframework.security.web.SecurityFilterChain
+import org.springframework.test.web.servlet.MockMvc
+import org.springframework.test.web.servlet.post
+
+/**
+ * Tests for [OAuth2ClientDsl]
+ *
+ * @author Eleftheria Stein
+ */
+@ExtendWith(SpringTestContextExtension::class)
+class OidcLogoutDslTests {
+    @JvmField
+    val spring = SpringTestContext(this)
+
+    @Autowired
+    lateinit var mockMvc: MockMvc
+
+    @Test
+    fun `oidcLogout when invalid token then errors`() {
+        this.spring.register(ClientRepositoryConfig::class.java).autowire()
+        val clientRegistration = this.spring.context.getBean(ClientRegistration::class.java)
+        this.mockMvc.post("/logout/connect/back-channel/" + clientRegistration.registrationId) {
+            param("logout_token", "token")
+        }.andExpect { status { isBadRequest() } }
+    }
+
+    @Configuration
+    @EnableWebSecurity
+    open class ClientRepositoryConfig {
+
+        @Bean
+        open fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
+            http {
+                oauth2Login { }
+                oidcLogout {
+                    backChannel { }
+                }
+                authorizeHttpRequests {
+                    authorize(anyRequest, authenticated)
+                }
+            }
+            return http.build()
+        }
+
+        @Bean
+        open fun clientRegistration(): ClientRegistration {
+            return TestClientRegistrations.clientRegistration().build()
+        }
+
+        @Bean
+        open fun clientRegistrationRepository(clientRegistration: ClientRegistration): ClientRegistrationRepository {
+            return InMemoryClientRegistrationRepository(clientRegistration)
+        }
+    }
+
+}

+ 97 - 0
config/src/test/kotlin/org/springframework/security/config/web/server/ServerOidcLogoutDslTests.kt

@@ -0,0 +1,97 @@
+/*
+ * Copyright 2002-2023 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.config.web.server
+
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.extension.ExtendWith
+import org.springframework.beans.factory.annotation.Autowired
+import org.springframework.context.ApplicationContext
+import org.springframework.context.annotation.Bean
+import org.springframework.context.annotation.Configuration
+import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity
+import org.springframework.security.config.test.SpringTestContext
+import org.springframework.security.config.test.SpringTestContextExtension
+import org.springframework.security.oauth2.client.registration.ClientRegistration
+import org.springframework.security.oauth2.client.registration.InMemoryReactiveClientRegistrationRepository
+import org.springframework.security.oauth2.client.registration.ReactiveClientRegistrationRepository
+import org.springframework.security.oauth2.client.registration.TestClientRegistrations
+import org.springframework.security.web.server.SecurityWebFilterChain
+import org.springframework.test.web.reactive.server.WebTestClient
+import org.springframework.web.reactive.config.EnableWebFlux
+import org.springframework.web.reactive.function.BodyInserters
+
+/**
+ * Tests for [ServerOidcLogoutDsl]
+ *
+ * @author Josh Cummings
+ */
+@ExtendWith(SpringTestContextExtension::class)
+class ServerOidcLogoutDslTests {
+    @JvmField
+    val spring = SpringTestContext(this)
+
+    private lateinit var client: WebTestClient
+
+    @Autowired
+    fun setup(context: ApplicationContext) {
+        this.client = WebTestClient
+                .bindToApplicationContext(context)
+                .configureClient()
+                .build()
+    }
+
+    @Test
+    fun `oidcLogout when invalid token then errors`() {
+        this.spring.register(ClientRepositoryConfig::class.java).autowire()
+        val clientRegistration = this.spring.context.getBean(ClientRegistration::class.java)
+        this.client.post()
+                .uri("/logout/connect/back-channel/" + clientRegistration.registrationId)
+                .body(BodyInserters.fromFormData("logout_token", "token"))
+                .exchange()
+                .expectStatus().isBadRequest
+    }
+
+    @Configuration
+    @EnableWebFlux
+    @EnableWebFluxSecurity
+    open class ClientRepositoryConfig {
+
+        @Bean
+        open fun securityFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain {
+            return http {
+                oauth2Login { }
+                oidcLogout {
+                    backChannel { }
+                }
+                authorizeExchange {
+                    authorize(anyExchange, authenticated)
+                }
+            }
+        }
+
+        @Bean
+        open fun clientRegistration(): ClientRegistration {
+            return TestClientRegistrations.clientRegistration().build()
+        }
+
+        @Bean
+        open fun clientRegistrationRepository(clientRegistration: ClientRegistration): ReactiveClientRegistrationRepository {
+            return InMemoryReactiveClientRegistrationRepository(clientRegistration)
+        }
+    }
+
+}

+ 1 - 107
docs/modules/ROOT/pages/reactive/oauth2/login/advanced.adoc

@@ -700,111 +700,5 @@ For MAC based algorithms such as `HS256`, `HS384` or `HS512`, the `client-secret
 [TIP]
 If more than one `ClientRegistration` is configured for OpenID Connect 1.0 Authentication, the JWS algorithm resolver may evaluate the provided `ClientRegistration` to determine which algorithm to return.
 
-
 [[webflux-oauth2-login-advanced-oidc-logout]]
-== OpenID Connect 1.0 Logout
-
-OpenID Connect Session Management 1.0 allows the ability to log out the End-User at the Provider using the Client.
-One of the strategies available is https://openid.net/specs/openid-connect-rpinitiated-1_0.html[RP-Initiated Logout].
-
-If the OpenID Provider supports both Session Management and https://openid.net/specs/openid-connect-discovery-1_0.html[Discovery], the client may obtain the `end_session_endpoint` `URL` from the OpenID Provider's https://openid.net/specs/openid-connect-session-1_0.html#OPMetadata[Discovery Metadata].
-This can be achieved by configuring the `ClientRegistration` with the `issuer-uri`, as in the following example:
-
-[source,yaml]
-----
-spring:
-  security:
-    oauth2:
-      client:
-        registration:
-          okta:
-            client-id: okta-client-id
-            client-secret: okta-client-secret
-            ...
-        provider:
-          okta:
-            issuer-uri: https://dev-1234.oktapreview.com
-----
-
-...and the `OidcClientInitiatedServerLogoutSuccessHandler`, which implements RP-Initiated Logout, may be configured as follows:
-
-[tabs]
-======
-Java::
-+
-[source,java,role="primary",subs="-attributes"]
-----
-@Configuration
-@EnableWebFluxSecurity
-public class OAuth2LoginSecurityConfig {
-
-	@Autowired
-	private ReactiveClientRegistrationRepository clientRegistrationRepository;
-
-	@Bean
-	public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) {
-		http
-			.authorizeExchange(authorize -> authorize
-				.anyExchange().authenticated()
-			)
-			.oauth2Login(withDefaults())
-			.logout(logout -> logout
-				.logoutSuccessHandler(oidcLogoutSuccessHandler())
-			);
-
-		return http.build();
-	}
-
-	private ServerLogoutSuccessHandler oidcLogoutSuccessHandler() {
-		OidcClientInitiatedServerLogoutSuccessHandler oidcLogoutSuccessHandler =
-				new OidcClientInitiatedServerLogoutSuccessHandler(this.clientRegistrationRepository);
-
-		// Sets the location that the End-User's User Agent will be redirected to
-		// after the logout has been performed at the Provider
-		oidcLogoutSuccessHandler.setPostLogoutRedirectUri("{baseUrl}");
-
-		return oidcLogoutSuccessHandler;
-	}
-}
-----
-
-Kotlin::
-+
-[source,kotlin,role="secondary",subs="-attributes"]
-----
-@Configuration
-@EnableWebFluxSecurity
-class OAuth2LoginSecurityConfig {
-
-    @Autowired
-    private lateinit var clientRegistrationRepository: ReactiveClientRegistrationRepository
-
-    @Bean
-    fun securityWebFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain {
-        http {
-            authorizeExchange {
-                authorize(anyExchange, authenticated)
-            }
-            oauth2Login { }
-            logout {
-                logoutSuccessHandler = oidcLogoutSuccessHandler()
-            }
-        }
-
-        return http.build()
-    }
-
-    private fun oidcLogoutSuccessHandler(): ServerLogoutSuccessHandler {
-        val oidcLogoutSuccessHandler = OidcClientInitiatedServerLogoutSuccessHandler(clientRegistrationRepository)
-
-        // Sets the location that the End-User's User Agent will be redirected to
-        // after the logout has been performed at the Provider
-        oidcLogoutSuccessHandler.setPostLogoutRedirectUri("{baseUrl}")
-        return oidcLogoutSuccessHandler
-    }
-}
-----
-======
-
-NOTE: `OidcClientInitiatedServerLogoutSuccessHandler` supports the `+{baseUrl}+` placeholder.
-If used, the application's base URL, like `https://app.example.org`, will replace it at request time.
+Then, you can proceed to configure xref:reactive/oauth2/login/logout.adoc[logout].

+ 267 - 0
docs/modules/ROOT/pages/reactive/oauth2/login/logout.adoc

@@ -0,0 +1,267 @@
+= OIDC Logout
+
+Once an end user is able to login to your application, it's important to consider how they will log out.
+
+Generally speaking, there are three use cases for you to consider:
+
+1. I want to perform only a local logout
+2. I want to log out both my application and the OIDC Provider, initiated by my application
+3. I want to log out both my application and the OIDC Provider, initiated by the OIDC Provider
+
+[[configure-local-logout]]
+== Local Logout
+
+To perform a local logout, no special OIDC configuration is needed.
+Spring Security automatically stands up a local logout endpoint, which you can xref:reactive/authentication/logout.adoc[configure through the `logout()` DSL].
+
+[[configure-client-initiated-oidc-logout]]
+[[oauth2login-advanced-oidc-logout]]
+== OpenID Connect 1.0 Client-Initiated Logout
+
+OpenID Connect Session Management 1.0 allows the ability to log out the end user at the Provider by using the Client.
+One of the strategies available is https://openid.net/specs/openid-connect-rpinitiated-1_0.html[RP-Initiated Logout].
+
+If the OpenID Provider supports both Session Management and https://openid.net/specs/openid-connect-discovery-1_0.html[Discovery], the client can obtain the `end_session_endpoint` `URL` from the OpenID Provider's https://openid.net/specs/openid-connect-session-1_0.html#OPMetadata[Discovery Metadata].
+You can do so by configuring the `ClientRegistration` with the `issuer-uri`, as follows:
+
+[source,yaml]
+----
+spring:
+  security:
+    oauth2:
+      client:
+        registration:
+          okta:
+            client-id: okta-client-id
+            client-secret: okta-client-secret
+            ...
+        provider:
+          okta:
+            issuer-uri: https://dev-1234.oktapreview.com
+----
+
+Also, you should configure `OidcClientInitiatedServerLogoutSuccessHandler`, which implements RP-Initiated Logout, as follows:
+
+[tabs]
+======
+Java::
++
+[source,java,role="primary"]
+----
+@Configuration
+@EnableWebFluxSecurity
+public class OAuth2LoginSecurityConfig {
+
+	@Autowired
+	private ReactiveClientRegistrationRepository clientRegistrationRepository;
+
+	@Bean
+	public SecurityWebFilterChain filterChain(ServerHttpSecurity http) throws Exception {
+		http
+			.authorizeExchange((authorize) -> authorize
+				.anyExchange().authenticated()
+			)
+			.oauth2Login(withDefaults())
+			.logout((logout) -> logout
+				.logoutSuccessHandler(oidcLogoutSuccessHandler())
+			);
+		return http.build();
+	}
+
+	private ServerLogoutSuccessHandler oidcLogoutSuccessHandler() {
+		OidcClientInitiatedServerLogoutSuccessHandler oidcLogoutSuccessHandler =
+				new OidcClientInitiatedServerLogoutSuccessHandler(this.clientRegistrationRepository);
+
+		// Sets the location that the End-User's User Agent will be redirected to
+		// after the logout has been performed at the Provider
+		oidcLogoutSuccessHandler.setPostLogoutRedirectUri("{baseUrl}");
+
+		return oidcLogoutSuccessHandler;
+	}
+}
+----
+
+Kotlin::
++
+[source,kotlin,role="secondary"]
+----
+@Configuration
+@EnableWebFluxSecurity
+class OAuth2LoginSecurityConfig {
+    @Autowired
+    private lateinit var clientRegistrationRepository: ReactiveClientRegistrationRepository
+
+    @Bean
+    open fun filterChain(http: ServerHttpSecurity): SecurityWebFilterChain {
+        http {
+            authorizeExchange {
+                authorize(anyExchange, authenticated)
+            }
+            oauth2Login { }
+            logout {
+                logoutSuccessHandler = oidcLogoutSuccessHandler()
+            }
+        }
+        return http.build()
+    }
+
+    private fun oidcLogoutSuccessHandler(): ServerLogoutSuccessHandler {
+        val oidcLogoutSuccessHandler = OidcClientInitiatedServerLogoutSuccessHandler(clientRegistrationRepository)
+
+        // Sets the location that the End-User's User Agent will be redirected to
+        // after the logout has been performed at the Provider
+        oidcLogoutSuccessHandler.setPostLogoutRedirectUri("{baseUrl}")
+        return oidcLogoutSuccessHandler
+    }
+}
+----
+======
+
+[NOTE]
+====
+`OidcClientInitiatedServerLogoutSuccessHandler` supports the `+{baseUrl}+` placeholder.
+If used, the application's base URL, such as `https://app.example.org`, replaces it at request time.
+====
+
+[[configure-provider-initiated-oidc-logout]]
+== OpenID Connect 1.0 Back-Channel Logout
+
+OpenID Connect Session Management 1.0 allows the ability to log out the end user at the Client by having the Provider make an API call to the Client.
+This is referred to as https://openid.net/specs/openid-connect-backchannel-1_0.html[OIDC Back-Channel Logout].
+
+To enable this, you can stand up the Back-Channel Logout endpoint in the DSL like so:
+
+[tabs]
+======
+Java::
++
+[source=java,role="primary"]
+----
+@Bean
+public SecurityWebFilterChain filterChain(ServerHttpSecurity http) throws Exception {
+    http
+        .authorizeExchange((authorize) -> authorize
+            .anyExchange().authenticated()
+        )
+        .oauth2Login(withDefaults())
+        .oidcLogout((logout) -> logout
+            .backChannel(Customizer.withDefaults())
+        );
+    return http.build();
+}
+----
+
+Kotlin::
++
+[source=kotlin,role="secondary"]
+----
+@Bean
+open fun filterChain(http: ServerHttpSecurity): SecurityWebFilterChain {
+    http {
+        authorizeExchange {
+            authorize(anyExchange, authenticated)
+        }
+        oauth2Login { }
+        oidcLogout {
+            backChannel { }
+        }
+    }
+    return http.build()
+}
+----
+======
+
+And that's it!
+
+This will stand up the endpoint `/logout/connect/back-channel/+{registrationId}` which the OIDC Provider can request to invalidate a given session of an end user in your application.
+
+[NOTE]
+`oidcLogout` requires that `oauth2Login` also be configured.
+
+[NOTE]
+`oidcLogout` requires that the session cookie be called `JSESSIONID` in order to correctly log out each session through a backchannel.
+
+=== Back-Channel Logout Architecture
+
+Consider a `ClientRegistration` whose identifier is `registrationId`.
+
+The overall flow for a Back-Channel logout is like this:
+
+1. At login time, Spring Security correlates the ID Token, CSRF Token, and Provider Session ID (if any) to your application's session id in its `ReactiveOidcSessionStrategy` implementation.
+2. Then at logout time, your OIDC Provider makes an API call to `/logout/connect/back-channel/registrationId` including a Logout Token that indicates either the `sub` (the End User) or the `sid` (the Provider Session ID) to logout.
+3. Spring Security validates the token's signature and claims.
+4. If the token contains a `sid` claim, then only the Client's session that correlates to that provider session is terminated.
+5. Otherwise, if the token contains a `sub` claim, then all that Client's sessions for that End User are terminated.
+
+[NOTE]
+Remember that Spring Security's OIDC support is multi-tenant.
+This means that it will only terminate sessions whose Client matches the `aud` claim in the Logout Token.
+
+=== Customizing the OIDC Provider Session Strategy
+
+By default, Spring Security stores in-memory all links between the OIDC Provider session and the Client session.
+
+There are a number of circumstances, like a clustered application, where it would be nice to store this instead in a separate location, like a database.
+
+You can achieve this by configuring a custom `ReactiveOidcSessionStrategy`, like so:
+
+[tabs]
+======
+Java::
++
+[source=java,role="primary"]
+----
+@Component
+public final class MySpringDataOidcSessionStrategy implements OidcSessionStrategy {
+    private final OidcProviderSessionRepository sessions;
+
+    // ...
+
+    @Override
+    public void saveSessionInformation(OidcSessionInformation info) {
+        this.sessions.save(info);
+    }
+
+    @Override
+    public OidcSessionInformation(String clientSessionId) {
+       return this.sessions.removeByClientSessionId(clientSessionId);
+    }
+
+    @Override
+    public Iterable<OidcSessionInformation> removeSessionInformation(OidcLogoutToken token) {
+        return token.getSessionId() != null ?
+            this.sessions.removeBySessionIdAndIssuerAndAudience(...) :
+            this.sessions.removeBySubjectAndIssuerAndAudience(...);
+    }
+}
+----
+
+Kotlin::
++
+[source=kotlin,role="secondary"]
+----
+@Component
+class MySpringDataOidcSessionStrategy: ReactiveOidcSessionStrategy {
+    val sessions: OidcProviderSessionRepository
+
+    // ...
+
+    @Override
+    fun saveSessionInformation(info: OidcSessionInformation): Mono<Void> {
+        return this.sessions.save(info)
+    }
+
+    @Override
+    fun removeSessionInformation(clientSessionId: String): Mono<OidcSessionInformation> {
+       return this.sessions.removeByClientSessionId(clientSessionId);
+    }
+
+    @Override
+    fun removeSessionInformation(token: OidcLogoutToken): Flux<OidcSessionInformation> {
+        return token.getSessionId() != null ?
+            this.sessions.removeBySessionIdAndIssuerAndAudience(...) :
+            this.sessions.removeBySubjectAndIssuerAndAudience(...);
+    }
+}
+----
+======

+ 3 - 0
docs/modules/ROOT/pages/servlet/oauth2/login/advanced.adoc

@@ -1037,3 +1037,6 @@ class OAuth2LoginSecurityConfig {
 `OidcClientInitiatedLogoutSuccessHandler` supports the `+{baseUrl}+` placeholder.
 If used, the application's base URL, such as `https://app.example.org`, replaces it at request time.
 ====
+
+[[oauth2login-advanced-oidc-logout]]
+Then, you can proceed to configure xref:reactive/oauth2/login/logout.adoc[logout]

+ 267 - 0
docs/modules/ROOT/pages/servlet/oauth2/login/logout.adoc

@@ -0,0 +1,267 @@
+= OIDC Logout
+
+Once an end user is able to login to your application, it's important to consider how they will log out.
+
+Generally speaking, there are three use cases for you to consider:
+
+1. I want to perform only a local logout
+2. I want to log out both my application and the OIDC Provider, initiated by my application
+3. I want to log out both my application and the OIDC Provider, initiated by the OIDC Provider
+
+[[configure-local-logout]]
+== Local Logout
+
+To perform a local logout, no special OIDC configuration is needed.
+Spring Security automatically stands up a local logout endpoint, which you can xref:servlet/authentication/logout.adoc[configure through the `logout()` DSL].
+
+[[configure-client-initiated-oidc-logout]]
+== OpenID Connect 1.0 Client-Initiated Logout
+
+OpenID Connect Session Management 1.0 allows the ability to log out the end user at the Provider by using the Client.
+One of the strategies available is https://openid.net/specs/openid-connect-rpinitiated-1_0.html[RP-Initiated Logout].
+
+If the OpenID Provider supports both Session Management and https://openid.net/specs/openid-connect-discovery-1_0.html[Discovery], the client can obtain the `end_session_endpoint` `URL` from the OpenID Provider's https://openid.net/specs/openid-connect-session-1_0.html#OPMetadata[Discovery Metadata].
+You can do so by configuring the `ClientRegistration` with the `issuer-uri`, as follows:
+
+[source,yaml]
+----
+spring:
+  security:
+    oauth2:
+      client:
+        registration:
+          okta:
+            client-id: okta-client-id
+            client-secret: okta-client-secret
+            ...
+        provider:
+          okta:
+            issuer-uri: https://dev-1234.oktapreview.com
+----
+
+Also, you should configure `OidcClientInitiatedLogoutSuccessHandler`, which implements RP-Initiated Logout, as follows:
+
+[tabs]
+======
+Java::
++
+[source,java,role="primary"]
+----
+@Configuration
+@EnableWebSecurity
+public class OAuth2LoginSecurityConfig {
+
+	@Autowired
+	private ClientRegistrationRepository clientRegistrationRepository;
+
+	@Bean
+	public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
+		http
+			.authorizeHttpRequests(authorize -> authorize
+				.anyRequest().authenticated()
+			)
+			.oauth2Login(withDefaults())
+			.logout(logout -> logout
+				.logoutSuccessHandler(oidcLogoutSuccessHandler())
+			);
+		return http.build();
+	}
+
+	private LogoutSuccessHandler oidcLogoutSuccessHandler() {
+		OidcClientInitiatedLogoutSuccessHandler oidcLogoutSuccessHandler =
+				new OidcClientInitiatedLogoutSuccessHandler(this.clientRegistrationRepository);
+
+		// Sets the location that the End-User's User Agent will be redirected to
+		// after the logout has been performed at the Provider
+		oidcLogoutSuccessHandler.setPostLogoutRedirectUri("{baseUrl}");
+
+		return oidcLogoutSuccessHandler;
+	}
+}
+----
+
+Kotlin::
++
+[source,kotlin,role="secondary"]
+----
+@Configuration
+@EnableWebSecurity
+class OAuth2LoginSecurityConfig {
+    @Autowired
+    private lateinit var clientRegistrationRepository: ClientRegistrationRepository
+
+    @Bean
+    open fun filterChain(http: HttpSecurity): SecurityFilterChain {
+        http {
+            authorizeHttpRequests {
+                authorize(anyRequest, authenticated)
+            }
+            oauth2Login { }
+            logout {
+                logoutSuccessHandler = oidcLogoutSuccessHandler()
+            }
+        }
+        return http.build()
+    }
+
+    private fun oidcLogoutSuccessHandler(): LogoutSuccessHandler {
+        val oidcLogoutSuccessHandler = OidcClientInitiatedLogoutSuccessHandler(clientRegistrationRepository)
+
+        // Sets the location that the End-User's User Agent will be redirected to
+        // after the logout has been performed at the Provider
+        oidcLogoutSuccessHandler.setPostLogoutRedirectUri("{baseUrl}")
+        return oidcLogoutSuccessHandler
+    }
+}
+----
+======
+
+[NOTE]
+====
+`OidcClientInitiatedLogoutSuccessHandler` supports the `+{baseUrl}+` placeholder.
+If used, the application's base URL, such as `https://app.example.org`, replaces it at request time.
+====
+
+[[configure-provider-initiated-oidc-logout]]
+== OpenID Connect 1.0 Back-Channel Logout
+
+OpenID Connect Session Management 1.0 allows the ability to log out the end user at the Client by having the Provider make an API call to the Client.
+This is referred to as https://openid.net/specs/openid-connect-backchannel-1_0.html[OIDC Back-Channel Logout].
+
+To enable this, you can stand up the Back-Channel Logout endpoint in the DSL like so:
+
+[tabs]
+======
+Java::
++
+[source=java,role="primary"]
+----
+@Bean
+public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
+    http
+        .authorizeHttpRequests((authorize) -> authorize
+            .anyRequest().authenticated()
+        )
+        .oauth2Login(withDefaults())
+        .oidcLogout((logout) -> logout
+            .backChannel(Customizer.withDefaults())
+        );
+    return http.build();
+}
+----
+
+Kotlin::
++
+[source=kotlin,role="secondary"]
+----
+@Bean
+open fun filterChain(http: HttpSecurity): SecurityFilterChain {
+    http {
+        authorizeRequests {
+            authorize(anyRequest, authenticated)
+        }
+        oauth2Login { }
+        oidcLogout {
+            backChannel { }
+        }
+    }
+    return http.build()
+}
+----
+======
+
+And that's it!
+
+This will stand up the endpoint `/logout/connect/back-channel/+{registrationId}` which the OIDC Provider can request to invalidate a given session of an end user in your application.
+
+[NOTE]
+`oidcLogout` requires that `oauth2Login` also be configured.
+
+[NOTE]
+`oidcLogout` requires that the session cookie be called `JSESSIONID` in order to correctly log out each session through a backchannel.
+
+=== Back-Channel Logout Architecture
+
+Consider a `ClientRegistration` whose identifier is `registrationId`.
+
+The overall flow for a Back-Channel logout is like this:
+
+1. At login time, Spring Security correlates the ID Token, CSRF Token, and Provider Session ID (if any) to your application's session id in its `OidcSessionStrategy` implementation.
+2. Then at logout time, your OIDC Provider makes an API call to `/logout/connect/back-channel/registrationId` including a Logout Token that indicates either the `sub` (the End User) or the `sid` (the Provider Session ID) to logout.
+3. Spring Security validates the token's signature and claims.
+4. If the token contains a `sid` claim, then only the Client's session that correlates to that provider session is terminated.
+5. Otherwise, if the token contains a `sub` claim, then all that Client's sessions for that End User are terminated.
+
+[NOTE]
+Remember that Spring Security's OIDC support is multi-tenant.
+This means that it will only terminate sessions whose Client matches the `aud` claim in the Logout Token.
+
+=== Customizing the OIDC Provider Session Strategy
+
+By default, Spring Security stores in-memory all links between the OIDC Provider session and the Client session.
+
+There are a number of circumstances, like a clustered application, where it would be nice to store this instead in a separate location, like a database.
+
+You can achieve this by configuring a custom `OidcSessionStrategy`, like so:
+
+[tabs]
+======
+Java::
++
+[source=java,role="primary"]
+----
+@Component
+public final class MySpringDataOidcSessionStrategy implements OidcSessionStrategy {
+    private final OidcProviderSessionRepository sessions;
+
+    // ...
+
+    @Override
+    public void saveSessionInformation(OidcSessionInformation info) {
+        this.sessions.save(info);
+    }
+
+    @Override
+    public OidcSessionInformation(String clientSessionId) {
+       return this.sessions.removeByClientSessionId(clientSessionId);
+    }
+
+    @Override
+    public Iterable<OidcSessionInformation> removeSessionInformation(OidcLogoutToken token) {
+        return token.getSessionId() != null ?
+            this.sessions.removeBySessionIdAndIssuerAndAudience(...) :
+            this.sessions.removeBySubjectAndIssuerAndAudience(...);
+    }
+}
+----
+
+Kotlin::
++
+[source=kotlin,role="secondary"]
+----
+@Component
+class MySpringDataOidcSessionStrategy: OidcSessionStrategy {
+    val sessions: OidcProviderSessionRepository
+
+    // ...
+
+    @Override
+    fun saveSessionInformation(info: OidcSessionInformation) {
+        this.sessions.save(info)
+    }
+
+    @Override
+    fun removeSessionInformation(clientSessionId: String): OidcSessionInformation {
+       return this.sessions.removeByClientSessionId(clientSessionId);
+    }
+
+    @Override
+    fun removeSessionInformation(token: OidcLogoutToken): Iterable<OidcSessionInformation> {
+        return token.getSessionId() != null ?
+            this.sessions.removeBySessionIdAndIssuerAndAudience(...) :
+            this.sessions.removeBySubjectAndIssuerAndAudience(...);
+    }
+}
+----
+======
+

+ 2 - 1
etc/nohttp/allowlist.lines

@@ -10,4 +10,5 @@
 ^http://www.w3.org/2001/04/xmlenc
 ^http://www.springframework.org/schema/security/.*
 ^http://openoffice.org/.*
-^http://www.w3.org/2003/g/data-view
+^http://www.w3.org/2003/g/data-view
+^http://schemas.openid.net/event/backchannel-logout

+ 96 - 0
oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/LogoutTokenClaimAccessor.java

@@ -0,0 +1,96 @@
+/*
+ * Copyright 2002-2023 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.oauth2.client.oidc.authentication.logout;
+
+import java.net.URL;
+import java.time.Instant;
+import java.util.List;
+import java.util.Map;
+
+import org.springframework.security.oauth2.core.ClaimAccessor;
+
+/**
+ * A {@link ClaimAccessor} for the &quot;claims&quot; that can be returned in OIDC Logout
+ * Tokens
+ *
+ * @author Josh Cummings
+ * @since 6.2
+ * @see OidcLogoutToken
+ * @see <a target="_blank" href=
+ * "https://openid.net/specs/openid-connect-backchannel-1_0.html#LogoutToken">OIDC
+ * Back-Channel Logout Token</a>
+ */
+public interface LogoutTokenClaimAccessor extends ClaimAccessor {
+
+	/**
+	 * Returns the Issuer identifier {@code (iss)}.
+	 * @return the Issuer identifier
+	 */
+	default URL getIssuer() {
+		return this.getClaimAsURL(LogoutTokenClaimNames.ISS);
+	}
+
+	/**
+	 * Returns the Subject identifier {@code (sub)}.
+	 * @return the Subject identifier
+	 */
+	default String getSubject() {
+		return this.getClaimAsString(LogoutTokenClaimNames.SUB);
+	}
+
+	/**
+	 * Returns the Audience(s) {@code (aud)} that this ID Token is intended for.
+	 * @return the Audience(s) that this ID Token is intended for
+	 */
+	default List<String> getAudience() {
+		return this.getClaimAsStringList(LogoutTokenClaimNames.AUD);
+	}
+
+	/**
+	 * Returns the time at which the ID Token was issued {@code (iat)}.
+	 * @return the time at which the ID Token was issued
+	 */
+	default Instant getIssuedAt() {
+		return this.getClaimAsInstant(LogoutTokenClaimNames.IAT);
+	}
+
+	/**
+	 * Returns a {@link Map} that identifies this token as a logout token
+	 * @return the identifying {@link Map}
+	 */
+	default Map<String, Object> getEvents() {
+		return getClaimAsMap(LogoutTokenClaimNames.EVENTS);
+	}
+
+	/**
+	 * Returns a {@code String} value {@code (sid)} representing the OIDC Provider session
+	 * @return the value representing the OIDC Provider session
+	 */
+	default String getSessionId() {
+		return getClaimAsString(LogoutTokenClaimNames.SID);
+	}
+
+	/**
+	 * Returns the JWT ID {@code (jti)} claim which provides a unique identifier for the
+	 * JWT.
+	 * @return the JWT ID claim which provides a unique identifier for the JWT
+	 */
+	default String getId() {
+		return this.getClaimAsString(LogoutTokenClaimNames.JTI);
+	}
+
+}

+ 70 - 0
oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/LogoutTokenClaimNames.java

@@ -0,0 +1,70 @@
+/*
+ * Copyright 2002-2022 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.oauth2.client.oidc.authentication.logout;
+
+/**
+ * The names of the &quot;claims&quot; defined by the OpenID Back-Channel Logout 1.0
+ * specification that can be returned in a Logout Token.
+ *
+ * @author Josh Cummings
+ * @since 6.2
+ * @see OidcLogoutToken
+ * @see <a target="_blank" href=
+ * "https://openid.net/specs/openid-connect-backchannel-1_0.html#LogoutToken">OIDC
+ * Back-Channel Logout Token</a>
+ */
+public final class LogoutTokenClaimNames {
+
+	/**
+	 * {@code jti} - the JTI identifier
+	 */
+	public static final String JTI = "jti";
+
+	/**
+	 * {@code iss} - the Issuer identifier
+	 */
+	public static final String ISS = "iss";
+
+	/**
+	 * {@code sub} - the Subject identifier
+	 */
+	public static final String SUB = "sub";
+
+	/**
+	 * {@code aud} - the Audience(s) that the ID Token is intended for
+	 */
+	public static final String AUD = "aud";
+
+	/**
+	 * {@code iat} - the time at which the ID Token was issued
+	 */
+	public static final String IAT = "iat";
+
+	/**
+	 * {@code events} - a JSON object that identifies this token as a logout token
+	 */
+	public static final String EVENTS = "events";
+
+	/**
+	 * {@code sid} - the session id for the OIDC provider
+	 */
+	public static final String SID = "sid";
+
+	private LogoutTokenClaimNames() {
+	}
+
+}

+ 223 - 0
oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/OidcLogoutToken.java

@@ -0,0 +1,223 @@
+/*
+ * Copyright 2002-2023 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.oauth2.client.oidc.authentication.logout;
+
+import java.time.Instant;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.function.Consumer;
+
+import org.springframework.security.oauth2.core.AbstractOAuth2Token;
+import org.springframework.security.oauth2.core.oidc.IdTokenClaimNames;
+import org.springframework.util.Assert;
+
+/**
+ * An implementation of an {@link AbstractOAuth2Token} representing an OpenID Backchannel
+ * Logout Token.
+ *
+ * <p>
+ * The {@code OidcLogoutToken} is a security token that contains &quot;claims&quot; about
+ * terminating sessions for a given OIDC Provider session id or End User.
+ *
+ * @author Josh Cummings
+ * @since 6.2
+ * @see AbstractOAuth2Token
+ * @see LogoutTokenClaimAccessor
+ * @see <a target="_blank" href=
+ * "https://openid.net/specs/openid-connect-backchannel-1_0.html#LogoutToken">Logout
+ * Token</a>
+ */
+public class OidcLogoutToken extends AbstractOAuth2Token implements LogoutTokenClaimAccessor {
+
+	private static final String BACKCHANNEL_LOGOUT_TOKEN_EVENT_NAME = "http://schemas.openid.net/event/backchannel-logout";
+
+	private final Map<String, Object> claims;
+
+	/**
+	 * Constructs a {@link OidcLogoutToken} using the provided parameters.
+	 * @param tokenValue the Logout Token value
+	 * @param issuedAt the time at which the Logout Token was issued {@code (iat)}
+	 * @param claims the claims about the logout statement
+	 */
+	OidcLogoutToken(String tokenValue, Instant issuedAt, Map<String, Object> claims) {
+		super(tokenValue, issuedAt, Instant.MAX);
+		this.claims = Collections.unmodifiableMap(claims);
+		Assert.notNull(claims, "claims must not be null");
+	}
+
+	@Override
+	public Map<String, Object> getClaims() {
+		return this.claims;
+	}
+
+	/**
+	 * Create a {@link OidcLogoutToken.Builder} based on the given token value
+	 * @param tokenValue the token value to use
+	 * @return the {@link OidcLogoutToken.Builder} for further configuration
+	 */
+	public static Builder withTokenValue(String tokenValue) {
+		return new Builder(tokenValue);
+	}
+
+	/**
+	 * A builder for {@link OidcLogoutToken}s
+	 *
+	 * @author Josh Cummings
+	 */
+	public static final class Builder {
+
+		private String tokenValue;
+
+		private final Map<String, Object> claims = new LinkedHashMap<>();
+
+		private Builder(String tokenValue) {
+			this.tokenValue = tokenValue;
+			this.claims.put(LogoutTokenClaimNames.EVENTS,
+					Collections.singletonMap(BACKCHANNEL_LOGOUT_TOKEN_EVENT_NAME, Collections.emptyMap()));
+		}
+
+		/**
+		 * Use this token value in the resulting {@link OidcLogoutToken}
+		 * @param tokenValue The token value to use
+		 * @return the {@link Builder} for further configurations
+		 */
+		public Builder tokenValue(String tokenValue) {
+			this.tokenValue = tokenValue;
+			return this;
+		}
+
+		/**
+		 * Use this claim in the resulting {@link OidcLogoutToken}
+		 * @param name The claim name
+		 * @param value The claim value
+		 * @return the {@link Builder} for further configurations
+		 */
+		public Builder claim(String name, Object value) {
+			this.claims.put(name, value);
+			return this;
+		}
+
+		/**
+		 * Provides access to every {@link #claim(String, Object)} declared so far with
+		 * the possibility to add, replace, or remove.
+		 * @param claimsConsumer the consumer
+		 * @return the {@link Builder} for further configurations
+		 */
+		public Builder claims(Consumer<Map<String, Object>> claimsConsumer) {
+			claimsConsumer.accept(this.claims);
+			return this;
+		}
+
+		/**
+		 * Use this audience in the resulting {@link OidcLogoutToken}
+		 * @param audience The audience(s) to use
+		 * @return the {@link Builder} for further configurations
+		 */
+		public Builder audience(Collection<String> audience) {
+			return claim(LogoutTokenClaimNames.AUD, audience);
+		}
+
+		/**
+		 * Use this issued-at timestamp in the resulting {@link OidcLogoutToken}
+		 * @param issuedAt The issued-at timestamp to use
+		 * @return the {@link Builder} for further configurations
+		 */
+		public Builder issuedAt(Instant issuedAt) {
+			return claim(LogoutTokenClaimNames.IAT, issuedAt);
+		}
+
+		/**
+		 * Use this issuer in the resulting {@link OidcLogoutToken}
+		 * @param issuer The issuer to use
+		 * @return the {@link Builder} for further configurations
+		 */
+		public Builder issuer(String issuer) {
+			return claim(LogoutTokenClaimNames.ISS, issuer);
+		}
+
+		/**
+		 * Use this id to identify the resulting {@link OidcLogoutToken}
+		 * @param jti The unique identifier to use
+		 * @return the {@link Builder} for further configurations
+		 */
+		public Builder jti(String jti) {
+			return claim(LogoutTokenClaimNames.JTI, jti);
+		}
+
+		/**
+		 * Use this subject in the resulting {@link OidcLogoutToken}
+		 * @param subject The subject to use
+		 * @return the {@link Builder} for further configurations
+		 */
+		public Builder subject(String subject) {
+			return claim(LogoutTokenClaimNames.SUB, subject);
+		}
+
+		/**
+		 * A JSON object that identifies this token as a logout token
+		 * @param events The JSON object to use
+		 * @return the {@link Builder} for further configurations
+		 */
+		public Builder events(Map<String, Object> events) {
+			return claim(LogoutTokenClaimNames.EVENTS, events);
+		}
+
+		/**
+		 * Use this session id to correlate the OIDC Provider session
+		 * @param sessionId The session id to use
+		 * @return the {@link Builder} for further configurations
+		 */
+		public Builder sessionId(String sessionId) {
+			return claim(LogoutTokenClaimNames.SID, sessionId);
+		}
+
+		public OidcLogoutToken build() {
+			Assert.notNull(this.claims.get(LogoutTokenClaimNames.ISS), "issuer must not be null");
+			Assert.isInstanceOf(Collection.class, this.claims.get(LogoutTokenClaimNames.AUD),
+					"audience must be a collection");
+			Assert.notEmpty((Collection<?>) this.claims.get(LogoutTokenClaimNames.AUD), "audience must not be empty");
+			Assert.notNull(this.claims.get(LogoutTokenClaimNames.JTI), "jti must not be null");
+			Assert.isTrue(hasLogoutTokenIdentifyingMember(),
+					"logout token must contain an events claim that contains a member called " + "'"
+							+ BACKCHANNEL_LOGOUT_TOKEN_EVENT_NAME + "' whose value is an empty Map");
+			Assert.isNull(this.claims.get("nonce"), "logout token must not contain a nonce claim");
+			Instant iat = toInstant(this.claims.get(IdTokenClaimNames.IAT));
+			return new OidcLogoutToken(this.tokenValue, iat, this.claims);
+		}
+
+		private boolean hasLogoutTokenIdentifyingMember() {
+			if (!(this.claims.get(LogoutTokenClaimNames.EVENTS) instanceof Map<?, ?> events)) {
+				return false;
+			}
+			if (!(events.get(BACKCHANNEL_LOGOUT_TOKEN_EVENT_NAME) instanceof Map<?, ?> object)) {
+				return false;
+			}
+			return object.isEmpty();
+		}
+
+		private Instant toInstant(Object timestamp) {
+			if (timestamp != null) {
+				Assert.isInstanceOf(Instant.class, timestamp, "timestamps must be of type Instant");
+			}
+			return (Instant) timestamp;
+		}
+
+	}
+
+}

+ 53 - 0
oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/server/session/InMemoryReactiveOidcSessionRegistry.java

@@ -0,0 +1,53 @@
+/*
+ * Copyright 2002-2023 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.oauth2.client.oidc.server.session;
+
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+
+import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcLogoutToken;
+import org.springframework.security.oauth2.client.oidc.session.InMemoryOidcSessionRegistry;
+import org.springframework.security.oauth2.client.oidc.session.OidcSessionInformation;
+
+/**
+ * An in-memory implementation of
+ * {@link org.springframework.security.oauth2.client.oidc.server.session.ReactiveOidcSessionRegistry}
+ *
+ * @author Josh Cummings
+ * @since 6.2
+ */
+public final class InMemoryReactiveOidcSessionRegistry implements ReactiveOidcSessionRegistry {
+
+	private final InMemoryOidcSessionRegistry delegate = new InMemoryOidcSessionRegistry();
+
+	@Override
+	public Mono<Void> saveSessionInformation(OidcSessionInformation info) {
+		this.delegate.saveSessionInformation(info);
+		return Mono.empty();
+	}
+
+	@Override
+	public Mono<OidcSessionInformation> removeSessionInformation(String clientSessionId) {
+		return Mono.justOrEmpty(this.delegate.removeSessionInformation(clientSessionId));
+	}
+
+	@Override
+	public Flux<OidcSessionInformation> removeSessionInformation(OidcLogoutToken token) {
+		return Flux.fromIterable(this.delegate.removeSessionInformation(token));
+	}
+
+}

+ 63 - 0
oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/server/session/ReactiveOidcSessionRegistry.java

@@ -0,0 +1,63 @@
+/*
+ * Copyright 2002-2023 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.oauth2.client.oidc.server.session;
+
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+
+import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcLogoutToken;
+import org.springframework.security.oauth2.client.oidc.session.OidcSessionInformation;
+
+/**
+ * A registry to record the tie between the OIDC Provider session and the Client session.
+ * This is handy when a provider makes a logout request that indicates the OIDC Provider
+ * session or the End User.
+ *
+ * @author Josh Cummings
+ * @since 6.2
+ * @see <a target="_blank" href=
+ * "https://openid.net/specs/openid-connect-backchannel-1_0.html#LogoutToken">Logout
+ * Token</a>
+ */
+public interface ReactiveOidcSessionRegistry {
+
+	/**
+	 * Register a OIDC Provider session with the provided client session. Generally
+	 * speaking, the client session should be the session tied to the current login.
+	 * @param info the {@link OidcSessionInformation} to use
+	 */
+	Mono<Void> saveSessionInformation(OidcSessionInformation info);
+
+	/**
+	 * Deregister the OIDC Provider session tied to the provided client session. Generally
+	 * speaking, the client session should be the session tied to the current logout.
+	 * @param clientSessionId the client session
+	 * @return any found {@link OidcSessionInformation}, could be {@code null}
+	 */
+	Mono<OidcSessionInformation> removeSessionInformation(String clientSessionId);
+
+	/**
+	 * Deregister the OIDC Provider sessions referenced by the provided OIDC Logout Token
+	 * by its session id or its subject. Note that the issuer and audience should also
+	 * match the corresponding values found in each {@link OidcSessionInformation}
+	 * returned.
+	 * @param logoutToken the {@link OidcLogoutToken}
+	 * @return any found {@link OidcSessionInformation}s, could be empty
+	 */
+	Flux<OidcSessionInformation> removeSessionInformation(OidcLogoutToken logoutToken);
+
+}

+ 123 - 0
oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/session/InMemoryOidcSessionRegistry.java

@@ -0,0 +1,123 @@
+/*
+ * Copyright 2002-2023 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.oauth2.client.oidc.session;
+
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.function.Predicate;
+
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+
+import org.springframework.security.oauth2.client.oidc.authentication.logout.LogoutTokenClaimNames;
+import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcLogoutToken;
+
+/**
+ * An in-memory implementation of {@link OidcSessionRegistry}
+ *
+ * @author Josh Cummings
+ * @since 6.2
+ */
+public final class InMemoryOidcSessionRegistry implements OidcSessionRegistry {
+
+	private final Log logger = LogFactory.getLog(InMemoryOidcSessionRegistry.class);
+
+	private final Map<String, OidcSessionInformation> sessions = new ConcurrentHashMap<>();
+
+	@Override
+	public void saveSessionInformation(OidcSessionInformation info) {
+		this.sessions.put(info.getSessionId(), info);
+	}
+
+	@Override
+	public OidcSessionInformation removeSessionInformation(String clientSessionId) {
+		OidcSessionInformation information = this.sessions.remove(clientSessionId);
+		if (information != null) {
+			this.logger.trace("Removed client session");
+		}
+		return information;
+	}
+
+	@Override
+	public Iterable<OidcSessionInformation> removeSessionInformation(OidcLogoutToken token) {
+		List<String> audience = token.getAudience();
+		String issuer = token.getIssuer().toString();
+		String subject = token.getSubject();
+		String providerSessionId = token.getSessionId();
+		Predicate<OidcSessionInformation> matcher = (providerSessionId != null)
+				? sessionIdMatcher(audience, issuer, providerSessionId) : subjectMatcher(audience, issuer, subject);
+		if (this.logger.isTraceEnabled()) {
+			String message = "Looking up sessions by issuer [%s] and %s [%s]";
+			if (providerSessionId != null) {
+				this.logger.trace(String.format(message, issuer, LogoutTokenClaimNames.SID, providerSessionId));
+			}
+			else {
+				this.logger.trace(String.format(message, issuer, LogoutTokenClaimNames.SUB, subject));
+			}
+		}
+		int size = this.sessions.size();
+		Set<OidcSessionInformation> infos = new HashSet<>();
+		this.sessions.values().removeIf((info) -> {
+			boolean result = matcher.test(info);
+			if (result) {
+				infos.add(info);
+			}
+			return result;
+		});
+		if (infos.isEmpty()) {
+			this.logger.debug("Failed to remove any sessions since none matched");
+		}
+		else if (this.logger.isTraceEnabled()) {
+			String message = "Found and removed %d session(s) from mapping of %d session(s)";
+			this.logger.trace(String.format(message, infos.size(), size));
+		}
+		return infos;
+	}
+
+	private static Predicate<OidcSessionInformation> sessionIdMatcher(List<String> audience, String issuer,
+			String sessionId) {
+		return (session) -> {
+			List<String> thatAudience = session.getPrincipal().getAudience();
+			String thatIssuer = session.getPrincipal().getIssuer().toString();
+			String thatSessionId = session.getPrincipal().getClaimAsString(LogoutTokenClaimNames.SID);
+			if (thatAudience == null) {
+				return false;
+			}
+			return !Collections.disjoint(audience, thatAudience) && issuer.equals(thatIssuer)
+					&& sessionId.equals(thatSessionId);
+		};
+	}
+
+	private static Predicate<OidcSessionInformation> subjectMatcher(List<String> audience, String issuer,
+			String subject) {
+		return (session) -> {
+			List<String> thatAudience = session.getPrincipal().getAudience();
+			String thatIssuer = session.getPrincipal().getIssuer().toString();
+			String thatSubject = session.getPrincipal().getSubject();
+			if (thatAudience == null) {
+				return false;
+			}
+			return !Collections.disjoint(audience, thatAudience) && issuer.equals(thatIssuer)
+					&& subject.equals(thatSubject);
+		};
+	}
+
+}

+ 74 - 0
oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/session/OidcSessionInformation.java

@@ -0,0 +1,74 @@
+/*
+ * Copyright 2002-2023 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.oauth2.client.oidc.session;
+
+import java.util.Collections;
+import java.util.Date;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import org.springframework.security.core.session.SessionInformation;
+import org.springframework.security.oauth2.core.oidc.user.OidcUser;
+
+/**
+ * A {@link SessionInformation} extension that enforces the principal be of type
+ * {@link OidcUser}.
+ *
+ * @author Josh Cummings
+ * @since 6.2
+ */
+public class OidcSessionInformation extends SessionInformation {
+
+	private final Map<String, String> authorities;
+
+	/**
+	 * Construct an {@link OidcSessionInformation}
+	 * @param sessionId the Client's session id
+	 * @param authorities any material that authorizes operating on the session
+	 * @param user the OIDC Provider's session and end user
+	 */
+	public OidcSessionInformation(String sessionId, Map<String, String> authorities, OidcUser user) {
+		super(user, sessionId, new Date());
+		this.authorities = (authorities != null) ? new LinkedHashMap<>(authorities) : Collections.emptyMap();
+	}
+
+	/**
+	 * Any material needed to authorize operations on this session
+	 * @return the {@link Map} of credentials
+	 */
+	public Map<String, String> getAuthorities() {
+		return this.authorities;
+	}
+
+	/**
+	 * {@inheritDoc}
+	 */
+	@Override
+	public OidcUser getPrincipal() {
+		return (OidcUser) super.getPrincipal();
+	}
+
+	/**
+	 * Copy this {@link OidcSessionInformation}, using a new session identifier
+	 * @param sessionId the new session identifier to use
+	 * @return a new {@link OidcSessionInformation} instance
+	 */
+	public OidcSessionInformation withSessionId(String sessionId) {
+		return new OidcSessionInformation(sessionId, getAuthorities(), getPrincipal());
+	}
+
+}

+ 59 - 0
oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/session/OidcSessionRegistry.java

@@ -0,0 +1,59 @@
+/*
+ * Copyright 2002-2023 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.oauth2.client.oidc.session;
+
+import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcLogoutToken;
+
+/**
+ * A registry to record the tie between the OIDC Provider session and the Client session.
+ * This is handy when a provider makes a logout request that indicates the OIDC Provider
+ * session or the End User.
+ *
+ * @author Josh Cummings
+ * @since 6.2
+ * @see <a target="_blank" href=
+ * "https://openid.net/specs/openid-connect-backchannel-1_0.html#LogoutToken">Logout
+ * Token</a>
+ */
+public interface OidcSessionRegistry {
+
+	/**
+	 * Register a OIDC Provider session with the provided client session. Generally
+	 * speaking, the client session should be the session tied to the current login.
+	 * @param info the {@link OidcSessionInformation} to use
+	 */
+	void saveSessionInformation(OidcSessionInformation info);
+
+	/**
+	 * Deregister the OIDC Provider session tied to the provided client session. Generally
+	 * speaking, the client session should be the session tied to the current logout.
+	 * @param clientSessionId the client session
+	 * @return any found {@link OidcSessionInformation}, could be {@code null}
+	 */
+	OidcSessionInformation removeSessionInformation(String clientSessionId);
+
+	/**
+	 * Deregister the OIDC Provider sessions referenced by the provided OIDC Logout Token
+	 * by its session id or its subject. Note that the issuer and audience should also
+	 * match the corresponding values found in each {@link OidcSessionInformation}
+	 * returned.
+	 * @param logoutToken the {@link OidcLogoutToken}
+	 * @return any found {@link OidcSessionInformation}s, could be empty
+	 */
+	Iterable<OidcSessionInformation> removeSessionInformation(OidcLogoutToken logoutToken);
+
+}

+ 50 - 0
oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/authentication/logout/TestOidcLogoutTokens.java

@@ -0,0 +1,50 @@
+/*
+ * Copyright 2002-2023 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.oauth2.client.oidc.authentication.logout;
+
+import java.time.Instant;
+import java.util.Collections;
+
+import org.springframework.security.oauth2.core.oidc.user.OidcUser;
+
+public final class TestOidcLogoutTokens {
+
+	public static OidcLogoutToken.Builder withUser(OidcUser user) {
+		OidcLogoutToken.Builder builder = OidcLogoutToken.withTokenValue("token")
+				.audience(Collections.singleton("client-id")).issuedAt(Instant.now())
+				.issuer(user.getIssuer().toString()).jti("id").subject(user.getSubject());
+		if (user.hasClaim(LogoutTokenClaimNames.SID)) {
+			builder.sessionId(user.getClaimAsString(LogoutTokenClaimNames.SID));
+		}
+		return builder;
+	}
+
+	public static OidcLogoutToken.Builder withSessionId(String issuer, String sessionId) {
+		return OidcLogoutToken.withTokenValue("token").audience(Collections.singleton("client-id"))
+				.issuedAt(Instant.now()).issuer(issuer).jti("id").sessionId(sessionId);
+	}
+
+	public static OidcLogoutToken.Builder withSubject(String issuer, String subject) {
+		return OidcLogoutToken.withTokenValue("token").audience(Collections.singleton("client-id"))
+				.issuedAt(Instant.now()).issuer(issuer).jti("id").subject(subject);
+	}
+
+	private TestOidcLogoutTokens() {
+
+	}
+
+}

+ 102 - 0
oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/session/InMemoryOidcSessionRegistryTests.java

@@ -0,0 +1,102 @@
+/*
+ * Copyright 2002-2023 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.oauth2.client.oidc.session;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.security.core.authority.AuthorityUtils;
+import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcLogoutToken;
+import org.springframework.security.oauth2.client.oidc.authentication.logout.TestOidcLogoutTokens;
+import org.springframework.security.oauth2.core.oidc.OidcIdToken;
+import org.springframework.security.oauth2.core.oidc.TestOidcIdTokens;
+import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser;
+import org.springframework.security.oauth2.core.oidc.user.OidcUser;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link InMemoryOidcSessionRegistry}
+ */
+public class InMemoryOidcSessionRegistryTests {
+
+	@Test
+	public void registerWhenDefaultsThenStoresSessionInformation() {
+		InMemoryOidcSessionRegistry sessionRegistry = new InMemoryOidcSessionRegistry();
+		String sessionId = "client";
+		OidcSessionInformation info = TestOidcSessionInformations.create(sessionId);
+		sessionRegistry.saveSessionInformation(info);
+		OidcLogoutToken logoutToken = TestOidcLogoutTokens.withUser(info.getPrincipal()).build();
+		Iterable<OidcSessionInformation> infos = sessionRegistry.removeSessionInformation(logoutToken);
+		assertThat(infos).containsExactly(info);
+	}
+
+	@Test
+	public void registerWhenIdTokenHasSessionIdThenStoresSessionInformation() {
+		InMemoryOidcSessionRegistry sessionRegistry = new InMemoryOidcSessionRegistry();
+		OidcIdToken idToken = TestOidcIdTokens.idToken().claim("sid", "provider").build();
+		OidcUser user = new DefaultOidcUser(AuthorityUtils.NO_AUTHORITIES, idToken);
+		OidcSessionInformation info = TestOidcSessionInformations.create("client", user);
+		sessionRegistry.saveSessionInformation(info);
+		OidcLogoutToken logoutToken = TestOidcLogoutTokens.withSessionId(idToken.getIssuer().toString(), "provider")
+				.build();
+		Iterable<OidcSessionInformation> infos = sessionRegistry.removeSessionInformation(logoutToken);
+		assertThat(infos).containsExactly(info);
+	}
+
+	@Test
+	public void unregisterWhenMultipleSessionsThenRemovesAllMatching() {
+		InMemoryOidcSessionRegistry sessionRegistry = new InMemoryOidcSessionRegistry();
+		OidcIdToken idToken = TestOidcIdTokens.idToken().claim("sid", "providerOne").subject("otheruser").build();
+		OidcUser user = new DefaultOidcUser(AuthorityUtils.NO_AUTHORITIES, idToken);
+		OidcSessionInformation oneSession = TestOidcSessionInformations.create("clientOne", user);
+		sessionRegistry.saveSessionInformation(oneSession);
+		idToken = TestOidcIdTokens.idToken().claim("sid", "providerTwo").build();
+		user = new DefaultOidcUser(AuthorityUtils.NO_AUTHORITIES, idToken);
+		OidcSessionInformation twoSession = TestOidcSessionInformations.create("clientTwo", user);
+		sessionRegistry.saveSessionInformation(twoSession);
+		idToken = TestOidcIdTokens.idToken().claim("sid", "providerThree").build();
+		user = new DefaultOidcUser(AuthorityUtils.NO_AUTHORITIES, idToken);
+		OidcSessionInformation threeSession = TestOidcSessionInformations.create("clientThree", user);
+		sessionRegistry.saveSessionInformation(threeSession);
+		OidcLogoutToken logoutToken = TestOidcLogoutTokens
+				.withSubject(idToken.getIssuer().toString(), idToken.getSubject()).build();
+		Iterable<OidcSessionInformation> infos = sessionRegistry.removeSessionInformation(logoutToken);
+		assertThat(infos).containsExactlyInAnyOrder(twoSession, threeSession);
+		logoutToken = TestOidcLogoutTokens.withSubject(idToken.getIssuer().toString(), "otheruser").build();
+		infos = sessionRegistry.removeSessionInformation(logoutToken);
+		assertThat(infos).containsExactly(oneSession);
+	}
+
+	@Test
+	public void unregisterWhenNoSessionsThenEmptyList() {
+		InMemoryOidcSessionRegistry sessionRegistry = new InMemoryOidcSessionRegistry();
+		OidcIdToken idToken = TestOidcIdTokens.idToken().claim("sid", "provider").build();
+		OidcUser user = new DefaultOidcUser(AuthorityUtils.NO_AUTHORITIES, idToken);
+		OidcSessionInformation info = TestOidcSessionInformations.create("client", user);
+		sessionRegistry.saveSessionInformation(info);
+		OidcLogoutToken logoutToken = TestOidcLogoutTokens.withSessionId(idToken.getIssuer().toString(), "wrong")
+				.build();
+		Iterable<?> infos = sessionRegistry.removeSessionInformation(logoutToken);
+		assertThat(infos).isNotNull();
+		assertThat(infos).isEmpty();
+		logoutToken = TestOidcLogoutTokens.withSessionId("https://wrong", "provider").build();
+		infos = sessionRegistry.removeSessionInformation(logoutToken);
+		assertThat(infos).isNotNull();
+		assertThat(infos).isEmpty();
+	}
+
+}

+ 45 - 0
oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/session/TestOidcSessionInformations.java

@@ -0,0 +1,45 @@
+/*
+ * Copyright 2002-2023 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.oauth2.client.oidc.session;
+
+import java.util.Map;
+
+import org.springframework.security.oauth2.core.oidc.user.OidcUser;
+import org.springframework.security.oauth2.core.oidc.user.TestOidcUsers;
+
+/**
+ * Sample {@link OidcSessionInformation} instances
+ */
+public final class TestOidcSessionInformations {
+
+	public static OidcSessionInformation create() {
+		return create("sessionId");
+	}
+
+	public static OidcSessionInformation create(String sessionId) {
+		return create(sessionId, TestOidcUsers.create());
+	}
+
+	public static OidcSessionInformation create(String sessionId, OidcUser user) {
+		return new OidcSessionInformation(sessionId, Map.of("_csrf", "token"), user);
+	}
+
+	private TestOidcSessionInformations() {
+
+	}
+
+}

+ 2 - 0
oauth2/oauth2-core/src/test/java/org/springframework/security/oauth2/core/oidc/TestOidcIdTokens.java

@@ -17,6 +17,7 @@
 package org.springframework.security.oauth2.core.oidc;
 
 import java.time.Instant;
+import java.util.List;
 
 /**
  * Test {@link OidcIdToken}s
@@ -32,6 +33,7 @@ public final class TestOidcIdTokens {
 		// @formatter:off
 		return OidcIdToken.withTokenValue("id-token")
 				.issuer("https://example.com")
+				.audience(List.of("client-id"))
 				.subject("subject")
 				.issuedAt(Instant.now())
 				.expiresAt(Instant.now()

+ 1 - 1
oauth2/oauth2-core/src/test/java/org/springframework/security/oauth2/core/oidc/user/TestOidcUsers.java

@@ -50,7 +50,7 @@ public final class TestOidcUsers {
 				.expiresAt(expiresAt)
 				.subject("subject")
 				.issuer("http://localhost/issuer")
-				.audience(Collections.unmodifiableSet(new LinkedHashSet<>(Collections.singletonList("client"))))
+				.audience(Collections.unmodifiableSet(new LinkedHashSet<>(Collections.singletonList("client-id"))))
 				.authorizedParty("client")
 				.build();
 		// @formatter:on