Skip to content

Commit b129a74

Browse files
committed
Add Reactive Support for Internal Call to Back-Channel Endpoint
Issue gh-13841
1 parent 6fbab2a commit b129a74

File tree

6 files changed

+245
-14
lines changed

6 files changed

+245
-14
lines changed

config/src/main/java/org/springframework/security/config/web/server/OidcBackChannelLogoutAuthentication.java

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020

2121
import org.springframework.security.authentication.AbstractAuthenticationToken;
2222
import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcLogoutToken;
23+
import org.springframework.security.oauth2.client.registration.ClientRegistration;
2324

2425
/**
2526
* An {@link org.springframework.security.core.Authentication} implementation that
@@ -37,13 +38,16 @@ class OidcBackChannelLogoutAuthentication extends AbstractAuthenticationToken {
3738

3839
private final OidcLogoutToken logoutToken;
3940

41+
private final ClientRegistration clientRegistration;
42+
4043
/**
4144
* Construct an {@link OidcBackChannelLogoutAuthentication}
4245
* @param logoutToken a deserialized, verified OIDC Logout Token
4346
*/
44-
OidcBackChannelLogoutAuthentication(OidcLogoutToken logoutToken) {
47+
OidcBackChannelLogoutAuthentication(OidcLogoutToken logoutToken, ClientRegistration clientRegistration) {
4548
super(Collections.emptyList());
4649
this.logoutToken = logoutToken;
50+
this.clientRegistration = clientRegistration;
4751
setAuthenticated(true);
4852
}
4953

@@ -63,4 +67,8 @@ public OidcLogoutToken getCredentials() {
6367
return this.logoutToken;
6468
}
6569

70+
ClientRegistration getClientRegistration() {
71+
return this.clientRegistration;
72+
}
73+
6674
}

config/src/main/java/org/springframework/security/config/web/server/OidcBackChannelLogoutReactiveAuthenticationManager.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ public Mono<Authentication> authenticate(Authentication authentication) throws A
8080
.map((jwt) -> OidcLogoutToken.withTokenValue(logoutToken)
8181
.claims((claims) -> claims.putAll(jwt.getClaims()))
8282
.build())
83-
.map(OidcBackChannelLogoutAuthentication::new);
83+
.map((oidcLogoutToken) -> new OidcBackChannelLogoutAuthentication(oidcLogoutToken, registration));
8484
}
8585

