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

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