Expose OidcBackChannelLogoutHandler

This component already uses by default a URI that doesn't require
a CSRF token and aalready allows for configuring a cookie name.

So, by making it public and configurable in the DSL, both
of these tickets quite naturally close.

Closes gh-13841
Closes gh-14904
This commit is contained in:
Josh Cummings 2024-07-22 16:58:49 -06:00
parent 2d4c498c3b
commit 8bb5875595
22 changed files with 910 additions and 137 deletions

View File

@ -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;
}
}

View File

@ -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);
}
/**

View File

@ -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;
}
}

View File

@ -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 <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 {
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<String, String> 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<String, String> 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;
}

View File

@ -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<B extends HttpSecurityBuilder<B>>
}
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<B extends HttpSecurityBuilder<B>>
*/
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.
*
* <p>
* This overrides any value given to {@link #logoutUri(String)}
*
* <p>
* By default, the resulting {@link LogoutHandler} will {@code POST} the session
* cookie and OIDC logout token back to the original back-channel logout endpoint.
*
* <p>
* 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.
*
* <p>
* For example, configuring Back-Channel Logout in the following way:
*
* <pre>
* http
* .oidcLogout((oidc) -&gt; oidc
* .backChannel((backChannel) -&gt; backChannel
* .logoutHandler(new OidcBackChannelLogoutHandler())
* )
* );
* </pre>
*
* will make so that the per-session logout invocation no longer requires special
* CSRF configurations.
*
* <p>
* 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.
*
* <p>
* 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:
*
* <pre>
* http
* .oidcLogout((oidc) -&gt; oidc
* .backChannel((backChannel) -&gt; backChannel
* .logoutHandler("http://localhost:9000/logout/connect/back-channel/{registrationId}")
* )
* );
* </pre>
*
* <p>
* You can also publish it as a {@code @Bean} as follows:
*
* <pre>
* &commat;Bean
* OidcBackChannelLogoutHandler oidcLogoutHandler(OidcSessionRegistry sessionRegistry) {
* OidcBackChannelLogoutHandler logoutHandler = new OidcBackChannelLogoutHandler(sessionRegistry);
* logoutHandler.setSessionCookieName("SESSION");
* return logoutHandler;
* }
* </pre>
*
* 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<B> 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> 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);
}
}
}
}
}

View File

@ -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;
}
}

View File

@ -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<Jwt> decode(ClientRegistration registration, String token) {

View File

@ -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;
}
}

View File

@ -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 <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 {
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<Void> 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<ResponseEntity<Void>> eachLogout(WebFilterExchange exchange, OidcSessionInformation session) {
private Mono<ResponseEntity<Void>> eachLogout(WebFilterExchange exchange, OidcSessionInformation session,
OidcBackChannelLogoutAuthentication token) {
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 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<String, String> 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;
}

View File

@ -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 {
*
* <p>
* 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.
*
* <p>
* 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.
*
* <p>
* This overrides any value given to {@link #logoutUri(String)}
*
* <p>
* By default, the resulting {@link LogoutHandler} will {@code POST} the
* session cookie and OIDC logout token back to the original back-channel
* logout endpoint.
*
* <p>
* 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.
*
* <p>
* For example, configuring Back-Channel Logout in the following way:
*
* <pre>
* http
* .oidcLogout((oidc) -&gt; oidc
* .backChannel((backChannel) -&gt; backChannel
* .logoutHandler(new OidcBackChannelServerLogoutHandler())
* )
* );
* </pre>
*
* will make so that the per-session logout invocation no longer requires
* special CSRF configurations.
*
* <p>
* 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.
*
* <p>
* 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:
*
* <pre>
* http
* .oidcLogout((oidc) -&gt; oidc
* .backChannel((backChannel) -&gt; backChannel
* .logoutUri("http://localhost:9000/logout/connect/back-channel/{registrationId}")
* )
* );
* </pre>
*
* <p>
* You can also publish it as a {@code @Bean} as follows:
*
* <pre>
* &commat;Bean
* OidcBackChannelServerLogoutHandler oidcLogoutHandler() {
* OidcBackChannelServerLogoutHandler logoutHandler = new OidcBackChannelServerLogoutHandler();
* logoutHandler.setLogoutUri("http://localhost:9000/logout/connect/back-channel/{registrationId}");
* return logoutHandler;
* }
* </pre>
*
* 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<Void> 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);
}
});
}
}
}
}

View File

@ -1,3 +1,4 @@
/*
* Copyright 2002-2023 the original author or authors.
*
@ -72,4 +73,5 @@ class OidcLogoutDsl {
backChannel?.also { oidcLogout.backChannel(backChannel) }
}
}
}

View File

@ -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<HttpSecurity>.BackChannelLogoutConfigurer) -> Unit {
return { backChannel -> }
return { backChannel ->
logoutHandler?.also { backChannel.logoutHandler(logoutHandler) }
logoutUri?.also { backChannel.logoutUri(logoutUri) }
}
}
}

View File

@ -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) }
}
}
}

View File

@ -47,7 +47,9 @@ class ServerOidcLogoutDsl {
* return http {
* oauth2Login { }
* oidcLogout {
* backChannel { }
* backChannel {
* sessionLogout { }
* }
* }
* }
* }

View File

@ -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");
}
}

View File

@ -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<MockMvc> 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<RecordedRequest> assertion = (rr) -> { };
MockMvcDispatcher(ObjectProvider<MockMvc> 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<RecordedRequest> 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();
}

View File

@ -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");
}
}

View File

@ -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<WebTestClient> 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<RecordedRequest> assertion = (rr) -> { };
WebTestClientDispatcher(ObjectProvider<WebTestClient> 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<RecordedRequest> 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;
}

View File

@ -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()

View File

@ -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()

View File

@ -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.

View File

@ -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.