diff --git a/docs/modules/ROOT/pages/reactive/oauth2/login/logout.adoc b/docs/modules/ROOT/pages/reactive/oauth2/login/logout.adoc index 15ce91c39c..af11f484d5 100644 --- a/docs/modules/ROOT/pages/reactive/oauth2/login/logout.adoc +++ b/docs/modules/ROOT/pages/reactive/oauth2/login/logout.adoc @@ -123,6 +123,12 @@ class OAuth2LoginSecurityConfig { If used, the application's base URL, such as `https://app.example.org`, replaces it at request time. ==== +[NOTE] +==== +By default, `OidcClientInitiatedServerLogoutSuccessHandler` redirects to the logout URL using a standard HTTP redirect with the `GET` method. +To perform the logout using a `POST` request, set the redirect strategy to `FormPostServerRedirectStrategy`, for example with `OidcClientInitiatedServerLogoutSuccessHandler.setRedirectStrategy(new ServerFormPostRedirectStrategy())`. +==== + [[configure-provider-initiated-oidc-logout]] == OpenID Connect 1.0 Back-Channel Logout diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/web/server/logout/OidcClientInitiatedServerLogoutSuccessHandler.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/web/server/logout/OidcClientInitiatedServerLogoutSuccessHandler.java index 9d35ddc69b..4943565fca 100644 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/web/server/logout/OidcClientInitiatedServerLogoutSuccessHandler.java +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/web/server/logout/OidcClientInitiatedServerLogoutSuccessHandler.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -51,7 +51,7 @@ */ public class OidcClientInitiatedServerLogoutSuccessHandler implements ServerLogoutSuccessHandler { - private final ServerRedirectStrategy redirectStrategy = new DefaultServerRedirectStrategy(); + private ServerRedirectStrategy redirectStrategy = new DefaultServerRedirectStrategy(); private final RedirectServerLogoutSuccessHandler serverLogoutSuccessHandler = new RedirectServerLogoutSuccessHandler(); @@ -199,6 +199,17 @@ public void setRedirectUriResolver(Converter this.redirectUriResolver = redirectUriResolver; } + /** + * Set the {@link ServerRedirectStrategy} to use, default + * {@link DefaultServerRedirectStrategy} + * @param redirectStrategy {@link ServerRedirectStrategy} + * @since 6.5 + */ + public void setRedirectStrategy(ServerRedirectStrategy redirectStrategy) { + Assert.notNull(redirectStrategy, "redirectStrategy cannot be null"); + this.redirectStrategy = redirectStrategy; + } + /** * Parameters, required for redirect URI resolving. * diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/web/server/logout/OidcClientInitiatedServerLogoutSuccessHandlerTests.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/web/server/logout/OidcClientInitiatedServerLogoutSuccessHandlerTests.java index 591ef091da..682ee3819a 100644 --- a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/web/server/logout/OidcClientInitiatedServerLogoutSuccessHandlerTests.java +++ b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/web/server/logout/OidcClientInitiatedServerLogoutSuccessHandlerTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -37,14 +37,18 @@ import org.springframework.security.oauth2.client.registration.TestClientRegistrations; import org.springframework.security.oauth2.core.oidc.user.TestOidcUsers; import org.springframework.security.oauth2.core.user.TestOAuth2Users; +import org.springframework.security.web.server.ServerRedirectStrategy; import org.springframework.security.web.server.WebFilterExchange; import org.springframework.web.server.ServerWebExchange; import org.springframework.web.server.WebFilterChain; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; /** * Tests for {@link OidcClientInitiatedServerLogoutSuccessHandler} @@ -219,6 +223,27 @@ public void logoutWhenCustomRedirectUriResolverSetThenRedirects() { assertThat(redirectedUrl(this.exchange)).isEqualTo("https://test.com"); } + @Test + public void setRedirectStrategyWhenGivenNullThenThrowsException() { + assertThatIllegalArgumentException().isThrownBy(() -> this.handler.setRedirectStrategy(null)); + } + + @Test + public void logoutWhenCustomRedirectStrategySetThenCustomRedirectStrategyUsed() { + ServerRedirectStrategy redirectStrategy = mock(ServerRedirectStrategy.class); + given(redirectStrategy.sendRedirect(any(), any())).willReturn(Mono.empty()); + OAuth2AuthenticationToken token = new OAuth2AuthenticationToken(TestOidcUsers.create(), + AuthorityUtils.NO_AUTHORITIES, this.registration.getRegistrationId()); + WebFilterExchange filterExchange = new WebFilterExchange(this.exchange, this.chain); + given(this.exchange.getRequest()) + .willReturn(MockServerHttpRequest.get("/").queryParam("location", "https://test.com").build()); + this.handler.setRedirectStrategy(redirectStrategy); + + this.handler.onLogoutSuccess(filterExchange, token).block(); + + verify(redirectStrategy, times(1)).sendRedirect(any(), any()); + } + private String redirectedUrl(ServerWebExchange exchange) { return exchange.getResponse().getHeaders().getFirst("Location"); } diff --git a/web/src/main/java/org/springframework/security/web/server/FormPostServerRedirectStrategy.java b/web/src/main/java/org/springframework/security/web/server/FormPostServerRedirectStrategy.java new file mode 100644 index 0000000000..b6f2711ea0 --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/server/FormPostServerRedirectStrategy.java @@ -0,0 +1,123 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.web.server; + +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.List; +import java.util.Map; + +import reactor.core.publisher.Mono; + +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferFactory; +import org.springframework.core.io.buffer.DataBufferUtils; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.server.reactive.ServerHttpResponse; +import org.springframework.security.crypto.keygen.Base64StringKeyGenerator; +import org.springframework.security.crypto.keygen.StringKeyGenerator; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.util.HtmlUtils; +import org.springframework.web.util.UriComponentsBuilder; + +/** + * Redirect using an auto-submitting HTML form using the POST method. All query params + * provided in the URL are changed to inputs in the form so they are submitted as POST + * data instead of query string data. + * + * @author Max Batischev + * @author Steve Riesenberg + * @since 6.5 + */ +public final class FormPostServerRedirectStrategy implements ServerRedirectStrategy { + + private static final String CONTENT_SECURITY_POLICY_HEADER = "Content-Security-Policy"; + + private static final String REDIRECT_PAGE_TEMPLATE = """ + + + + + + + + Redirect + + +
+ {{params}} + +
+ + + + """; + + private static final String HIDDEN_INPUT_TEMPLATE = """ + + """; + + private static final StringKeyGenerator DEFAULT_NONCE_GENERATOR = new Base64StringKeyGenerator( + Base64.getUrlEncoder().withoutPadding(), 96); + + @Override + public Mono sendRedirect(ServerWebExchange exchange, URI location) { + final UriComponentsBuilder uriComponentsBuilder = UriComponentsBuilder.fromUri(location); + + final StringBuilder hiddenInputsHtmlBuilder = new StringBuilder(); + for (final Map.Entry> entry : uriComponentsBuilder.build().getQueryParams().entrySet()) { + final String name = entry.getKey(); + for (final String value : entry.getValue()) { + // @formatter:off + final String hiddenInput = HIDDEN_INPUT_TEMPLATE + .replace("{{name}}", HtmlUtils.htmlEscape(name)) + .replace("{{value}}", HtmlUtils.htmlEscape(value)); + // @formatter:on + hiddenInputsHtmlBuilder.append(hiddenInput.trim()); + } + } + + // Create the script-src policy directive for the Content-Security-Policy header + final String nonce = DEFAULT_NONCE_GENERATOR.generateKey(); + final String policyDirective = "script-src 'nonce-%s'".formatted(nonce); + + // @formatter:off + final String html = REDIRECT_PAGE_TEMPLATE + // Clear the query string as we don't want that to be part of the form action URL + .replace("{{action}}", HtmlUtils.htmlEscape(uriComponentsBuilder.query(null).build().toUriString())) + .replace("{{params}}", hiddenInputsHtmlBuilder.toString()) + .replace("{{nonce}}", HtmlUtils.htmlEscape(nonce)); + // @formatter:on + + final ServerHttpResponse response = exchange.getResponse(); + response.setStatusCode(HttpStatus.OK); + response.getHeaders().setContentType(MediaType.TEXT_HTML); + response.getHeaders().set(CONTENT_SECURITY_POLICY_HEADER, policyDirective); + + final DataBufferFactory bufferFactory = response.bufferFactory(); + final DataBuffer buffer = bufferFactory.wrap(html.getBytes(StandardCharsets.UTF_8)); + return response.writeWith(Mono.just(buffer)).doOnError((error) -> DataBufferUtils.release(buffer)); + } + +} diff --git a/web/src/test/java/org/springframework/security/web/server/FormPostServerRedirectStrategyTests.java b/web/src/test/java/org/springframework/security/web/server/FormPostServerRedirectStrategyTests.java new file mode 100644 index 0000000000..1a2124bb98 --- /dev/null +++ b/web/src/test/java/org/springframework/security/web/server/FormPostServerRedirectStrategyTests.java @@ -0,0 +1,117 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.web.server; + +import java.net.URI; + +import org.assertj.core.api.ThrowingConsumer; +import org.junit.jupiter.api.Test; + +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.http.server.reactive.MockServerHttpResponse; +import org.springframework.mock.web.server.MockServerWebExchange; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link FormPostServerRedirectStrategy}. + * + * @author Max Batischev + */ +public class FormPostServerRedirectStrategyTests { + + private static final String POLICY_DIRECTIVE_PATTERN = "script-src 'nonce-(.+)'"; + + private final ServerRedirectStrategy redirectStrategy = new FormPostServerRedirectStrategy(); + + private final MockServerHttpRequest request = MockServerHttpRequest.get("https://localhost").build(); + + private final MockServerWebExchange webExchange = MockServerWebExchange.from(this.request); + + @Test + public void redirectWhetLocationAbsoluteUriIsPresentThenRedirect() { + this.redirectStrategy.sendRedirect(this.webExchange, URI.create("https://example.com")).block(); + + MockServerHttpResponse response = this.webExchange.getResponse(); + assertThat(response.getBodyAsString().block()).contains("action=\"https://example.com\""); + assertThat(this.webExchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(this.webExchange.getResponse().getHeaders().getContentType()).isEqualTo(MediaType.TEXT_HTML); + assertThat(this.webExchange.getResponse()).satisfies(hasScriptSrcNonce()); + } + + @Test + public void redirectWhetLocationRootRelativeUriIsPresentThenRedirect() { + this.redirectStrategy.sendRedirect(this.webExchange, URI.create("/test")).block(); + + MockServerHttpResponse response = this.webExchange.getResponse(); + assertThat(response.getBodyAsString().block()).contains("action=\"/test\""); + assertThat(this.webExchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(this.webExchange.getResponse().getHeaders().getContentType()).isEqualTo(MediaType.TEXT_HTML); + assertThat(this.webExchange.getResponse()).satisfies(hasScriptSrcNonce()); + } + + @Test + public void redirectWhetLocationRelativeUriIsPresentThenRedirect() { + this.redirectStrategy.sendRedirect(this.webExchange, URI.create("test")).block(); + + MockServerHttpResponse response = this.webExchange.getResponse(); + assertThat(response.getBodyAsString().block()).contains("action=\"test\""); + assertThat(this.webExchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(this.webExchange.getResponse().getHeaders().getContentType()).isEqualTo(MediaType.TEXT_HTML); + assertThat(this.webExchange.getResponse()).satisfies(hasScriptSrcNonce()); + } + + @Test + public void redirectWhenLocationAbsoluteUriWithFragmentIsPresentThenRedirect() { + this.redirectStrategy.sendRedirect(this.webExchange, URI.create("https://example.com/path#fragment")).block(); + + MockServerHttpResponse response = this.webExchange.getResponse(); + assertThat(response.getBodyAsString().block()).contains("action=\"https://example.com/path#fragment\""); + assertThat(this.webExchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(this.webExchange.getResponse().getHeaders().getContentType()).isEqualTo(MediaType.TEXT_HTML); + assertThat(this.webExchange.getResponse()).satisfies(hasScriptSrcNonce()); + } + + @Test + public void redirectWhenLocationAbsoluteUriWithQueryParamsIsPresentThenRedirect() { + this.redirectStrategy + .sendRedirect(this.webExchange, URI.create("https://example.com/path?param1=one¶m2=two#fragment")) + .block(); + + MockServerHttpResponse response = this.webExchange.getResponse(); + String content = response.getBodyAsString().block(); + assertThat(this.webExchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(this.webExchange.getResponse().getHeaders().getContentType()).isEqualTo(MediaType.TEXT_HTML); + assertThat(content).contains("action=\"https://example.com/path#fragment\""); + assertThat(content).contains(""); + assertThat(content).contains(""); + } + + private ThrowingConsumer hasScriptSrcNonce() { + return (response) -> { + final String policyDirective = response.getHeaders().getFirst("Content-Security-Policy"); + assertThat(policyDirective).isNotEmpty(); + assertThat(policyDirective).matches(POLICY_DIRECTIVE_PATTERN); + + final String nonce = policyDirective.replaceFirst(POLICY_DIRECTIVE_PATTERN, "$1"); + assertThat(response.getBodyAsString().block()).contains("