diff --git a/docs/modules/ROOT/pages/servlet/oauth2/login/logout.adoc b/docs/modules/ROOT/pages/servlet/oauth2/login/logout.adoc
index 9edd4492bdb..f7cd752328a 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 `FormRedirectStrategy`, for example with `OidcClientInitiatedLogoutSuccessHandler.setRedirectStrategy(new FormRedirectStrategy())`.
+====
+
[[configure-provider-initiated-oidc-logout]]
== OpenID Connect 1.0 Back-Channel Logout
diff --git a/web/src/main/java/org/springframework/security/web/aot/hint/WebMvcSecurityRuntimeHints.java b/web/src/main/java/org/springframework/security/web/aot/hint/WebMvcSecurityRuntimeHints.java
index 86df3618f27..54c44930641 100644
--- a/web/src/main/java/org/springframework/security/web/aot/hint/WebMvcSecurityRuntimeHints.java
+++ b/web/src/main/java/org/springframework/security/web/aot/hint/WebMvcSecurityRuntimeHints.java
@@ -54,6 +54,11 @@ public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
hints.resources().registerResource(webauthnJavascript);
}
+ ClassPathResource redirect = new ClassPathResource("org/springframework/security/form-redirect.js");
+ if (redirect.exists()) {
+ hints.resources().registerResource(redirect);
+ }
+
}
}
diff --git a/web/src/main/java/org/springframework/security/web/authentication/ui/DefaultResourcesFilter.java b/web/src/main/java/org/springframework/security/web/authentication/ui/DefaultResourcesFilter.java
index c2c80f19bd4..2b927175020 100644
--- a/web/src/main/java/org/springframework/security/web/authentication/ui/DefaultResourcesFilter.java
+++ b/web/src/main/java/org/springframework/security/web/authentication/ui/DefaultResourcesFilter.java
@@ -111,4 +111,20 @@ public static DefaultResourcesFilter webauthn() {
new MediaType("text", "javascript", StandardCharsets.UTF_8));
}
+ /**
+ * Create an instance of {@link DefaultResourcesFilter} serving Spring Security's
+ * default webauthn javascript.
+ *
+ * The created {@link DefaultResourcesFilter} matches requests
+ * {@code HTTP GET /form-redirect.js}, and returns the default webauthn javascript at
+ * {@code org/springframework/security/form-redirect.js} with content-type
+ * {@code text/javascript;charset=UTF-8}.
+ * @return -
+ */
+ public static DefaultResourcesFilter formRedirectJavascript() {
+ return new DefaultResourcesFilter(AntPathRequestMatcher.antMatcher(HttpMethod.GET, "/form-redirect.js"),
+ new ClassPathResource("org/springframework/security/form-redirect.js"),
+ new MediaType("text", "javascript", StandardCharsets.UTF_8));
+ }
+
}
diff --git a/web/src/main/java/org/springframework/security/web/server/ui/DefaultResourcesWebFilter.java b/web/src/main/java/org/springframework/security/web/server/ui/DefaultResourcesWebFilter.java
index 7f96e6f0d98..a147e1b3019 100644
--- a/web/src/main/java/org/springframework/security/web/server/ui/DefaultResourcesWebFilter.java
+++ b/web/src/main/java/org/springframework/security/web/server/ui/DefaultResourcesWebFilter.java
@@ -98,4 +98,21 @@ public static DefaultResourcesWebFilter css() {
new MediaType("text", "css", StandardCharsets.UTF_8));
}
+ /**
+ * Create an instance of {@link DefaultResourcesWebFilter} serving Spring Security's
+ * form redirect javascript.
+ *
+ * The created {@link DefaultResourcesFilter} matches requests
+ * {@code HTTP GET /form-redirect.js}, and returns the default javascript at
+ * {@code org/springframework/security/form-redirect.js} with content-type
+ * {@code text/javascript;charset=UTF-8}.
+ * @return -
+ */
+ public static DefaultResourcesWebFilter formRedirectJavascript() {
+ return new DefaultResourcesWebFilter(
+ new PathPatternParserServerWebExchangeMatcher("/form-redirect.js", HttpMethod.GET),
+ new ClassPathResource("org/springframework/security/form-redirect.js"),
+ new MediaType("text", "javascript", StandardCharsets.UTF_8));
+ }
+
}
diff --git a/web/src/main/java/org/springframework/security/web/server/ui/FormRedirectStrategy.java b/web/src/main/java/org/springframework/security/web/server/ui/FormRedirectStrategy.java
new file mode 100644
index 00000000000..e2b1f89fe45
--- /dev/null
+++ b/web/src/main/java/org/springframework/security/web/server/ui/FormRedirectStrategy.java
@@ -0,0 +1,95 @@
+/*
+ * Copyright 2002-2023 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.ui;
+
+import java.io.IOException;
+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.web.RedirectStrategy;
+import org.springframework.web.util.UriComponentsBuilder;
+
+/**
+ * Redirect using an autosubmitting 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.
+ */
+/* default */ class FormRedirectStrategy implements RedirectStrategy {
+
+ private static final String REDIRECT_PAGE_TEMPLATE = """
+
+
+
+
+
+
+
+ Redirect
+
+
+
+
+
+
+
+
+
+ """;
+
+ private static final String HIDDEN_INPUT_TEMPLATE = """
+
+ """;
+
+ @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();
+ // inputs
+ for (final Entry> entry : uriComponentsBuilder.build().getQueryParams().entrySet()) {
+ final String name = entry.getKey();
+ for (final String value : entry.getValue()) {
+ hiddenInputsHtmlBuilder.append(HtmlTemplates.fromTemplate(HIDDEN_INPUT_TEMPLATE)
+ .withValue("name", name)
+ .withValue("value", value)
+ .render());
+ }
+ }
+
+ final String html = HtmlTemplates.fromTemplate(REDIRECT_PAGE_TEMPLATE)
+ // clear the query string as we don't want that to be part of the form action
+ // URL
+ .withValue("action", uriComponentsBuilder.query(null).build().toUriString())
+ .withRawHtml("params", hiddenInputsHtmlBuilder.toString())
+ .withValue("contextPath", request.getContextPath())
+ .render();
+ response.setStatus(HttpStatus.OK.value());
+ response.setContentType(MediaType.TEXT_HTML_VALUE);
+ response.getWriter().write(html);
+ response.getWriter().flush();
+ }
+
+}
diff --git a/web/src/main/resources/org/springframework/security/form-redirect.js b/web/src/main/resources/org/springframework/security/form-redirect.js
new file mode 100644
index 00000000000..aecae36ee0a
--- /dev/null
+++ b/web/src/main/resources/org/springframework/security/form-redirect.js
@@ -0,0 +1 @@
+document.getElementById("redirectForm").submit();
diff --git a/web/src/test/java/org/springframework/security/web/aot/hint/WebMvcSecurityRuntimeHintsTests.java b/web/src/test/java/org/springframework/security/web/aot/hint/WebMvcSecurityRuntimeHintsTests.java
index 180d10db48c..b781f7c3ab4 100644
--- a/web/src/test/java/org/springframework/security/web/aot/hint/WebMvcSecurityRuntimeHintsTests.java
+++ b/web/src/test/java/org/springframework/security/web/aot/hint/WebMvcSecurityRuntimeHintsTests.java
@@ -74,4 +74,10 @@ void webauthnJavascriptHasHints() {
.forResource("org/springframework/security/spring-security-webauthn.js")).accepts(this.hints);
}
+ @Test
+ void formRedirectJavascriptHasHints() {
+ assertThat(RuntimeHintsPredicates.resource().forResource("org/springframework/security/form-redirect.js"))
+ .accepts(this.hints);
+ }
+
}
diff --git a/web/src/test/java/org/springframework/security/web/authentication/ui/DefaultResourcesFilterTests.java b/web/src/test/java/org/springframework/security/web/authentication/ui/DefaultResourcesFilterTests.java
index e7d0eb2b230..916d828e2fb 100644
--- a/web/src/test/java/org/springframework/security/web/authentication/ui/DefaultResourcesFilterTests.java
+++ b/web/src/test/java/org/springframework/security/web/authentication/ui/DefaultResourcesFilterTests.java
@@ -94,4 +94,35 @@ void toStringPrintsPathAndResource() {
}
+ @Nested
+ class FormRedirectJavascriptFilter {
+
+ private final DefaultResourcesFilter formRedirectJavascriptFilter = DefaultResourcesFilter
+ .formRedirectJavascript();
+
+ private final MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new Object())
+ .addFilters(this.formRedirectJavascriptFilter)
+ .build();
+
+ @Test
+ void doFilterThenRender() throws Exception {
+ this.mockMvc.perform(get("/form-redirect.js"))
+ .andExpect(status().isOk())
+ .andExpect(content().contentType("text/javascript;charset=UTF-8"))
+ .andExpect(content().string(containsString("async function authenticate(")));
+ }
+
+ @Test
+ void doFilterWhenPathDoesNotMatchThenCallsThrough() throws Exception {
+ this.mockMvc.perform(get("/does-not-match")).andExpect(status().isNotFound());
+ }
+
+ @Test
+ void toStringPrintsPathAndResource() {
+ assertThat(this.formRedirectJavascriptFilter.toString()).isEqualTo(
+ "DefaultResourcesFilter [matcher=Ant [pattern='/form-redirect.js', GET], resource=org/springframework/security/form-redirect.js]");
+ }
+
+ }
+
}
diff --git a/web/src/test/java/org/springframework/security/web/server/ui/DefaultResourcesWebFilterTests.java b/web/src/test/java/org/springframework/security/web/server/ui/DefaultResourcesCssWebFilterTests.java
similarity index 98%
rename from web/src/test/java/org/springframework/security/web/server/ui/DefaultResourcesWebFilterTests.java
rename to web/src/test/java/org/springframework/security/web/server/ui/DefaultResourcesCssWebFilterTests.java
index 9b0b5c64b3c..590fe8cb635 100644
--- a/web/src/test/java/org/springframework/security/web/server/ui/DefaultResourcesWebFilterTests.java
+++ b/web/src/test/java/org/springframework/security/web/server/ui/DefaultResourcesCssWebFilterTests.java
@@ -36,7 +36,7 @@
* @author Daniel Garnier-Moiroux
* @since 6.4
*/
-class DefaultResourcesWebFilterTests {
+class DefaultResourcesCssWebFilterTests {
private final WebHandler notFoundHandler = (exchange) -> {
exchange.getResponse().setStatusCode(HttpStatus.NOT_FOUND);
diff --git a/web/src/test/java/org/springframework/security/web/server/ui/DefaultResourcesFormRedirectJavascriptWebFilterTests.java b/web/src/test/java/org/springframework/security/web/server/ui/DefaultResourcesFormRedirectJavascriptWebFilterTests.java
new file mode 100644
index 00000000000..ad8961a4a5a
--- /dev/null
+++ b/web/src/test/java/org/springframework/security/web/server/ui/DefaultResourcesFormRedirectJavascriptWebFilterTests.java
@@ -0,0 +1,77 @@
+/*
+ * Copyright 2002-2024 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.ui;
+
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+import reactor.core.publisher.Mono;
+
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
+import org.springframework.mock.web.server.MockServerWebExchange;
+import org.springframework.web.server.WebFilterChain;
+import org.springframework.web.server.WebHandler;
+import org.springframework.web.server.handler.DefaultWebFilterChain;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * @author Craig Andrews
+ * @since 6.4
+ */
+class DefaultResourcesFormRedirectJavascriptWebFilterTests {
+
+ private final WebHandler notFoundHandler = (exchange) -> {
+ exchange.getResponse().setStatusCode(HttpStatus.NOT_FOUND);
+ return Mono.empty();
+ };
+
+ private final DefaultResourcesWebFilter filter = DefaultResourcesWebFilter.formRedirectJavascript();
+
+ @Test
+ void filterWhenPathMatchesThenRenders() {
+ MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/form-redirect.js"));
+ WebFilterChain filterChain = new DefaultWebFilterChain(this.notFoundHandler, List.of(this.filter));
+
+ filterChain.filter(exchange).block();
+
+ assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.OK);
+ assertThat(exchange.getResponse().getHeaders().getContentType())
+ .isEqualTo(new MediaType("text", "javascript", StandardCharsets.UTF_8));
+ assertThat(exchange.getResponse().getBodyAsString().block()).contains("document");
+ }
+
+ @Test
+ void filterWhenPathDoesNotMatchThenCallsThrough() {
+ MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/does-not-match"));
+ WebFilterChain filterChain = new DefaultWebFilterChain(this.notFoundHandler, List.of(this.filter));
+
+ filterChain.filter(exchange).block();
+
+ assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
+ }
+
+ @Test
+ void toStringPrintsPathAndResource() {
+ assertThat(this.filter.toString()).isEqualTo(
+ "DefaultResourcesWebFilter{matcher=PathMatcherServerWebExchangeMatcher{pattern='/form-redirect.js', method=GET}, resource='org/springframework/security/form-redirect.js'}");
+ }
+
+}
diff --git a/web/src/test/java/org/springframework/security/web/server/ui/FormRedirectStrategyTests.java b/web/src/test/java/org/springframework/security/web/server/ui/FormRedirectStrategyTests.java
new file mode 100644
index 00000000000..4eb7b4c3b66
--- /dev/null
+++ b/web/src/test/java/org/springframework/security/web/server/ui/FormRedirectStrategyTests.java
@@ -0,0 +1,96 @@
+/*
+ * Copyright 2002-2023 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.ui;
+
+import java.io.IOException;
+
+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 FormRedirectStrategyTests {
+
+ private FormRedirectStrategy formRedirectStrategy;
+
+ private MockHttpServletRequest request;
+
+ private MockHttpServletResponse response;
+
+ @BeforeEach
+ public void beforeEach() {
+ this.formRedirectStrategy = new FormRedirectStrategy();
+ 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.formRedirectStrategy.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\"");
+ }
+
+ @Test
+ public void rootRelativeUrlNoParametersRedirect() throws IOException {
+ this.formRedirectStrategy.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\"");
+ }
+
+ @Test
+ public void relativeUrlNoParametersRedirect() throws IOException {
+ this.formRedirectStrategy.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\"");
+ }
+
+ @Test
+ public void absoluteUrlWithFragmentRedirect() throws IOException {
+ this.formRedirectStrategy.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\"");
+ }
+
+ @Test
+ public void absoluteUrlWithQueryParamsRedirect() throws IOException {
+ this.formRedirectStrategy.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("");
+ }
+
+}