Skip to content

Commit cd8b993

Browse files
committed
Add FormRedirectStrategy to enable POST OIDC Logout
FormRedirectStrategy redirects using an autosubmitting HTML form using the POST method versus DefaultRedirectStrategy which redirects using the GET method. Can be used to implement POST binding for relying party initiated OIDC logout by setting FormRedirectStrategy as the redirection strategy on OidcClientInitiatedLogoutSuccessHandler. Closes gh-13002
1 parent 5329030 commit cd8b993

File tree

9 files changed

+302
-1
lines changed

9 files changed

+302
-1
lines changed

docs/modules/ROOT/pages/servlet/oauth2/login/logout.adoc

+6
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,12 @@ class OAuth2LoginSecurityConfig {
122122
If used, the application's base URL, such as `https://app.example.org`, replaces it at request time.
123123
====
124124

125+
[NOTE]
126+
====
127+
By default, `OidcClientInitiatedLogoutSuccessHandler` redirects to the logout URL using a standard HTTP redirect with the `GET` method.
128+
To perform the logout using a `POST` request, set the redirect strategy to `FormRedirectStrategy`, for example with `OidcClientInitiatedLogoutSuccessHandler.setRedirectStrategy(new FormRedirectStrategy())`.
129+
====
130+
125131
[[configure-provider-initiated-oidc-logout]]
126132
== OpenID Connect 1.0 Back-Channel Logout
127133

web/src/main/java/org/springframework/security/web/aot/hint/WebMvcSecurityRuntimeHints.java

+5
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,11 @@ public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
5454
hints.resources().registerResource(webauthnJavascript);
5555
}
5656

57+
ClassPathResource redirect = new ClassPathResource("org/springframework/security/form-redirect.js");
58+
if (redirect.exists()) {
59+
hints.resources().registerResource(redirect);
60+
}
61+
5762
}
5863

5964
}

web/src/main/java/org/springframework/security/web/server/ui/DefaultResourcesWebFilter.java

+17
Original file line numberDiff line numberDiff line change
@@ -98,4 +98,21 @@ public static DefaultResourcesWebFilter css() {
9898
new MediaType("text", "css", StandardCharsets.UTF_8));
9999
}
100100

101+
/**
102+
* Create an instance of {@link DefaultResourcesWebFilter} serving Spring Security's
103+
* form redirect javascript.
104+
* <p>
105+
* The created {@link DefaultResourcesFilter} matches requests
106+
* {@code HTTP GET /form-redirect.js}, and returns the default javascript at
107+
* {@code org/springframework/security/form-redirect.js} with content-type
108+
* {@code text/javascript;charset=UTF-8}.
109+
* @return -
110+
*/
111+
public static DefaultResourcesWebFilter formRedirectJavascript() {
112+
return new DefaultResourcesWebFilter(
113+
new PathPatternParserServerWebExchangeMatcher("/form-redirect.js", HttpMethod.GET),
114+
new ClassPathResource("org/springframework/security/form-redirect.js"),
115+
new MediaType("text", "javascript", StandardCharsets.UTF_8));
116+
}
117+
101118
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
/*
2+
* Copyright 2002-2023 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.security.web.server.ui;
18+
19+
import java.io.IOException;
20+
import java.util.List;
21+
import java.util.Map.Entry;
22+
23+
import jakarta.servlet.http.HttpServletRequest;
24+
import jakarta.servlet.http.HttpServletResponse;
25+
26+
import org.springframework.http.HttpStatus;
27+
import org.springframework.http.MediaType;
28+
import org.springframework.security.web.RedirectStrategy;
29+
import org.springframework.web.util.HtmlUtils;
30+
import org.springframework.web.util.UriComponentsBuilder;
31+
32+
/**
33+
* Redirect using an autosubmitting HTML form using the POST method. All query params
34+
* provided in the URL are changed to inputs in the form so they are submitted as POST
35+
* data instead of query string data.
36+
*/
37+
/* default */ class FormRedirectStrategy implements RedirectStrategy {
38+
39+
private static final String REDIRECT_PAGE_TEMPLATE = """
40+
<!DOCTYPE html>
41+
<html lang="en">
42+
<head>
43+
<meta charset="utf-8">
44+
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
45+
<meta name="description" content="">
46+
<meta name="author" content="">
47+
<title>Redirect</title>
48+
<link href="{{contextPath}}/default-ui.css" rel="stylesheet" />
49+
</head>
50+
<body>
51+
<div class="content">
52+
<form id="redirectForm" class="redirect-form" method="POST" action="{{action}}">
53+
{{params}}
54+
<button class="primary" type="submit">Click to Continue</button>
55+
</form>
56+
</div>
57+
<script src="{{contextPath}}/form-redirect.js"></script>
58+
</body>
59+
</html>
60+
""";
61+
62+
private static final String HIDDEN_INPUT_TEMPLATE = """
63+
<input name="{{name}}" type="hidden" value="{{value}}" />
64+
""";
65+
66+
@Override
67+
public void sendRedirect(final HttpServletRequest request, final HttpServletResponse response, final String url)
68+
throws IOException {
69+
final UriComponentsBuilder uriComponentsBuilder = UriComponentsBuilder.fromUriString(url);
70+
71+
final StringBuilder hiddenInputsHtmlBuilder = new StringBuilder();
72+
// inputs
73+
for (final Entry<String, List<String>> entry : uriComponentsBuilder.build().getQueryParams().entrySet()) {
74+
final String name = entry.getKey();
75+
for (final String value : entry.getValue()) {
76+
hiddenInputsHtmlBuilder.append(HtmlTemplates.fromTemplate(HIDDEN_INPUT_TEMPLATE)
77+
.withValue("name", name)
78+
.withValue("value", value)
79+
.render());
80+
}
81+
}
82+
83+
final String html = HtmlTemplates.fromTemplate(REDIRECT_PAGE_TEMPLATE)
84+
// clear the query string as we don't want that to be part of the form action
85+
// URL
86+
.withValue("action", uriComponentsBuilder.query(null).build().toUriString())
87+
.withRawHtml("params", hiddenInputsHtmlBuilder.toString())
88+
.withValue("contextPath", request.getContextPath())
89+
.render();
90+
response.setStatus(HttpStatus.OK.value());
91+
response.setContentType(MediaType.TEXT_HTML_VALUE);
92+
response.getWriter().write(html);
93+
response.getWriter().flush();
94+
}
95+
96+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
document.getElementById("redirectForm").submit();

web/src/test/java/org/springframework/security/web/aot/hint/WebMvcSecurityRuntimeHintsTests.java

+6
Original file line numberDiff line numberDiff line change
@@ -74,4 +74,10 @@ void webauthnJavascriptHasHints() {
7474
.forResource("org/springframework/security/spring-security-webauthn.js")).accepts(this.hints);
7575
}
7676