8686
private Mono<Jwt> decode(ClientRegistration registration, String token) {

config/src/main/java/org/springframework/security/config/web/server/OidcBackChannelServerLogoutHandler.java

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,9 @@
4343
import org.springframework.security.web.server.WebFilterExchange;
4444
import org.springframework.security.web.server.authentication.logout.ServerLogoutHandler;
4545
import org.springframework.util.Assert;
46+
import org.springframework.util.LinkedMultiValueMap;
47+
import org.springframework.util.MultiValueMap;
48+
import org.springframework.web.reactive.function.BodyInserters;
4649
import org.springframework.web.reactive.function.client.WebClient;
4750
import org.springframework.web.util.UriComponents;
4851
import org.springframework.web.util.UriComponentsBuilder;
@@ -84,7 +87,7 @@ public Mono<Void> logout(WebFilterExchange exchange, Authentication authenticati
8487
AtomicInteger invalidatedCount = new AtomicInteger(0);
8588
return this.sessionRegistry.removeSessionInformation(token.getPrincipal()).concatMap((session) -> {
8689
totalCount.incrementAndGet();
87-
return eachLogout(exchange, session).flatMap((response) -> {
90+
return eachLogout(exchange, session, token).flatMap((response) -> {
8891
invalidatedCount.incrementAndGet();
8992
return Mono.empty();
9093
}).onErrorResume((ex) -> {
@@ -105,17 +108,26 @@ public Mono<Void> logout(WebFilterExchange exchange, Authentication authenticati
105108
});
106109
}
107110

108-
private Mono<ResponseEntity<Void>> eachLogout(WebFilterExchange exchange, OidcSessionInformation session) {
111+
private Mono<ResponseEntity<Void>> eachLogout(WebFilterExchange exchange, OidcSessionInformation session,
112+
OidcBackChannelLogoutAuthentication token) {
109113
HttpHeaders headers = new HttpHeaders();
110114
headers.add(HttpHeaders.COOKIE, this.sessionCookieName + "=" + session.getSessionId());
111115
for (Map.Entry<String, String> credential : session.getAuthorities().entrySet()) {
112116
headers.add(credential.getKey(), credential.getValue());
113117
}
114-
String logout = computeLogoutEndpoint(exchange.getExchange().getRequest());
115-
return this.web.post().uri(logout).headers((h) -> h.putAll(headers)).retrieve().toBodilessEntity();
118+
String logout = computeLogoutEndpoint(exchange.getExchange().getRequest(), token);
119+
MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
120+
body.add("logout_token", token.getPrincipal().getTokenValue());
121+
body.add("_spring_security_internal_logout", "true");
122+
return this.web.post()
123+
.uri(logout)
124+
.headers((h) -> h.putAll(headers))
125+
.body(BodyInserters.fromFormData(body))
126+
.retrieve()
127+
.toBodilessEntity();
116128
}
117129

118-
String computeLogoutEndpoint(ServerHttpRequest request) {
130+
String computeLogoutEndpoint(ServerHttpRequest request, OidcBackChannelLogoutAuthentication token) {
119131
// @formatter:off
120132
UriComponents uriComponents = UriComponentsBuilder.fromUri(request.getURI())
121133
.replacePath(request.getPath().contextPath().value())
@@ -137,6 +149,9 @@ String computeLogoutEndpoint(ServerHttpRequest request) {
137149
int port = uriComponents.getPort();
138150
uriVariables.put("basePort", (port == -1) ? "" : ":" + port);
139151

152+
String registrationId = token.getClientRegistration().getRegistrationId();
153+
uriVariables.put("registrationId", registrationId);
154+
140155
return UriComponentsBuilder.fromUriString(this.logoutUri)
141156
.buildAndExpand(uriVariables)
142157
.toUriString();

config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java

Lines changed: 156 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5539,7 +5539,9 @@ private ServerLogoutHandler logoutHandler() {
55395539
* @return the {@link OidcLogoutConfigurer.BackChannelLogoutConfigurer} for
55405540
* further customizations
55415541
* @since 6.2.4
5542+
* @deprecated Please use {@link #sessionLogout} instead
55425543
*/
5544+
@Deprecated
55435545
public BackChannelLogoutConfigurer logoutUri(String logoutUri) {
55445546
this.logoutHandler = () -> {
55455547
OidcBackChannelServerLogoutHandler logoutHandler = new OidcBackChannelServerLogoutHandler();
@@ -5550,13 +5552,166 @@ public BackChannelLogoutConfigurer logoutUri(String logoutUri) {
55505552
return this;
55515553
}
55525554

5555+
/**
5556+
* Configure what and how per-session logout will be performed.
5557+
*
5558+
* <p>
5559+
* This overrides any value given to {@link #logoutUri(String)}
5560+
*
5561+
* <p>
5562+
* By default, the resulting {@link LogoutHandler} will {@code POST} the
5563+
* session cookie and OIDC logout token back to the original back-channel
5564+
* logout endpoint.
5565+
*
5566+
* <p>
5567+
* Using this method changes the underlying default that {@code POST}s the
5568+
* session cookie and CSRF token to your application's {@code /logout}
5569+
* endpoint. As such, it is recommended to call this instead of accepting the
5570+
* {@code /logout} default as this does not require any special CSRF
5571+
* configuration, even if you don't require other changes.
5572+
*
5573+
* <p>
5574+
* For example, configuring Back-Channel Logout in the following way:
5575+
*
5576+
* <pre>
5577+
* http
5578+
* .oidcLogout((oidc) -> oidc
5579+
* .backChannel((backChannel) -> backChannel
5580+
* .sessionLogout(Customizer.withDefaults())
5581+
* )
5582+
* );
5583+
* </pre>
5584+
*
5585+
* will make so that the per-session logout invocation no longer requires
5586+
* special CSRF configurations.
5587+
*
5588+
* <p>
5589+
* By default, the URI is set to
5590+
* {@code {baseScheme}://localhost{basePort}/logout/connect/back-channel/{registrationId}},
5591+
* which is simply an internal version of the same endpoint exposed to your
5592+
* Back-Channel services. You can use {@link SessionLogoutConfigurer#uri} to
5593+
* alter the scheme, server name, or port in the {@code Host} header to
5594+
* accommodate how your application would address itself internally.
5595+
*
5596+
* <p>
5597+
* For example, if the way your application would internally call itself is on
5598+
* a different scheme and port than incoming traffic, you can configure the
5599+
* endpoint in the following way:
5600+
*
5601+
* <pre>
5602+
* http
5603+
* .oidcLogout((oidc) -&gt; oidc
5604+
* .backChannel((backChannel) -&gt; backChannel
5605+
* .sessionLogout((logout) -&gt; logout
5606+
* .uri("http://localhost:9000/logout/connect/back-channel/{registrationId}")
5607+
* )
5608+
* )
5609+
* );
5610+
* </pre>
5611+
* @param sessionLogout a {@link Customizer} for configuring how to log out of
5612+
* each individual session
5613+
* @return {@link OidcLogoutConfigurer.BackChannelLogoutConfigurer} for
5614+
* further customizations
5615+
* @since 6.4
5616+
*/
5617+
public BackChannelLogoutConfigurer sessionLogout(Customizer<SessionLogoutConfigurer> sessionLogout) {
5618+
this.logoutHandler = () -> {
5619+
SessionLogoutConfigurer logoutHandler = new SessionLogoutConfigurer();
5620+
sessionLogout.customize(logoutHandler);
5621+
return logoutHandler.configure();
5622+
};
5623+
return this;
5624+
}
5625+
55535626
void configure(ServerHttpSecurity http) {
55545627
OidcBackChannelLogoutWebFilter filter = new OidcBackChannelLogoutWebFilter(authenticationConverter(),
55555628
authenticationManager());
5556-
filter.setLogoutHandler(this.logoutHandler.get());
5629+
ServerLogoutHandler oidcLogout = this.logoutHandler.get();
5630+
ServerLogoutHandler sessionLogout = new SecurityContextServerLogoutHandler();
5631+
LogoutSpec logout = ServerHttpSecurity.this.logout;
5632+
if (logout != null) {
5633+
sessionLogout = new DelegatingServerLogoutHandler(logout.logoutHandlers);
5634+
}
5635+
filter.setLogoutHandler(new EitherLogoutHandler(oidcLogout, sessionLogout));
55575636
http.addFilterBefore(filter, SecurityWebFiltersOrder.CSRF);
55585637
}
55595638

5639+
private static final class EitherLogoutHandler implements ServerLogoutHandler {
5640+
5641+
private final ServerLogoutHandler left;
5642+
5643+
private final ServerLogoutHandler right;
5644+
5645+
EitherLogoutHandler(ServerLogoutHandler left, ServerLogoutHandler right) {
5646+
this.left = left;
5647+
this.right = right;
5648+
}
5649+
5650+
@Override
5651+
public Mono<Void> logout(WebFilterExchange exchange, Authentication authentication) {
5652+
return exchange.getExchange().getFormData().flatMap((data) -> {
5653+
if (data.getFirst("_spring_security_internal_logout") == null) {
5654+
return this.left.logout(exchange, authentication);
5655+
}
5656+
else {
5657+
return this.right.logout(exchange, authentication);
5658+
}
5659+
});
5660+
}
5661+
5662+
}
5663+
5664+
/**
5665+
* A configurer for logging out each internal session as identified by the
5666+
* external session listed in the OIDC Logout Token.
5667+
*
5668+
* @author Josh Cummings
5669+
* @since 6.4
5670+
*/
5671+
public final class SessionLogoutConfigurer {
5672+
5673+
private String logoutUri = "{baseScheme}://localhost{basePort}/logout/connect/back-channel/{registrationId}";
5674+
5675+
private SessionLogoutConfigurer() {
5676+
5677+
}
5678+
5679+
/**
5680+
* Use this URI to log out a specific session indicated by the OIDC Logout
5681+
* Token.
5682+
*
5683+
* <p>
5684+
* Defaults to pointing back to the original Back-Channel OIDC endpoint,
5685+
* now including the session cookie value that corresponds to the
5686+
* {@code sid} referenced in the OIDC Logout Token.
5687+
*
5688+
* <p>
5689+
* This default value is
5690+
* {@code {baseScheme}://localhost{basePort}/logout/connect/back-channel/{registrationId}}
5691+
*
5692+
* <p>
5693+
* If needed for backward compatibility, you can also set this to a
5694+
* different logout endpoint, like the Spring Security logout endpoint:
5695+
* {@code {baseScheme}://localhost{basePort}/logout}.
5696+
* @param uri the URI to invoke to log out specific sessions
5697+
* @return the
5698+
* {@link OidcLogoutConfigurer.BackChannelLogoutConfigurer.SessionLogoutConfigurer}
5699+
* for further customizations
5700+
*/
5701+
public SessionLogoutConfigurer uri(String uri) {
5702+
this.logoutUri = uri;
5703+
return this;
5704+
}
5705+
5706+
private ServerLogoutHandler configure() {
5707+
OidcBackChannelServerLogoutHandler logoutHandler = new OidcBackChannelServerLogoutHandler();
5708+
logoutHandler.setSessionRegistry(OidcLogoutSpec.this.getSessionRegistry());
5709+
logoutHandler.setLogoutUri(this.logoutUri);
5710+
return logoutHandler;
5711+
}
5712+
5713+
}
5714+
55605715
}
55615716

55625717
}

config/src/test/java/org/springframework/security/config/web/server/OidcBackChannelServerLogoutHandlerTests.java

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
import org.junit.jupiter.api.Test;
2020

2121
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
22+
import org.springframework.security.oauth2.client.oidc.authentication.logout.TestOidcLogoutTokens;
23+
import org.springframework.security.oauth2.client.registration.TestClientRegistrations;
2224

2325
import static org.assertj.core.api.Assertions.assertThat;
2426

@@ -27,15 +29,19 @@
2729
*/
2830
public class OidcBackChannelServerLogoutHandlerTests {
2931

32+
private final OidcBackChannelLogoutAuthentication token = new OidcBackChannelLogoutAuthentication(
33+
TestOidcLogoutTokens.withSubject("issuer", "subject").build(),
34+
TestClientRegistrations.clientRegistration().build());
35+
3036
// gh-14553
3137
@Test
3238
public void computeLogoutEndpointWhenDifferentHostnameThenLocalhost() {
3339
OidcBackChannelServerLogoutHandler logoutHandler = new OidcBackChannelServerLogoutHandler();
3440
MockServerHttpRequest request = MockServerHttpRequest
3541
.get("https://host.docker.internal:8090/back-channel/logout")
3642
.build();
37-
String endpoint = logoutHandler.computeLogoutEndpoint(request);
38-
assertThat(endpoint).isEqualTo("https://localhost:8090/logout");
43+
String endpoint = logoutHandler.computeLogoutEndpoint(request, this.token);
44+
assertThat(endpoint).startsWith("https://localhost:8090/logout");
3945
}
4046

4147
@Test
@@ -45,8 +51,8 @@ public void computeLogoutEndpointWhenUsingBaseUrlTemplateThenServerName() {
4551
MockServerHttpRequest request = MockServerHttpRequest
4652
.get("http://host.docker.internal:8090/back-channel/logout")
4753
.build();
48-
String endpoint = logoutHandler.computeLogoutEndpoint(request);
49-
assertThat(endpoint).isEqualTo("http://host.docker.internal:8090/logout");
54+
String endpoint = logoutHandler.computeLogoutEndpoint(request, this.token);
55+
assertThat(endpoint).startsWith("http://host.docker.internal:8090/logout");
5056
}
5157

5258
// gh-14609
@@ -55,8 +61,8 @@ public void computeLogoutEndpointWhenLogoutUriThenUses() {
5561
OidcBackChannelServerLogoutHandler logoutHandler = new OidcBackChannelServerLogoutHandler();
5662
logoutHandler.setLogoutUri("http://localhost:8090/logout");
5763
MockServerHttpRequest request = MockServerHttpRequest.get("https://server-one.com/back-channel/logout").build();
58-
String endpoint = logoutHandler.computeLogoutEndpoint(request);
59-
assertThat(endpoint).isEqualTo("http://localhost:8090/logout");
64+
String endpoint = logoutHandler.computeLogoutEndpoint(request, this.token);
65+
assertThat(endpoint).startsWith("http://localhost:8090/logout");
6066
}
6167

6268
}

config/src/test/java/org/springframework/security/config/web/server/OidcLogoutSpecTests.java

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,29 @@ void logoutWhenRemoteLogoutUriThenUses() {
268268
this.test.get().uri("/token/logout").cookie("SESSION", one).exchange().expectStatus().isOk();
269269
}
270270

271+
@Test
272+
void logoutWhenSelfRemoteLogoutUriThenUses() {
273+
this.spring.register(WebServerConfig.class, OidcProviderConfig.class, SelfLogoutUriConfig.class).autowire();
274+
String registrationId = this.clientRegistration.getRegistrationId();
275+
String sessionId = login();
276+
String logoutToken = this.test.get()
277+
.uri("/token/logout")
278+
.cookie("SESSION", sessionId)
279+
.exchange()
280+
.expectStatus()
281+
.isOk()
282+
.returnResult(String.class)
283+
.getResponseBody()
284+
.blockFirst();
285+
this.test.post()
286+
.uri(this.web.url("/logout/connect/back-channel/" + registrationId).toString())
287+
.body(BodyInserters.fromFormData("logout_token", logoutToken))
288+
.exchange()
289+
.expectStatus()
290+
.isOk();
291+
this.test.get().uri("/token/logout").cookie("SESSION", sessionId).exchange().expectStatus().isUnauthorized();
292+
}
293+
271294
@Test
272295
void logoutWhenRemoteLogoutFailsThenReportsPartialLogout() {
273296
this.spring.register(WebServerConfig.class, OidcProviderConfig.class, WithBrokenLogoutConfig.class).autowire();
@@ -444,6 +467,30 @@ SecurityWebFilterChain filters(ServerHttpSecurity http) throws Exception {
444467

445468
}
446469

470+
@Configuration
471+
@EnableWebFluxSecurity
472+
@Import(RegistrationConfig.class)
473+
static class SelfLogoutUriConfig {
474+
475+
@Bean
476+
@Order(1)
477+
SecurityWebFilterChain filters(ServerHttpSecurity http) throws Exception {
478+
// @formatter:off
479+
http
480+
.authorizeExchange((authorize) -> authorize.anyExchange().authenticated())
481+
.oauth2Login(Customizer.withDefaults())
482+
.oidcLogout((oidc) -> oidc
483+
.backChannel((backchannel) -> backchannel
484+
.sessionLogout(Customizer.withDefaults())
485+
)
486+
);
487+
// @formatter:on
488+
489+
return http.build();
490+
}
491+
492+
}
493+
447494
@Configuration
448495
@EnableWebFluxSecurity
449496
@Import(RegistrationConfig.class)

0 commit comments

Comments
 (0)