diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcBackChannelLogoutAuthentication.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcBackChannelLogoutAuthentication.java index f65b1c11c0..73f76bffd7 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcBackChannelLogoutAuthentication.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcBackChannelLogoutAuthentication.java @@ -20,6 +20,7 @@ import java.util.Collections; import org.springframework.security.authentication.AbstractAuthenticationToken; import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcLogoutToken; +import org.springframework.security.oauth2.client.registration.ClientRegistration; /** * An {@link org.springframework.security.core.Authentication} implementation that @@ -37,13 +38,16 @@ class OidcBackChannelLogoutAuthentication extends AbstractAuthenticationToken { private final OidcLogoutToken logoutToken; + private final ClientRegistration clientRegistration; + /** * Construct an {@link OidcBackChannelLogoutAuthentication} * @param logoutToken a deserialized, verified OIDC Logout Token */ - OidcBackChannelLogoutAuthentication(OidcLogoutToken logoutToken) { + OidcBackChannelLogoutAuthentication(OidcLogoutToken logoutToken, ClientRegistration clientRegistration) { super(Collections.emptyList()); this.logoutToken = logoutToken; + this.clientRegistration = clientRegistration; setAuthenticated(true); } @@ -63,4 +67,8 @@ class OidcBackChannelLogoutAuthentication extends AbstractAuthenticationToken { return this.logoutToken; } + ClientRegistration getClientRegistration() { + return this.clientRegistration; + } + } diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcBackChannelLogoutAuthenticationProvider.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcBackChannelLogoutAuthenticationProvider.java index d25153ce7b..f1412fd660 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcBackChannelLogoutAuthenticationProvider.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcBackChannelLogoutAuthenticationProvider.java @@ -99,7 +99,7 @@ final class OidcBackChannelLogoutAuthenticationProvider implements Authenticatio OidcLogoutToken oidcLogoutToken = OidcLogoutToken.withTokenValue(logoutToken) .claims((claims) -> claims.putAll(jwt.getClaims())) .build(); - return new OidcBackChannelLogoutAuthentication(oidcLogoutToken); + return new OidcBackChannelLogoutAuthentication(oidcLogoutToken, registration); } /** diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcBackChannelLogoutFilter.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcBackChannelLogoutFilter.java index 0a03ec8383..f76aeb8c30 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcBackChannelLogoutFilter.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcBackChannelLogoutFilter.java @@ -58,7 +58,7 @@ class OidcBackChannelLogoutFilter extends OncePerRequestFilter { private final OAuth2ErrorHttpMessageConverter errorHttpMessageConverter = new OAuth2ErrorHttpMessageConverter(); - private LogoutHandler logoutHandler = new OidcBackChannelLogoutHandler(); + private final LogoutHandler logoutHandler; /** * Construct an {@link OidcBackChannelLogoutFilter} @@ -68,11 +68,13 @@ class OidcBackChannelLogoutFilter extends OncePerRequestFilter { * Logout Tokens */ OidcBackChannelLogoutFilter(AuthenticationConverter authenticationConverter, - AuthenticationManager authenticationManager) { + AuthenticationManager authenticationManager, LogoutHandler logoutHandler) { Assert.notNull(authenticationConverter, "authenticationConverter cannot be null"); Assert.notNull(authenticationManager, "authenticationManager cannot be null"); + Assert.notNull(logoutHandler, "logoutHandler cannot be null"); this.authenticationConverter = authenticationConverter; this.authenticationManager = authenticationManager; + this.logoutHandler = logoutHandler; } /** @@ -126,14 +128,4 @@ class OidcBackChannelLogoutFilter extends OncePerRequestFilter { "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; - } - } diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcBackChannelLogoutHandler.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcBackChannelLogoutHandler.java index 26ded20003..d7348f5c09 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcBackChannelLogoutHandler.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcBackChannelLogoutHandler.java @@ -29,10 +29,9 @@ import org.apache.commons.logging.LogFactory; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; 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; @@ -40,6 +39,8 @@ import org.springframework.security.oauth2.core.http.converter.OAuth2ErrorHttpMe import org.springframework.security.web.authentication.logout.LogoutHandler; import org.springframework.security.web.util.UrlUtils; import org.springframework.util.Assert; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; import org.springframework.web.client.RestClientException; import org.springframework.web.client.RestOperations; import org.springframework.web.client.RestTemplate; @@ -51,25 +52,29 @@ import org.springframework.web.util.UriComponentsBuilder; * Back-Channel Logout Token and invalidates each one. * * @author Josh Cummings - * @since 6.2 + * @since 6.4 * @see OIDC Back-Channel Logout * Spec */ -final class OidcBackChannelLogoutHandler implements LogoutHandler { +public final class OidcBackChannelLogoutHandler implements LogoutHandler { private final Log logger = LogFactory.getLog(getClass()); - private OidcSessionRegistry sessionRegistry = new InMemoryOidcSessionRegistry(); + private final OidcSessionRegistry sessionRegistry; private RestOperations restOperations = new RestTemplate(); - private String logoutUri = "{baseScheme}://localhost{basePort}/logout"; + private String logoutUri = "{baseUrl}/logout/connect/back-channel/{registrationId}"; private String sessionCookieName = "JSESSIONID"; private final OAuth2ErrorHttpMessageConverter errorHttpMessageConverter = new OAuth2ErrorHttpMessageConverter(); + public OidcBackChannelLogoutHandler(OidcSessionRegistry sessionRegistry) { + this.sessionRegistry = sessionRegistry; + } + @Override public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) { if (!(authentication instanceof OidcBackChannelLogoutAuthentication token)) { @@ -86,7 +91,7 @@ final class OidcBackChannelLogoutHandler implements LogoutHandler { for (OidcSessionInformation session : sessions) { totalCount++; try { - eachLogout(request, session); + eachLogout(request, token, session); invalidatedCount++; } catch (RestClientException ex) { @@ -103,18 +108,23 @@ final class OidcBackChannelLogoutHandler implements LogoutHandler { } } - private void eachLogout(HttpServletRequest request, OidcSessionInformation session) { + private void eachLogout(HttpServletRequest request, OidcBackChannelLogoutAuthentication token, + OidcSessionInformation session) { HttpHeaders headers = new HttpHeaders(); headers.add(HttpHeaders.COOKIE, this.sessionCookieName + "=" + session.getSessionId()); for (Map.Entry credential : session.getAuthorities().entrySet()) { headers.add(credential.getKey(), credential.getValue()); } - String logout = computeLogoutEndpoint(request); - HttpEntity entity = new HttpEntity<>(null, headers); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + String logout = computeLogoutEndpoint(request, token); + MultiValueMap body = new LinkedMultiValueMap(); + body.add("logout_token", token.getPrincipal().getTokenValue()); + body.add("_spring_security_internal_logout", "true"); + HttpEntity entity = new HttpEntity<>(body, headers); this.restOperations.postForEntity(logout, entity, Object.class); } - String computeLogoutEndpoint(HttpServletRequest request) { + String computeLogoutEndpoint(HttpServletRequest request, OidcBackChannelLogoutAuthentication token) { // @formatter:off UriComponents uriComponents = UriComponentsBuilder .fromHttpUrl(UrlUtils.buildFullRequestUrl(request)) @@ -137,6 +147,9 @@ final class OidcBackChannelLogoutHandler implements LogoutHandler { int port = uriComponents.getPort(); uriVariables.put("basePort", (port == -1) ? "" : ":" + port); + String registrationId = token.getClientRegistration().getRegistrationId(); + uriVariables.put("registrationId", registrationId); + return UriComponentsBuilder.fromUriString(this.logoutUri) .buildAndExpand(uriVariables) .toUriString(); @@ -158,34 +171,13 @@ final class OidcBackChannelLogoutHandler implements LogoutHandler { } } - /** - * 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) { + public void setLogoutUri(String logoutUri) { Assert.hasText(logoutUri, "logoutUri cannot be empty"); this.logoutUri = logoutUri; } @@ -197,7 +189,7 @@ final class OidcBackChannelLogoutHandler implements LogoutHandler { * 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) { + public void setSessionCookieName(String sessionCookieName) { Assert.hasText(sessionCookieName, "clientSessionCookieName cannot be empty"); this.sessionCookieName = sessionCookieName; } diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurer.java index 38c2e55e6b..9e3eefc0e7 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurer.java @@ -19,16 +19,24 @@ package org.springframework.security.config.annotation.web.configurers.oauth2.cl import java.util.function.Consumer; import java.util.function.Function; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import org.springframework.context.ApplicationContext; 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.config.annotation.web.configurers.LogoutConfigurer; +import org.springframework.security.core.Authentication; 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.CompositeLogoutHandler; import org.springframework.security.web.authentication.logout.LogoutHandler; +import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler; import org.springframework.security.web.csrf.CsrfFilter; import org.springframework.util.Assert; @@ -140,8 +148,11 @@ public final class OidcLogoutConfigurer> } private LogoutHandler logoutHandler(B http) { - OidcBackChannelLogoutHandler logoutHandler = new OidcBackChannelLogoutHandler(); - logoutHandler.setSessionRegistry(OAuth2ClientConfigurerUtils.getOidcSessionRegistry(http)); + OidcBackChannelLogoutHandler logoutHandler = getBeanOrNull(OidcBackChannelLogoutHandler.class); + if (logoutHandler != null) { + return logoutHandler; + } + logoutHandler = new OidcBackChannelLogoutHandler(OAuth2ClientConfigurerUtils.getOidcSessionRegistry(http)); return logoutHandler; } @@ -176,21 +187,137 @@ public final class OidcLogoutConfigurer> */ public BackChannelLogoutConfigurer logoutUri(String logoutUri) { this.logoutHandler = (http) -> { - OidcBackChannelLogoutHandler logoutHandler = new OidcBackChannelLogoutHandler(); - logoutHandler.setSessionRegistry(OAuth2ClientConfigurerUtils.getOidcSessionRegistry(http)); + OidcBackChannelLogoutHandler logoutHandler = new OidcBackChannelLogoutHandler( + OAuth2ClientConfigurerUtils.getOidcSessionRegistry(http)); logoutHandler.setLogoutUri(logoutUri); return logoutHandler; }; return this; } + /** + * Configure what and how per-session logout will be performed. + * + *

+ * This overrides any value given to {@link #logoutUri(String)} + * + *

+ * By default, the resulting {@link LogoutHandler} will {@code POST} the session + * cookie and OIDC logout token back to the original back-channel logout endpoint. + * + *

+ * Using this method changes the underlying default that {@code POST}s the session + * cookie and CSRF token to your application's {@code /logout} endpoint. As such, + * it is recommended to call this instead of accepting the {@code /logout} default + * as this does not require any special CSRF configuration, even if you don't + * require other changes. + * + *

+ * For example, configuring Back-Channel Logout in the following way: + * + *

+		 * 	http
+		 *     	.oidcLogout((oidc) -> oidc
+		 *     		.backChannel((backChannel) -> backChannel
+		 *     			.logoutHandler(new OidcBackChannelLogoutHandler())
+		 *     		)
+		 *     	);
+		 * 
+ * + * will make so that the per-session logout invocation no longer requires special + * CSRF configurations. + * + *

+ * The default URI is + * {@code {baseUrl}/logout/connect/back-channel/{registrationId}}, which is simply + * an internal version of the same endpoint exposed to your Back-Channel services. + * You can use {@link OidcBackChannelLogoutHandler#setLogoutUri(String)} to alter + * the scheme, server name, or port in the {@code Host} header to accommodate how + * your application would address itself internally. + * + *

+ * For example, if the way your application would internally call itself is on a + * different scheme and port than incoming traffic, you can configure the endpoint + * in the following way: + * + *

+		 * 	http
+		 * 		.oidcLogout((oidc) -> oidc
+		 * 			.backChannel((backChannel) -> backChannel
+		 * 				.logoutHandler("http://localhost:9000/logout/connect/back-channel/{registrationId}")
+		 * 			)
+		 * 		);
+		 * 
+ * + *

+ * You can also publish it as a {@code @Bean} as follows: + * + *

+		 *	@Bean
+		 *	OidcBackChannelLogoutHandler oidcLogoutHandler(OidcSessionRegistry sessionRegistry) {
+		 *  	OidcBackChannelLogoutHandler logoutHandler = new OidcBackChannelLogoutHandler(sessionRegistry);
+		 *  	logoutHandler.setSessionCookieName("SESSION");
+		 *  	return logoutHandler;
+		 *	}
+		 * 
+ * + * to have the same effect. + * @param logoutHandler the {@link LogoutHandler} to use each individual session + * @return {@link BackChannelLogoutConfigurer} for further customizations + * @since 6.4 + */ + public BackChannelLogoutConfigurer logoutHandler(LogoutHandler logoutHandler) { + this.logoutHandler = (http) -> logoutHandler; + return this; + } + void configure(B http) { + LogoutHandler oidcLogout = this.logoutHandler.apply(http); + LogoutHandler sessionLogout = new SecurityContextLogoutHandler(); + LogoutConfigurer logout = http.getConfigurer(LogoutConfigurer.class); + if (logout != null) { + sessionLogout = new CompositeLogoutHandler(logout.getLogoutHandlers()); + } OidcBackChannelLogoutFilter filter = new OidcBackChannelLogoutFilter(authenticationConverter(http), - authenticationManager()); - filter.setLogoutHandler(this.logoutHandler.apply(http)); + authenticationManager(), new EitherLogoutHandler(oidcLogout, sessionLogout)); http.addFilterBefore(filter, CsrfFilter.class); } + private T getBeanOrNull(Class clazz) { + ApplicationContext context = getBuilder().getSharedObject(ApplicationContext.class); + if (context != null) { + String[] names = context.getBeanNamesForType(clazz); + if (names.length == 1) { + return (T) context.getBean(names[0]); + } + } + return null; + } + + private static final class EitherLogoutHandler implements LogoutHandler { + + private final LogoutHandler left; + + private final LogoutHandler right; + + EitherLogoutHandler(LogoutHandler left, LogoutHandler right) { + this.left = left; + this.right = right; + } + + @Override + public void logout(HttpServletRequest request, HttpServletResponse response, + Authentication authentication) { + if (request.getParameter("_spring_security_internal_logout") == null) { + this.left.logout(request, response, authentication); + } + else { + this.right.logout(request, response, authentication); + } + } + + } + } } diff --git a/config/src/main/java/org/springframework/security/config/web/server/OidcBackChannelLogoutAuthentication.java b/config/src/main/java/org/springframework/security/config/web/server/OidcBackChannelLogoutAuthentication.java index c68063b614..f7dd4b2e09 100644 --- a/config/src/main/java/org/springframework/security/config/web/server/OidcBackChannelLogoutAuthentication.java +++ b/config/src/main/java/org/springframework/security/config/web/server/OidcBackChannelLogoutAuthentication.java @@ -20,6 +20,7 @@ import java.util.Collections; import org.springframework.security.authentication.AbstractAuthenticationToken; import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcLogoutToken; +import org.springframework.security.oauth2.client.registration.ClientRegistration; /** * An {@link org.springframework.security.core.Authentication} implementation that @@ -37,13 +38,16 @@ class OidcBackChannelLogoutAuthentication extends AbstractAuthenticationToken { private final OidcLogoutToken logoutToken; + private final ClientRegistration clientRegistration; + /** * Construct an {@link OidcBackChannelLogoutAuthentication} * @param logoutToken a deserialized, verified OIDC Logout Token */ - OidcBackChannelLogoutAuthentication(OidcLogoutToken logoutToken) { + OidcBackChannelLogoutAuthentication(OidcLogoutToken logoutToken, ClientRegistration clientRegistration) { super(Collections.emptyList()); this.logoutToken = logoutToken; + this.clientRegistration = clientRegistration; setAuthenticated(true); } @@ -63,4 +67,8 @@ class OidcBackChannelLogoutAuthentication extends AbstractAuthenticationToken { return this.logoutToken; } + ClientRegistration getClientRegistration() { + return this.clientRegistration; + } + } diff --git a/config/src/main/java/org/springframework/security/config/web/server/OidcBackChannelLogoutReactiveAuthenticationManager.java b/config/src/main/java/org/springframework/security/config/web/server/OidcBackChannelLogoutReactiveAuthenticationManager.java index 46e4c44206..f36e48a2cb 100644 --- a/config/src/main/java/org/springframework/security/config/web/server/OidcBackChannelLogoutReactiveAuthenticationManager.java +++ b/config/src/main/java/org/springframework/security/config/web/server/OidcBackChannelLogoutReactiveAuthenticationManager.java @@ -80,7 +80,7 @@ final class OidcBackChannelLogoutReactiveAuthenticationManager implements Reacti .map((jwt) -> OidcLogoutToken.withTokenValue(logoutToken) .claims((claims) -> claims.putAll(jwt.getClaims())) .build()) - .map(OidcBackChannelLogoutAuthentication::new); + .map((oidcLogoutToken) -> new OidcBackChannelLogoutAuthentication(oidcLogoutToken, registration)); } private Mono decode(ClientRegistration registration, String token) { diff --git a/config/src/main/java/org/springframework/security/config/web/server/OidcBackChannelLogoutWebFilter.java b/config/src/main/java/org/springframework/security/config/web/server/OidcBackChannelLogoutWebFilter.java index 74f5f32e68..291ea41034 100644 --- a/config/src/main/java/org/springframework/security/config/web/server/OidcBackChannelLogoutWebFilter.java +++ b/config/src/main/java/org/springframework/security/config/web/server/OidcBackChannelLogoutWebFilter.java @@ -34,7 +34,6 @@ 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; @@ -60,7 +59,7 @@ class OidcBackChannelLogoutWebFilter implements WebFilter { private final ReactiveAuthenticationManager authenticationManager; - private ServerLogoutHandler logoutHandler = new OidcBackChannelServerLogoutHandler(); + private final ServerLogoutHandler logoutHandler; /** * Construct an {@link OidcBackChannelLogoutWebFilter} @@ -70,11 +69,13 @@ class OidcBackChannelLogoutWebFilter implements WebFilter { * Logout Tokens */ OidcBackChannelLogoutWebFilter(ServerAuthenticationConverter authenticationConverter, - ReactiveAuthenticationManager authenticationManager) { + ReactiveAuthenticationManager authenticationManager, ServerLogoutHandler logoutHandler) { Assert.notNull(authenticationConverter, "authenticationConverter cannot be null"); Assert.notNull(authenticationManager, "authenticationManager cannot be null"); + Assert.notNull(logoutHandler, "logoutHandler cannot be null"); this.authenticationConverter = authenticationConverter; this.authenticationManager = authenticationManager; + this.logoutHandler = logoutHandler; } @Override @@ -124,14 +125,4 @@ class OidcBackChannelLogoutWebFilter implements WebFilter { "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; - } - } diff --git a/config/src/main/java/org/springframework/security/config/web/server/OidcBackChannelServerLogoutHandler.java b/config/src/main/java/org/springframework/security/config/web/server/OidcBackChannelServerLogoutHandler.java index 5312a6da7c..1fe128fd7e 100644 --- a/config/src/main/java/org/springframework/security/config/web/server/OidcBackChannelServerLogoutHandler.java +++ b/config/src/main/java/org/springframework/security/config/web/server/OidcBackChannelServerLogoutHandler.java @@ -34,15 +34,15 @@ import org.springframework.http.ResponseEntity; import org.springframework.http.server.reactive.ServerHttpRequest; 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.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.reactive.function.BodyInserters; import org.springframework.web.reactive.function.client.WebClient; import org.springframework.web.util.UriComponents; import org.springframework.web.util.UriComponentsBuilder; @@ -52,23 +52,27 @@ import org.springframework.web.util.UriComponentsBuilder; * Back-Channel Logout Token and invalidates each one. * * @author Josh Cummings - * @since 6.2 + * @since 6.4 * @see OIDC Back-Channel Logout * Spec */ -final class OidcBackChannelServerLogoutHandler implements ServerLogoutHandler { +public final class OidcBackChannelServerLogoutHandler implements ServerLogoutHandler { private final Log logger = LogFactory.getLog(getClass()); - private ReactiveOidcSessionRegistry sessionRegistry = new InMemoryReactiveOidcSessionRegistry(); + private final ReactiveOidcSessionRegistry sessionRegistry; private WebClient web = WebClient.create(); - private String logoutUri = "{baseScheme}://localhost{basePort}/logout"; + private String logoutUri = "{baseUrl}/logout/connect/back-channel/{registrationId}"; private String sessionCookieName = "SESSION"; + public OidcBackChannelServerLogoutHandler(ReactiveOidcSessionRegistry sessionRegistry) { + this.sessionRegistry = sessionRegistry; + } + @Override public Mono logout(WebFilterExchange exchange, Authentication authentication) { if (!(authentication instanceof OidcBackChannelLogoutAuthentication token)) { @@ -84,7 +88,7 @@ final class OidcBackChannelServerLogoutHandler implements ServerLogoutHandler { AtomicInteger invalidatedCount = new AtomicInteger(0); return this.sessionRegistry.removeSessionInformation(token.getPrincipal()).concatMap((session) -> { totalCount.incrementAndGet(); - return eachLogout(exchange, session).flatMap((response) -> { + return eachLogout(exchange, session, token).flatMap((response) -> { invalidatedCount.incrementAndGet(); return Mono.empty(); }).onErrorResume((ex) -> { @@ -105,17 +109,26 @@ final class OidcBackChannelServerLogoutHandler implements ServerLogoutHandler { }); } - private Mono> eachLogout(WebFilterExchange exchange, OidcSessionInformation session) { + private Mono> eachLogout(WebFilterExchange exchange, OidcSessionInformation session, + OidcBackChannelLogoutAuthentication token) { HttpHeaders headers = new HttpHeaders(); headers.add(HttpHeaders.COOKIE, this.sessionCookieName + "=" + session.getSessionId()); for (Map.Entry credential : session.getAuthorities().entrySet()) { headers.add(credential.getKey(), credential.getValue()); } - String logout = computeLogoutEndpoint(exchange.getExchange().getRequest()); - return this.web.post().uri(logout).headers((h) -> h.putAll(headers)).retrieve().toBodilessEntity(); + String logout = computeLogoutEndpoint(exchange.getExchange().getRequest(), token); + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("logout_token", token.getPrincipal().getTokenValue()); + body.add("_spring_security_internal_logout", "true"); + return this.web.post() + .uri(logout) + .headers((h) -> h.putAll(headers)) + .body(BodyInserters.fromFormData(body)) + .retrieve() + .toBodilessEntity(); } - String computeLogoutEndpoint(ServerHttpRequest request) { + String computeLogoutEndpoint(ServerHttpRequest request, OidcBackChannelLogoutAuthentication token) { // @formatter:off UriComponents uriComponents = UriComponentsBuilder.fromUri(request.getURI()) .replacePath(request.getPath().contextPath().value()) @@ -137,6 +150,9 @@ final class OidcBackChannelServerLogoutHandler implements ServerLogoutHandler { int port = uriComponents.getPort(); uriVariables.put("basePort", (port == -1) ? "" : ":" + port); + String registrationId = token.getClientRegistration().getRegistrationId(); + uriVariables.put("registrationId", registrationId); + return UriComponentsBuilder.fromUriString(this.logoutUri) .buildAndExpand(uriVariables) .toUriString(); @@ -161,34 +177,13 @@ final class OidcBackChannelServerLogoutHandler implements ServerLogoutHandler { 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) { + public void setLogoutUri(String logoutUri) { Assert.hasText(logoutUri, "logoutUri cannot be empty"); this.logoutUri = logoutUri; } @@ -200,7 +195,7 @@ final class OidcBackChannelServerLogoutHandler implements ServerLogoutHandler { * 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) { + public void setSessionCookieName(String sessionCookieName) { Assert.hasText(sessionCookieName, "clientSessionCookieName cannot be empty"); this.sessionCookieName = sessionCookieName; } diff --git a/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java b/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java index ebec42a6ef..b6000b7322 100644 --- a/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java +++ b/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java @@ -58,7 +58,6 @@ import org.springframework.security.authorization.AuthorizationDecision; import org.springframework.security.authorization.ObservationReactiveAuthorizationManager; import org.springframework.security.authorization.ReactiveAuthorizationManager; import org.springframework.security.config.Customizer; -import org.springframework.security.config.annotation.web.configurers.oauth2.client.OidcLogoutConfigurer; import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.AuthorityUtils; @@ -5529,8 +5528,12 @@ public class ServerHttpSecurity { } private ServerLogoutHandler logoutHandler() { - OidcBackChannelServerLogoutHandler logoutHandler = new OidcBackChannelServerLogoutHandler(); - logoutHandler.setSessionRegistry(OidcLogoutSpec.this.getSessionRegistry()); + OidcBackChannelServerLogoutHandler logoutHandler = getBeanOrNull( + OidcBackChannelServerLogoutHandler.class); + if (logoutHandler != null) { + return logoutHandler; + } + logoutHandler = new OidcBackChannelServerLogoutHandler(OidcLogoutSpec.this.getSessionRegistry()); return logoutHandler; } @@ -5548,9 +5551,9 @@ public class ServerHttpSecurity { * *

* By default, the URI is set to - * {@code {baseScheme}://localhost{basePort}/logout}, meaning that the scheme - * and port of the original back-channel request is preserved, while the host - * and endpoint are changed. + * {@code {baseUrl}/logout/connect/back-channel/{registrationId}}, meaning + * that the scheme and port of the original back-channel request is preserved, + * while the host and endpoint are changed. * *

* If you are using Spring Security for the logout endpoint, the path part of @@ -5561,27 +5564,135 @@ public class ServerHttpSecurity { * that the scheme, server name, or port in the {@code Host} header are * different from how you would address the same server internally. * @param logoutUri the URI to request logout on the back-channel - * @return the {@link OidcLogoutConfigurer.BackChannelLogoutConfigurer} for - * further customizations + * @return the {@link BackChannelLogoutConfigurer} for further customizations * @since 6.2.4 */ public BackChannelLogoutConfigurer logoutUri(String logoutUri) { this.logoutHandler = () -> { - OidcBackChannelServerLogoutHandler logoutHandler = new OidcBackChannelServerLogoutHandler(); - logoutHandler.setSessionRegistry(OidcLogoutSpec.this.getSessionRegistry()); + OidcBackChannelServerLogoutHandler logoutHandler = new OidcBackChannelServerLogoutHandler( + OidcLogoutSpec.this.getSessionRegistry()); logoutHandler.setLogoutUri(logoutUri); return logoutHandler; }; return this; } + /** + * Configure what and how per-session logout will be performed. + * + *

+ * This overrides any value given to {@link #logoutUri(String)} + * + *

+ * By default, the resulting {@link LogoutHandler} will {@code POST} the + * session cookie and OIDC logout token back to the original back-channel + * logout endpoint. + * + *

+ * Using this method changes the underlying default that {@code POST}s the + * session cookie and CSRF token to your application's {@code /logout} + * endpoint. As such, it is recommended to call this instead of accepting the + * {@code /logout} default as this does not require any special CSRF + * configuration, even if you don't require other changes. + * + *

+ * For example, configuring Back-Channel Logout in the following way: + * + *

+			 * 	http
+			 *     	.oidcLogout((oidc) -> oidc
+			 *     		.backChannel((backChannel) -> backChannel
+			 *     			.logoutHandler(new OidcBackChannelServerLogoutHandler())
+			 *     		)
+			 *     	);
+			 * 
+ * + * will make so that the per-session logout invocation no longer requires + * special CSRF configurations. + * + *

+ * The default URI is + * {@code {baseUrl}/logout/connect/back-channel/{registrationId}}, which is + * simply an internal version of the same endpoint exposed to your + * Back-Channel services. You can use + * {@link OidcBackChannelServerLogoutHandler#setLogoutUri(String)} to alter + * the scheme, server name, or port in the {@code Host} header to accommodate + * how your application would address itself internally. + * + *

+ * For example, if the way your application would internally call itself is on + * a different scheme and port than incoming traffic, you can configure the + * endpoint in the following way: + * + *

+			 * 	http
+			 * 		.oidcLogout((oidc) -> oidc
+			 * 			.backChannel((backChannel) -> backChannel
+			 * 				.logoutUri("http://localhost:9000/logout/connect/back-channel/{registrationId}")
+			 * 			)
+			 * 		);
+			 * 
+ * + *

+ * You can also publish it as a {@code @Bean} as follows: + * + *

+			 *	@Bean
+			 *	OidcBackChannelServerLogoutHandler oidcLogoutHandler() {
+			 *  	OidcBackChannelServerLogoutHandler logoutHandler = new OidcBackChannelServerLogoutHandler();
+			 *  	logoutHandler.setLogoutUri("http://localhost:9000/logout/connect/back-channel/{registrationId}");
+			 *  	return logoutHandler;
+			 *	}
+			 * 
+ * + * to have the same effect. + * @param logoutHandler the {@link ServerLogoutHandler} to use each individual + * session + * @return {@link BackChannelLogoutConfigurer} for further customizations + * @since 6.4 + */ + public BackChannelLogoutConfigurer logoutHandler(ServerLogoutHandler logoutHandler) { + this.logoutHandler = () -> logoutHandler; + return this; + } + void configure(ServerHttpSecurity http) { + ServerLogoutHandler oidcLogout = this.logoutHandler.get(); + ServerLogoutHandler sessionLogout = new SecurityContextServerLogoutHandler(); + LogoutSpec logout = ServerHttpSecurity.this.logout; + if (logout != null) { + sessionLogout = new DelegatingServerLogoutHandler(logout.logoutHandlers); + } OidcBackChannelLogoutWebFilter filter = new OidcBackChannelLogoutWebFilter(authenticationConverter(), - authenticationManager()); - filter.setLogoutHandler(this.logoutHandler.get()); + authenticationManager(), new EitherLogoutHandler(oidcLogout, sessionLogout)); http.addFilterBefore(filter, SecurityWebFiltersOrder.CSRF); } + private static final class EitherLogoutHandler implements ServerLogoutHandler { + + private final ServerLogoutHandler left; + + private final ServerLogoutHandler right; + + EitherLogoutHandler(ServerLogoutHandler left, ServerLogoutHandler right) { + this.left = left; + this.right = right; + } + + @Override + public Mono logout(WebFilterExchange exchange, Authentication authentication) { + return exchange.getExchange().getFormData().flatMap((data) -> { + if (data.getFirst("_spring_security_internal_logout") == null) { + return this.left.logout(exchange, authentication); + } + else { + return this.right.logout(exchange, authentication); + } + }); + } + + } + } } diff --git a/config/src/main/kotlin/org/springframework/security/config/annotation/web/OidcLogoutDsl.kt b/config/src/main/kotlin/org/springframework/security/config/annotation/web/OidcLogoutDsl.kt index f9fdd7dc4d..27532b4c01 100644 --- a/config/src/main/kotlin/org/springframework/security/config/annotation/web/OidcLogoutDsl.kt +++ b/config/src/main/kotlin/org/springframework/security/config/annotation/web/OidcLogoutDsl.kt @@ -1,3 +1,4 @@ + /* * Copyright 2002-2023 the original author or authors. * @@ -72,4 +73,5 @@ class OidcLogoutDsl { backChannel?.also { oidcLogout.backChannel(backChannel) } } } + } diff --git a/config/src/main/kotlin/org/springframework/security/config/annotation/web/oauth2/login/OidcBackChannelLogoutDsl.kt b/config/src/main/kotlin/org/springframework/security/config/annotation/web/oauth2/login/OidcBackChannelLogoutDsl.kt index efac77a566..f23b14d981 100644 --- a/config/src/main/kotlin/org/springframework/security/config/annotation/web/oauth2/login/OidcBackChannelLogoutDsl.kt +++ b/config/src/main/kotlin/org/springframework/security/config/annotation/web/oauth2/login/OidcBackChannelLogoutDsl.kt @@ -18,6 +18,7 @@ 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 +import org.springframework.security.web.authentication.logout.LogoutHandler /** * A Kotlin DSL to configure the OIDC 1.0 Back-Channel configuration using @@ -28,7 +29,26 @@ import org.springframework.security.config.annotation.web.configurers.oauth2.cli */ @OAuth2LoginSecurityMarker class OidcBackChannelLogoutDsl { + private var _logoutUri: String? = null + private var _logoutHandler: LogoutHandler? = null + + var logoutHandler: LogoutHandler? + get() = _logoutHandler + set(value) { + _logoutHandler = value + _logoutUri = null + } + var logoutUri: String? + get() = _logoutUri + set(value) { + _logoutUri = value + _logoutHandler = null + } + internal fun get(): (OidcLogoutConfigurer.BackChannelLogoutConfigurer) -> Unit { - return { backChannel -> } + return { backChannel -> + logoutHandler?.also { backChannel.logoutHandler(logoutHandler) } + logoutUri?.also { backChannel.logoutUri(logoutUri) } + } } } diff --git a/config/src/main/kotlin/org/springframework/security/config/web/server/ServerOidcBackChannelLogoutDsl.kt b/config/src/main/kotlin/org/springframework/security/config/web/server/ServerOidcBackChannelLogoutDsl.kt index 5a245e5092..ba6b15da93 100644 --- a/config/src/main/kotlin/org/springframework/security/config/web/server/ServerOidcBackChannelLogoutDsl.kt +++ b/config/src/main/kotlin/org/springframework/security/config/web/server/ServerOidcBackChannelLogoutDsl.kt @@ -16,6 +16,8 @@ package org.springframework.security.config.web.server +import org.springframework.security.web.server.authentication.logout.ServerLogoutHandler + /** * A Kotlin DSL to configure [ServerHttpSecurity] OIDC 1.0 Back-Channel Logout support using idiomatic Kotlin code. * @@ -24,7 +26,26 @@ package org.springframework.security.config.web.server */ @ServerSecurityMarker class ServerOidcBackChannelLogoutDsl { + private var _logoutUri: String? = null + private var _logoutHandler: ServerLogoutHandler? = null + + var logoutHandler: ServerLogoutHandler? + get() = _logoutHandler + set(value) { + _logoutHandler = value + _logoutUri = null + } + var logoutUri: String? + get() = _logoutUri + set(value) { + _logoutUri = value + _logoutHandler = null + } + internal fun get(): (ServerHttpSecurity.OidcLogoutSpec.BackChannelLogoutConfigurer) -> Unit { - return { backChannel -> } + return { backChannel -> + logoutHandler?.also { backChannel.logoutHandler(logoutHandler) } + logoutUri?.also { backChannel.logoutUri(logoutUri) } + } } } diff --git a/config/src/main/kotlin/org/springframework/security/config/web/server/ServerOidcLogoutDsl.kt b/config/src/main/kotlin/org/springframework/security/config/web/server/ServerOidcLogoutDsl.kt index 503a5b0c84..7c27e3e081 100644 --- a/config/src/main/kotlin/org/springframework/security/config/web/server/ServerOidcLogoutDsl.kt +++ b/config/src/main/kotlin/org/springframework/security/config/web/server/ServerOidcLogoutDsl.kt @@ -47,7 +47,9 @@ class ServerOidcLogoutDsl { * return http { * oauth2Login { } * oidcLogout { - * backChannel { } + * backChannel { + * sessionLogout { } + * } * } * } * } diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcBackChannelLogoutHandlerTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcBackChannelLogoutHandlerTests.java index 71595ac7aa..9dc542a406 100644 --- a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcBackChannelLogoutHandlerTests.java +++ b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcBackChannelLogoutHandlerTests.java @@ -19,44 +19,55 @@ package org.springframework.security.config.annotation.web.configurers.oauth2.cl import org.junit.jupiter.api.Test; import org.springframework.mock.web.MockHttpServletRequest; +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.TestClientRegistrations; import static org.assertj.core.api.Assertions.assertThat; public class OidcBackChannelLogoutHandlerTests { + private final OidcSessionRegistry sessionRegistry = new InMemoryOidcSessionRegistry(); + + private final OidcBackChannelLogoutAuthentication token = new OidcBackChannelLogoutAuthentication( + TestOidcLogoutTokens.withSubject("issuer", "subject").build(), + TestClientRegistrations.clientRegistration().build()); + // gh-14553 @Test public void computeLogoutEndpointWhenDifferentHostnameThenLocalhost() { - OidcBackChannelLogoutHandler logoutHandler = new OidcBackChannelLogoutHandler(); + OidcBackChannelLogoutHandler logoutHandler = new OidcBackChannelLogoutHandler(this.sessionRegistry); MockHttpServletRequest request = new MockHttpServletRequest("GET", "/back-channel/logout"); + logoutHandler.setLogoutUri("{baseScheme}://localhost{basePort}/logout"); request.setServerName("host.docker.internal"); request.setServerPort(8090); - String endpoint = logoutHandler.computeLogoutEndpoint(request); - assertThat(endpoint).isEqualTo("http://localhost:8090/logout"); + String endpoint = logoutHandler.computeLogoutEndpoint(request, this.token); + assertThat(endpoint).startsWith("http://localhost:8090/logout"); } @Test public void computeLogoutEndpointWhenUsingBaseUrlTemplateThenServerName() { - OidcBackChannelLogoutHandler logoutHandler = new OidcBackChannelLogoutHandler(); + OidcBackChannelLogoutHandler logoutHandler = new OidcBackChannelLogoutHandler(this.sessionRegistry); logoutHandler.setLogoutUri("{baseUrl}/logout"); MockHttpServletRequest request = new MockHttpServletRequest("GET", "/back-channel/logout"); request.setServerName("host.docker.internal"); request.setServerPort(8090); - String endpoint = logoutHandler.computeLogoutEndpoint(request); - assertThat(endpoint).isEqualTo("http://host.docker.internal:8090/logout"); + String endpoint = logoutHandler.computeLogoutEndpoint(request, this.token); + assertThat(endpoint).startsWith("http://host.docker.internal:8090/logout"); } // gh-14609 @Test public void computeLogoutEndpointWhenLogoutUriThenUses() { - OidcBackChannelLogoutHandler logoutHandler = new OidcBackChannelLogoutHandler(); + OidcBackChannelLogoutHandler logoutHandler = new OidcBackChannelLogoutHandler(this.sessionRegistry); logoutHandler.setLogoutUri("http://localhost:8090/logout"); MockHttpServletRequest request = new MockHttpServletRequest("GET", "/back-channel/logout"); request.setScheme("https"); request.setServerName("server-one.com"); request.setServerPort(80); - String endpoint = logoutHandler.computeLogoutEndpoint(request); - assertThat(endpoint).isEqualTo("http://localhost:8090/logout"); + String endpoint = logoutHandler.computeLogoutEndpoint(request, this.token); + assertThat(endpoint).startsWith("http://localhost:8090/logout"); } } diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurerTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurerTests.java index 481dcced74..e1a6c357fa 100644 --- a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurerTests.java +++ b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurerTests.java @@ -24,6 +24,7 @@ import java.time.Instant; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Consumer; import com.nimbusds.jose.jwk.JWKSet; import com.nimbusds.jose.jwk.RSAKey; @@ -91,6 +92,7 @@ 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.assertj.core.api.Assertions.assertThat; import static org.hamcrest.Matchers.containsString; import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.willThrow; @@ -218,6 +220,40 @@ public class OidcLogoutConfigurerTests { this.mvc.perform(get("/token/logout").session(one)).andExpect(status().isOk()); } + @Test + void logoutWhenSelfRemoteLogoutUriThenUses() throws Exception { + this.spring.register(WebServerConfig.class, OidcProviderConfig.class, SelfLogoutUriConfig.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 logoutWhenDifferentCookieNameThenUses() throws Exception { + this.spring.register(OidcProviderConfig.class, CookieConfig.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 logoutWhenRemoteLogoutFailsThenReportsPartialLogout() throws Exception { this.spring.register(WebServerConfig.class, OidcProviderConfig.class, WithBrokenLogoutConfig.class).autowire(); @@ -355,6 +391,87 @@ public class OidcLogoutConfigurerTests { } + @Configuration + @EnableWebSecurity + @Import(RegistrationConfig.class) + static class SelfLogoutUriConfig { + + private final OidcSessionRegistry sessionRegistry = 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 + OidcBackChannelLogoutHandler oidcLogoutHandler() { + return new OidcBackChannelLogoutHandler(this.sessionRegistry); + } + + } + + @Configuration + @EnableWebSecurity + @Import(RegistrationConfig.class) + static class CookieConfig { + + private final MockWebServer server = new MockWebServer(); + + private final OidcSessionRegistry sessionRegistry = 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 + OidcBackChannelLogoutHandler oidcLogoutHandler() { + OidcBackChannelLogoutHandler logoutHandler = new OidcBackChannelLogoutHandler(this.sessionRegistry); + logoutHandler.setSessionCookieName("SESSION"); + return logoutHandler; + } + + @Bean + MockWebServer web(ObjectProvider mvc) { + MockMvcDispatcher dispatcher = new MockMvcDispatcher(mvc); + dispatcher.setAssertion((rr) -> { + String cookie = rr.getHeaders().get("Cookie"); + if (cookie == null) { + return; + } + assertThat(cookie).contains("SESSION").doesNotContain("JSESSIONID"); + }); + this.server.setDispatcher(dispatcher); + return this.server; + } + + @PreDestroy + void shutdown() throws IOException { + this.server.shutdown(); + } + + } + @Configuration @EnableWebSecurity @Import(RegistrationConfig.class) @@ -559,12 +676,15 @@ public class OidcLogoutConfigurerTests { private MockMvc mvc; + private Consumer assertion = (rr) -> { }; + MockMvcDispatcher(ObjectProvider mvc) { this.mvcProvider = mvc; } @Override public MockResponse dispatch(RecordedRequest request) throws InterruptedException { + this.assertion.accept(request); this.mvc = this.mvcProvider.getObject(); String method = request.getMethod(); String path = request.getPath(); @@ -601,6 +721,10 @@ public class OidcLogoutConfigurerTests { this.session.put(session.getId(), session); } + void setAssertion(Consumer assertion) { + this.assertion = assertion; + } + private MockHttpSession session(RecordedRequest request) { String cookieHeaderValue = request.getHeader("Cookie"); if (cookieHeaderValue == null) { @@ -613,6 +737,10 @@ public class OidcLogoutConfigurerTests { return this.session.computeIfAbsent(parts[1], (k) -> new MockHttpSession(new MockServletContext(), parts[1])); } + if ("SESSION".equals(parts[0])) { + return this.session.computeIfAbsent(parts[1], + (k) -> new MockHttpSession(new MockServletContext(), parts[1])); + } } return new MockHttpSession(); } diff --git a/config/src/test/java/org/springframework/security/config/web/server/OidcBackChannelServerLogoutHandlerTests.java b/config/src/test/java/org/springframework/security/config/web/server/OidcBackChannelServerLogoutHandlerTests.java index 37190608c1..a8494bdc90 100644 --- a/config/src/test/java/org/springframework/security/config/web/server/OidcBackChannelServerLogoutHandlerTests.java +++ b/config/src/test/java/org/springframework/security/config/web/server/OidcBackChannelServerLogoutHandlerTests.java @@ -19,6 +19,10 @@ package org.springframework.security.config.web.server; import org.junit.jupiter.api.Test; import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +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.TestClientRegistrations; import static org.assertj.core.api.Assertions.assertThat; @@ -27,36 +31,43 @@ import static org.assertj.core.api.Assertions.assertThat; */ public class OidcBackChannelServerLogoutHandlerTests { + private final ReactiveOidcSessionRegistry sessionRegistry = new InMemoryReactiveOidcSessionRegistry(); + + private final OidcBackChannelLogoutAuthentication token = new OidcBackChannelLogoutAuthentication( + TestOidcLogoutTokens.withSubject("issuer", "subject").build(), + TestClientRegistrations.clientRegistration().build()); + // gh-14553 @Test public void computeLogoutEndpointWhenDifferentHostnameThenLocalhost() { - OidcBackChannelServerLogoutHandler logoutHandler = new OidcBackChannelServerLogoutHandler(); + OidcBackChannelServerLogoutHandler logoutHandler = new OidcBackChannelServerLogoutHandler(this.sessionRegistry); + logoutHandler.setLogoutUri("{baseScheme}://localhost{basePort}/logout"); MockServerHttpRequest request = MockServerHttpRequest .get("https://host.docker.internal:8090/back-channel/logout") .build(); - String endpoint = logoutHandler.computeLogoutEndpoint(request); - assertThat(endpoint).isEqualTo("https://localhost:8090/logout"); + String endpoint = logoutHandler.computeLogoutEndpoint(request, this.token); + assertThat(endpoint).startsWith("https://localhost:8090/logout"); } @Test public void computeLogoutEndpointWhenUsingBaseUrlTemplateThenServerName() { - OidcBackChannelServerLogoutHandler logoutHandler = new OidcBackChannelServerLogoutHandler(); + OidcBackChannelServerLogoutHandler logoutHandler = new OidcBackChannelServerLogoutHandler(this.sessionRegistry); logoutHandler.setLogoutUri("{baseUrl}/logout"); MockServerHttpRequest request = MockServerHttpRequest .get("http://host.docker.internal:8090/back-channel/logout") .build(); - String endpoint = logoutHandler.computeLogoutEndpoint(request); - assertThat(endpoint).isEqualTo("http://host.docker.internal:8090/logout"); + String endpoint = logoutHandler.computeLogoutEndpoint(request, this.token); + assertThat(endpoint).startsWith("http://host.docker.internal:8090/logout"); } // gh-14609 @Test public void computeLogoutEndpointWhenLogoutUriThenUses() { - OidcBackChannelServerLogoutHandler logoutHandler = new OidcBackChannelServerLogoutHandler(); + OidcBackChannelServerLogoutHandler logoutHandler = new OidcBackChannelServerLogoutHandler(this.sessionRegistry); logoutHandler.setLogoutUri("http://localhost:8090/logout"); MockServerHttpRequest request = MockServerHttpRequest.get("https://server-one.com/back-channel/logout").build(); - String endpoint = logoutHandler.computeLogoutEndpoint(request); - assertThat(endpoint).isEqualTo("http://localhost:8090/logout"); + String endpoint = logoutHandler.computeLogoutEndpoint(request, this.token); + assertThat(endpoint).startsWith("http://localhost:8090/logout"); } } diff --git a/config/src/test/java/org/springframework/security/config/web/server/OidcLogoutSpecTests.java b/config/src/test/java/org/springframework/security/config/web/server/OidcLogoutSpecTests.java index fed59255f0..63845b7fcf 100644 --- a/config/src/test/java/org/springframework/security/config/web/server/OidcLogoutSpecTests.java +++ b/config/src/test/java/org/springframework/security/config/web/server/OidcLogoutSpecTests.java @@ -25,6 +25,7 @@ import java.time.Instant; import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.function.Consumer; import com.nimbusds.jose.jwk.JWKSet; import com.nimbusds.jose.jwk.RSAKey; @@ -96,6 +97,7 @@ import org.springframework.web.reactive.function.BodyInserters; import org.springframework.web.server.WebSession; import org.springframework.web.server.adapter.WebHttpHandlerBuilder; +import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.Matchers.containsString; import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; @@ -268,6 +270,52 @@ public class OidcLogoutSpecTests { this.test.get().uri("/token/logout").cookie("SESSION", one).exchange().expectStatus().isOk(); } + @Test + void logoutWhenSelfRemoteLogoutUriThenUses() { + this.spring.register(WebServerConfig.class, OidcProviderConfig.class, SelfLogoutUriConfig.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(); + } + + @Test + void logoutWhenDifferentCookieNameThenUses() { + this.spring.register(OidcProviderConfig.class, CookieConfig.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(); + } + @Test void logoutWhenRemoteLogoutFailsThenReportsPartialLogout() { this.spring.register(WebServerConfig.class, OidcProviderConfig.class, WithBrokenLogoutConfig.class).autowire(); @@ -444,6 +492,81 @@ public class OidcLogoutSpecTests { } + @Configuration + @EnableWebFluxSecurity + @Import(RegistrationConfig.class) + static class SelfLogoutUriConfig { + + @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 CookieConfig { + + private final ReactiveOidcSessionRegistry sessionRegistry = new InMemoryReactiveOidcSessionRegistry(); + + private final MockWebServer server = new MockWebServer(); + + @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 + OidcBackChannelServerLogoutHandler oidcLogoutHandler() { + OidcBackChannelServerLogoutHandler logoutHandler = new OidcBackChannelServerLogoutHandler( + this.sessionRegistry); + logoutHandler.setSessionCookieName("JSESSIONID"); + return logoutHandler; + } + + @Bean + MockWebServer web(ObjectProvider web) { + WebTestClientDispatcher dispatcher = new WebTestClientDispatcher(web); + dispatcher.setAssertion((rr) -> { + String cookie = rr.getHeaders().get("Cookie"); + if (cookie == null) { + return; + } + assertThat(cookie).contains("JSESSIONID"); + }); + this.server.setDispatcher(dispatcher); + return this.server; + } + + @PreDestroy + void shutdown() throws IOException { + this.server.shutdown(); + } + + } + @Configuration @EnableWebFluxSecurity @Import(RegistrationConfig.class) @@ -652,12 +775,15 @@ public class OidcLogoutSpecTests { private WebTestClient web; + private Consumer assertion = (rr) -> { }; + WebTestClientDispatcher(ObjectProvider web) { this.webProvider = web; } @Override public MockResponse dispatch(RecordedRequest request) throws InterruptedException { + this.assertion.accept(request); this.web = this.webProvider.getObject(); String method = request.getMethod(); String path = request.getPath(); @@ -700,6 +826,10 @@ public class OidcLogoutSpecTests { } } + void setAssertion(Consumer assertion) { + this.assertion = assertion; + } + private String session(RecordedRequest request) { String cookieHeaderValue = request.getHeader("Cookie"); if (cookieHeaderValue == null) { @@ -711,6 +841,9 @@ public class OidcLogoutSpecTests { if (SESSION_COOKIE_NAME.equals(parts[0])) { return parts[1]; } + if ("JSESSIONID".equals(parts[0])) { + return parts[1]; + } } return null; } diff --git a/config/src/test/kotlin/org/springframework/security/config/annotation/web/OidcLogoutDslTests.kt b/config/src/test/kotlin/org/springframework/security/config/annotation/web/OidcLogoutDslTests.kt index 468be4251b..2518991b8d 100644 --- a/config/src/test/kotlin/org/springframework/security/config/annotation/web/OidcLogoutDslTests.kt +++ b/config/src/test/kotlin/org/springframework/security/config/annotation/web/OidcLogoutDslTests.kt @@ -23,13 +23,19 @@ 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.annotation.web.configurers.oauth2.client.OidcBackChannelLogoutHandler +import org.springframework.security.config.annotation.web.oauth2.login.OidcBackChannelLogoutDsl import org.springframework.security.config.test.SpringTestContext import org.springframework.security.config.test.SpringTestContextExtension +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.web.SecurityFilterChain +import org.springframework.security.web.authentication.logout.LogoutHandler +import org.springframework.test.util.ReflectionTestUtils import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.post @@ -53,12 +59,23 @@ class OidcLogoutDslTests { this.mockMvc.post("/logout/connect/back-channel/" + clientRegistration.registrationId) { param("logout_token", "token") }.andExpect { status { isBadRequest() } } + val chain: SecurityFilterChain = this.spring.context.getBean(SecurityFilterChain::class.java) + for (filter in chain.filters) { + if (filter.javaClass.simpleName.equals("OidcBackChannelLogoutFilter")) { + val logoutHandler = ReflectionTestUtils.getField(filter, "logoutHandler") as LogoutHandler + val backChannelLogoutHandler = ReflectionTestUtils.getField(logoutHandler, "left") as LogoutHandler + var cookieName = ReflectionTestUtils.getField(backChannelLogoutHandler, "sessionCookieName") as String + assert(cookieName.equals("SESSION")) + } + } } @Configuration @EnableWebSecurity open class ClientRepositoryConfig { + private val sessionRegistry = InMemoryOidcSessionRegistry() + @Bean open fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { http { @@ -73,6 +90,13 @@ class OidcLogoutDslTests { return http.build() } + @Bean + open fun oidcLogoutHandler(): OidcBackChannelLogoutHandler { + val logoutHandler = OidcBackChannelLogoutHandler(this.sessionRegistry) + logoutHandler.setSessionCookieName("SESSION"); + return logoutHandler; + } + @Bean open fun clientRegistration(): ClientRegistration { return TestClientRegistrations.clientRegistration().build() diff --git a/config/src/test/kotlin/org/springframework/security/config/web/server/ServerOidcLogoutDslTests.kt b/config/src/test/kotlin/org/springframework/security/config/web/server/ServerOidcLogoutDslTests.kt index a4b62b2dc0..4bc0ca993d 100644 --- a/config/src/test/kotlin/org/springframework/security/config/web/server/ServerOidcLogoutDslTests.kt +++ b/config/src/test/kotlin/org/springframework/security/config/web/server/ServerOidcLogoutDslTests.kt @@ -25,14 +25,18 @@ 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.oidc.server.session.InMemoryReactiveOidcSessionRegistry 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.authentication.logout.LogoutHandler import org.springframework.security.web.server.SecurityWebFilterChain +import org.springframework.test.util.ReflectionTestUtils import org.springframework.test.web.reactive.server.WebTestClient import org.springframework.web.reactive.config.EnableWebFlux import org.springframework.web.reactive.function.BodyInserters +import org.springframework.web.server.WebFilter /** * Tests for [ServerOidcLogoutDsl] @@ -63,6 +67,15 @@ class ServerOidcLogoutDslTests { .body(BodyInserters.fromFormData("logout_token", "token")) .exchange() .expectStatus().isBadRequest + val chain: SecurityWebFilterChain = this.spring.context.getBean(SecurityWebFilterChain::class.java) + chain.webFilters.doOnNext({ filter: WebFilter -> + if (filter.javaClass.simpleName.equals("OidcBackChannelLogoutWebFilter")) { + val logoutHandler = ReflectionTestUtils.getField(filter, "logoutHandler") as LogoutHandler + val backChannelLogoutHandler = ReflectionTestUtils.getField(logoutHandler, "left") as LogoutHandler + var cookieName = ReflectionTestUtils.getField(backChannelLogoutHandler, "sessionCookieName") as String + assert(cookieName.equals("SESSION")) + } + }) } @Configuration @@ -70,6 +83,8 @@ class ServerOidcLogoutDslTests { @EnableWebFluxSecurity open class ClientRepositoryConfig { + private val sessionRegistry = InMemoryReactiveOidcSessionRegistry() + @Bean open fun securityFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain { return http { @@ -83,6 +98,13 @@ class ServerOidcLogoutDslTests { } } + @Bean + open fun oidcLogoutHandler(): OidcBackChannelServerLogoutHandler { + val logoutHandler = OidcBackChannelServerLogoutHandler(this.sessionRegistry) + logoutHandler.setSessionCookieName("SESSION"); + return logoutHandler; + } + @Bean open fun clientRegistration(): ClientRegistration { return TestClientRegistrations.clientRegistration().build() diff --git a/docs/modules/ROOT/pages/reactive/oauth2/login/logout.adoc b/docs/modules/ROOT/pages/reactive/oauth2/login/logout.adoc index c1545aeb74..15ce91c39c 100644 --- a/docs/modules/ROOT/pages/reactive/oauth2/login/logout.adoc +++ b/docs/modules/ROOT/pages/reactive/oauth2/login/logout.adoc @@ -137,6 +137,11 @@ Java:: + [source,java,role="primary"] ---- +@Bean +OidcBackChannelServerLogoutHandler oidcLogoutHandler() { + return new OidcBackChannelServerLogoutHandler(); +} + @Bean public SecurityWebFilterChain filterChain(ServerHttpSecurity http) throws Exception { http @@ -155,6 +160,11 @@ Kotlin:: + [source,kotlin,role="secondary"] ---- +@Bean +fun oidcLogoutHandler(): OidcBackChannelLogoutHandler { + return OidcBackChannelLogoutHandler() +} + @Bean open fun filterChain(http: ServerHttpSecurity): SecurityWebFilterChain { http { @@ -197,6 +207,80 @@ The overall flow for a Back-Channel logout is like this: 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 Session Logout Endpoint + +With `OidcBackChannelServerLogoutHandler` published, the session logout endpoint is `+{baseUrl}+/logout/connect/back-channel/+{registrationId}+`. + +If `OidcBackChannelServerLogoutHandler` is not wired, then the URL is `+{baseUrl}+/logout/connect/back-channel/+{registrationId}+`, which is not recommended since it requires passing a CSRF token, which can be challenging depending on the kind of repository your application uses. + +In the event that you need to customize the endpoint, you can provide the URL as follows: + + +[tabs] +====== +Java:: ++ +[source=java,role="primary"] +---- +http + // ... + .oidcLogout((oidc) -> oidc + .backChannel((backChannel) -> backChannel + .logoutUri("http://localhost:9000/logout/connect/back-channel/+{registrationId}+") + ) + ); +---- + +Kotlin:: ++ +[source=kotlin,role="secondary"] +---- +http { + oidcLogout { + backChannel { + logoutUri = "http://localhost:9000/logout/connect/back-channel/+{registrationId}+" + } + } +} +---- +====== + +=== Customizing the Session Logout Cookie Name + +By default, the session logout endpoint uses the `JSESSIONID` cookie to correlate the session to the corresponding `OidcSessionInformation`. + +However, the default cookie name in Spring Session is `SESSION`. + +You can configure Spring Session's cookie name in the DSL like so: + +[tabs] +====== +Java:: ++ +[source=java,role="primary"] +---- +@Bean +OidcBackChannelServerLogoutHandler oidcLogoutHandler(ReactiveOidcSessionRegistry sessionRegistry) { + OidcBackChannelServerLogoutHandler logoutHandler = new OidcBackChannelServerLogoutHandler(sessionRegistry); + logoutHandler.setSessionCookieName("SESSION"); + return logoutHandler; +} +---- + +Kotlin:: ++ +[source=kotlin,role="secondary"] +---- +@Bean +open fun oidcLogoutHandler(val sessionRegistry: ReactiveOidcSessionRegistry): OidcBackChannelServerLogoutHandler { + val logoutHandler = OidcBackChannelServerLogoutHandler(sessionRegistry) + logoutHandler.setSessionCookieName("SESSION") + return logoutHandler +} +---- +====== + +[[oidc-backchannel-logout-session-registry]] === Customizing the OIDC Provider Session Registry By default, Spring Security stores in-memory all links between the OIDC Provider session and the Client session. diff --git a/docs/modules/ROOT/pages/servlet/oauth2/login/logout.adoc b/docs/modules/ROOT/pages/servlet/oauth2/login/logout.adoc index 32577615ed..9edd4492bd 100644 --- a/docs/modules/ROOT/pages/servlet/oauth2/login/logout.adoc +++ b/docs/modules/ROOT/pages/servlet/oauth2/login/logout.adoc @@ -136,6 +136,11 @@ Java:: + [source,java,role="primary"] ---- +@Bean +OidcBackChannelLogoutHandler oidcLogoutHandler() { + return new OidcBackChannelLogoutHandler(); +} + @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http @@ -154,6 +159,11 @@ Kotlin:: + [source,kotlin,role="secondary"] ---- +@Bean +fun oidcLogoutHandler(): OidcBackChannelLogoutHandler { + return OidcBackChannelLogoutHandler() +} + @Bean open fun filterChain(http: HttpSecurity): SecurityFilterChain { http { @@ -223,6 +233,87 @@ The overall flow for a Back-Channel logout is like this: 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. +One notable part of this architecture's implementation is that it propagates the incoming back-channel request internally for each corresponding session. +Initially, this may seem unnecessary. +However, recall that the Servlet API does not give direct access to the `HttpSession` store. +By making an internal logout call, the corresponding session can now be validated. + +Additionally, forging a logout call internally allows for each set of ``LogoutHandler``s to be run against that session and corresponding `SecurityContext`. + +=== Customizing the Session Logout Endpoint + +With `OidcBackChannelLogoutHandler` published, the session logout endpoint is `+{baseUrl}+/logout/connect/back-channel/+{registrationId}+`. + +If `OidcBackChannelLogoutHandler` is not wired, then the URL is `+{baseUrl}+/logout/connect/back-channel/+{registrationId}+`, which is not recommended since it requires passing a CSRF token, which can be challenging depending on the kind of repository your application uses. + +In the event that you need to customize the endpoint, you can provide the URL as follows: + + +[tabs] +====== +Java:: ++ +[source=java,role="primary"] +---- +http + // ... + .oidcLogout((oidc) -> oidc + .backChannel((backChannel) -> backChannel + .logoutUri("http://localhost:9000/logout/connect/back-channel/+{registrationId}+") + ) + ); +---- + +Kotlin:: ++ +[source=kotlin,role="secondary"] +---- +http { + oidcLogout { + backChannel { + logoutUri = "http://localhost:9000/logout/connect/back-channel/+{registrationId}+" + } + } +} +---- +====== + +=== Customizing the Session Logout Cookie Name + +By default, the session logout endpoint uses the `JSESSIONID` cookie to correlate the session to the corresponding `OidcSessionInformation`. + +However, the default cookie name in Spring Session is `SESSION`. + +You can configure Spring Session's cookie name in the DSL like so: + +[tabs] +====== +Java:: ++ +[source=java,role="primary"] +---- +@Bean +OidcBackChannelLogoutHandler oidcLogoutHandler(OidcSessionRegistry sessionRegistry) { + OidcBackChannelLogoutHandler logoutHandler = new OidcBackChannelLogoutHandler(oidcSessionRegistry); + logoutHandler.setSessionCookieName("SESSION"); + return logoutHandler; +} +---- + +Kotlin:: ++ +[source=kotlin,role="secondary"] +---- +@Bean +open fun oidcLogoutHandler(val sessionRegistry: OidcSessionRegistry): OidcBackChannelLogoutHandler { + val logoutHandler = OidcBackChannelLogoutHandler(sessionRegistry) + logoutHandler.setSessionCookieName("SESSION") + return logoutHandler +} +---- +====== + +[[oidc-backchannel-logout-session-registry]] === Customizing the OIDC Provider Session Registry By default, Spring Security stores in-memory all links between the OIDC Provider session and the Client session.