77+
@Test
78+
void formRedirectJavascriptHasHints() {
79+
assertThat(RuntimeHintsPredicates.resource().forResource("org/springframework/security/form-redirect.js"))
80+
.accepts(this.hints);
81+
}
82+
7783
}

web/src/test/java/org/springframework/security/web/server/ui/DefaultResourcesWebFilterTests.java renamed to web/src/test/java/org/springframework/security/web/server/ui/DefaultResourcesCssWebFilterTests.java

+1-1
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
* @author Daniel Garnier-Moiroux
3737
* @since 6.4
3838
*/
39-
class DefaultResourcesWebFilterTests {
39+
class DefaultResourcesCssWebFilterTests {
4040

4141
private final WebHandler notFoundHandler = (exchange) -> {
4242
exchange.getResponse().setStatusCode(HttpStatus.NOT_FOUND);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
/*
2+
* Copyright 2002-2024 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.security.web.server.ui;
18+
19+
import java.nio.charset.StandardCharsets;
20+
import java.util.List;
21+
22+
import org.junit.jupiter.api.Test;
23+
import reactor.core.publisher.Mono;
24+
25+
import org.springframework.http.HttpStatus;
26+
import org.springframework.http.MediaType;
27+
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
28+
import org.springframework.mock.web.server.MockServerWebExchange;
29+
import org.springframework.web.server.WebFilterChain;
30+
import org.springframework.web.server.WebHandler;
31+
import org.springframework.web.server.handler.DefaultWebFilterChain;
32+
33+
import static org.assertj.core.api.Assertions.assertThat;
34+
35+
/**
36+
* @author Craig Andrews
37+
* @since 6.4
38+
*/
39+
class DefaultResourcesFormRedirectJavascriptWebFilterTests {
40+
41+
private final WebHandler notFoundHandler = (exchange) -> {
42+
exchange.getResponse().setStatusCode(HttpStatus.NOT_FOUND);
43+
return Mono.empty();
44+
};
45+
46+
private final DefaultResourcesWebFilter filter = DefaultResourcesWebFilter.formRedirectJavascript();
47+
48+
@Test
49+
void filterWhenPathMatchesThenRenders() {
50+
MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/form-redirect.js"));
51+
WebFilterChain filterChain = new DefaultWebFilterChain(this.notFoundHandler, List.of(this.filter));
52+
53+
filterChain.filter(exchange).block();
54+
55+
assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.OK);
56+
assertThat(exchange.getResponse().getHeaders().getContentType())
57+
.isEqualTo(new MediaType("text", "javascript", StandardCharsets.UTF_8));
58+
assertThat(exchange.getResponse().getBodyAsString().block()).contains("document");
59+
}
60+
61+
@Test
62+
void filterWhenPathDoesNotMatchThenCallsThrough() {
63+
MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/does-not-match"));
64+
WebFilterChain filterChain = new DefaultWebFilterChain(this.notFoundHandler, List.of(this.filter));
65+
66+
filterChain.filter(exchange).block();
67+
68+
assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
69+
}
70+
71+
@Test
72+
void toStringPrintsPathAndResource() {
73+
assertThat(this.filter.toString()).isEqualTo(
74+
"DefaultResourcesWebFilter{matcher=PathMatcherServerWebExchangeMatcher{pattern='/form-redirect.js', method=GET}, resource='org/springframework/security/form-redirect.js'}");
75+
}
76+
77+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
/*
2+
* Copyright 2002-2023 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.security.web.server.ui;
18+
19+
import java.io.IOException;
20+
21+
import static org.assertj.core.api.Assertions.assertThat;
22+
23+
import org.junit.jupiter.api.BeforeEach;
24+
import org.junit.jupiter.api.Test;
25+
26+
import org.springframework.http.HttpStatus;
27+
import org.springframework.http.MediaType;
28+
import org.springframework.mock.web.MockHttpServletRequest;
29+
import org.springframework.mock.web.MockHttpServletResponse;
30+
import org.springframework.mock.web.MockServletContext;
31+
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
32+
33+
public class FormRedirectStrategyTest {
34+
35+
private FormRedirectStrategy formRedirectStrategy;
36+
37+
private MockHttpServletRequest request;
38+
39+
private MockHttpServletResponse response;
40+
41+
@BeforeEach
42+
public void beforeEach() {
43+
formRedirectStrategy = new FormRedirectStrategy();
44+
final MockServletContext mockServletContext = new MockServletContext();
45+
mockServletContext.setContextPath("/contextPath");
46+
// the request URL doesn't matter
47+
request = MockMvcRequestBuilders.get("http://localhost").buildRequest(mockServletContext);
48+
response = new MockHttpServletResponse();
49+
}
50+
51+
@Test
52+
public void absoluteUrlNoParametersRedirect() throws IOException {
53+
formRedirectStrategy.sendRedirect(request, response, "http://example.com");
54+
assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value());
55+
assertThat(response.getContentType()).isEqualTo(MediaType.TEXT_HTML_VALUE);
56+
assertThat(response.getContentAsString()).contains("action=\"http://example.com\"");
57+
}
58+
59+
@Test
60+
public void rootRelativeUrlNoParametersRedirect() throws IOException {
61+
formRedirectStrategy.sendRedirect(request, response, "/test");
62+
assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value());
63+
assertThat(response.getContentType()).isEqualTo(MediaType.TEXT_HTML_VALUE);
64+
assertThat(response.getContentAsString()).contains("action=\"/test\"");
65+
}
66+
67+
@Test
68+
public void relativeUrlNoParametersRedirect() throws IOException {
69+
formRedirectStrategy.sendRedirect(request, response, "test");
70+
assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value());
71+
assertThat(response.getContentType()).isEqualTo(MediaType.TEXT_HTML_VALUE);
72+
assertThat(response.getContentAsString()).contains("action=\"test\"");
73+
}
74+
75+
@Test
76+
public void absoluteUrlWithFragmentRedirect() throws IOException {
77+
formRedirectStrategy.sendRedirect(request, response, "http://example.com/path#fragment");
78+
assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value());
79+
assertThat(response.getContentType()).isEqualTo(MediaType.TEXT_HTML_VALUE);
80+
assertThat(response.getContentAsString()).contains("action=\"http://example.com/path#fragment\"");
81+
}
82+
83+
@Test
84+
public void absoluteUrlWithQueryParamsRedirect() throws IOException {
85+
formRedirectStrategy.sendRedirect(request, response, "http://example.com/path?param1=one&param2=two#fragment");
86+
assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value());
87+
assertThat(response.getContentType()).isEqualTo(MediaType.TEXT_HTML_VALUE);
88+
assertThat(response.getContentAsString()).contains("action=\"http://example.com/path#fragment\"");
89+
assertThat(response.getContentAsString()).contains("<input name=\"param1\" type=\"hidden\" value=\"one\" />");
90+
assertThat(response.getContentAsString()).contains("<input name=\"param2\" type=\"hidden\" value=\"two\" />");
91+
}
92+
93+
}

0 commit comments

Comments
 (0)