diff --git a/docs/modules/ROOT/pages/servlet/oauth2/login/logout.adoc b/docs/modules/ROOT/pages/servlet/oauth2/login/logout.adoc index 9edd4492bdb..e26d77b8661 100644 --- a/docs/modules/ROOT/pages/servlet/oauth2/login/logout.adoc +++ b/docs/modules/ROOT/pages/servlet/oauth2/login/logout.adoc @@ -122,6 +122,12 @@ class OAuth2LoginSecurityConfig { If used, the application's base URL, such as `https://app.example.org`, replaces it at request time. ==== +[NOTE] +==== +By default, `OidcClientInitiatedLogoutSuccessHandler` 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 `FormPostRedirectStrategy`, for example with `OidcClientInitiatedLogoutSuccessHandler.setRedirectStrategy(new FormPostRedirectStrategy())`. +==== + [[configure-provider-initiated-oidc-logout]] == OpenID Connect 1.0 Back-Channel Logout diff --git a/web/src/main/java/org/springframework/security/web/FormPostRedirectStrategy.java b/web/src/main/java/org/springframework/security/web/FormPostRedirectStrategy.java new file mode 100644 index 00000000000..6445c078f65 --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/FormPostRedirectStrategy.java @@ -0,0 +1,116 @@ +/* + * 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; + +import java.io.IOException; +import java.util.Base64; +import java.util.List; +import java.util.Map.Entry; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.security.crypto.keygen.Base64StringKeyGenerator; +import org.springframework.security.crypto.keygen.StringKeyGenerator; +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 Craig Andrews + * @author Steve Riesenberg + * @since 6.5 + */ +public final class FormPostRedirectStrategy implements RedirectStrategy { + + 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 void sendRedirect(final HttpServletRequest request, final HttpServletResponse response, final String url) + throws IOException { + final UriComponentsBuilder uriComponentsBuilder = UriComponentsBuilder.fromUriString(url); + + final StringBuilder hiddenInputsHtmlBuilder = new StringBuilder(); + for (final 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 + + response.setStatus(HttpStatus.OK.value()); + response.setContentType(MediaType.TEXT_HTML_VALUE); + response.setHeader(CONTENT_SECURITY_POLICY_HEADER, policyDirective); + response.getWriter().write(html); + response.getWriter().flush(); + } + +} diff --git a/web/src/test/java/org/springframework/security/web/FormPostRedirectStrategyTests.java b/web/src/test/java/org/springframework/security/web/FormPostRedirectStrategyTests.java new file mode 100644 index 00000000000..a3be15cb77a --- /dev/null +++ b/web/src/test/java/org/springframework/security/web/FormPostRedirectStrategyTests.java @@ -0,0 +1,115 @@ +/* + * 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; + +import java.io.IOException; + +import org.assertj.core.api.ThrowingConsumer; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.mock.web.MockServletContext; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; + +import static org.assertj.core.api.Assertions.assertThat; + +public class FormPostRedirectStrategyTests { + + private static final String POLICY_DIRECTIVE_PATTERN = "script-src 'nonce-(.+)'"; + + private FormPostRedirectStrategy redirectStrategy; + + private MockHttpServletRequest request; + + private MockHttpServletResponse response; + + @BeforeEach + public void beforeEach() { + this.redirectStrategy = new FormPostRedirectStrategy(); + final MockServletContext mockServletContext = new MockServletContext(); + mockServletContext.setContextPath("/contextPath"); + // the request URL doesn't matter + this.request = MockMvcRequestBuilders.get("https://localhost").buildRequest(mockServletContext); + this.response = new MockHttpServletResponse(); + } + + @Test + public void absoluteUrlNoParametersRedirect() throws IOException { + this.redirectStrategy.sendRedirect(this.request, this.response, "https://example.com"); + assertThat(this.response.getStatus()).isEqualTo(HttpStatus.OK.value()); + assertThat(this.response.getContentType()).isEqualTo(MediaType.TEXT_HTML_VALUE); + assertThat(this.response.getContentAsString()).contains("action=\"https://example.com\""); + assertThat(this.response).satisfies(hasScriptSrcNonce()); + } + + @Test + public void rootRelativeUrlNoParametersRedirect() throws IOException { + this.redirectStrategy.sendRedirect(this.request, this.response, "/test"); + assertThat(this.response.getStatus()).isEqualTo(HttpStatus.OK.value()); + assertThat(this.response.getContentType()).isEqualTo(MediaType.TEXT_HTML_VALUE); + assertThat(this.response.getContentAsString()).contains("action=\"/test\""); + assertThat(this.response).satisfies(hasScriptSrcNonce()); + } + + @Test + public void relativeUrlNoParametersRedirect() throws IOException { + this.redirectStrategy.sendRedirect(this.request, this.response, "test"); + assertThat(this.response.getStatus()).isEqualTo(HttpStatus.OK.value()); + assertThat(this.response.getContentType()).isEqualTo(MediaType.TEXT_HTML_VALUE); + assertThat(this.response.getContentAsString()).contains("action=\"test\""); + assertThat(this.response).satisfies(hasScriptSrcNonce()); + } + + @Test + public void absoluteUrlWithFragmentRedirect() throws IOException { + this.redirectStrategy.sendRedirect(this.request, this.response, "https://example.com/path#fragment"); + assertThat(this.response.getStatus()).isEqualTo(HttpStatus.OK.value()); + assertThat(this.response.getContentType()).isEqualTo(MediaType.TEXT_HTML_VALUE); + assertThat(this.response.getContentAsString()).contains("action=\"https://example.com/path#fragment\""); + assertThat(this.response).satisfies(hasScriptSrcNonce()); + } + + @Test + public void absoluteUrlWithQueryParamsRedirect() throws IOException { + this.redirectStrategy.sendRedirect(this.request, this.response, + "https://example.com/path?param1=one¶m2=two#fragment"); + assertThat(this.response.getStatus()).isEqualTo(HttpStatus.OK.value()); + assertThat(this.response.getContentType()).isEqualTo(MediaType.TEXT_HTML_VALUE); + assertThat(this.response.getContentAsString()).contains("action=\"https://example.com/path#fragment\""); + assertThat(this.response.getContentAsString()) + .contains(""); + assertThat(this.response.getContentAsString()) + .contains(""); + assertThat(this.response).satisfies(hasScriptSrcNonce()); + } + + private ThrowingConsumer hasScriptSrcNonce() { + return (response) -> { + final String policyDirective = response.getHeader("Content-Security-Policy"); + assertThat(policyDirective).isNotEmpty(); + assertThat(policyDirective).matches(POLICY_DIRECTIVE_PATTERN); + + final String nonce = policyDirective.replaceFirst(POLICY_DIRECTIVE_PATTERN, "$1"); + assertThat(response.getContentAsString()).contains("