Skip to content

Commit 23ea2bf

Browse files
candrewssjohnr
authored andcommitted
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 Signed-off-by: Craig Andrews <[email protected]>
1 parent 7030a62 commit 23ea2bf

File tree

11 files changed

+351
-1
lines changed

11 files changed

+351
-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/authentication/ui/DefaultResourcesFilter.java

+16
Original file line numberDiff line numberDiff line change
@@ -111,4 +111,20 @@ public static DefaultResourcesFilter webauthn() {
111111
new MediaType("text", "javascript", StandardCharsets.UTF_8));
112112
}
113113

114+
/**
115+
* Create an instance of {@link DefaultResourcesFilter} serving Spring Security's
116+
* default webauthn javascript.
117+
* <p>
118+
* The created {@link DefaultResourcesFilter} matches requests
119+
* {@code HTTP GET /form-redirect.js}, and returns the default webauthn javascript at
120+
* {@code org/springframework/security/form-redirect.js} with content-type
121+
* {@code text/javascript;charset=UTF-8}.
122+
* @return -
123+
*/
124+
public static DefaultResourcesFilter formRedirectJavascript() {
125+
return new DefaultResourcesFilter(AntPathRequestMatcher.antMatcher(HttpMethod.GET, "/form-redirect.js"),
126+
new ClassPathResource("org/springframework/security/form-redirect.js"),
127+
new MediaType("text", "javascript", StandardCharsets.UTF_8));
128+
}
129+
114130
}

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,95 @@
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.UriComponentsBuilder;
30+
31+
/**
32+
* Redirect using an autosubmitting HTML form using the POST method. All query params
33+
* provided in the URL are changed to inputs in the form so they are submitted as POST
34+
* data instead of query string data.
35+
*/
36+
/* default */ class FormRedirectStrategy implements RedirectStrategy {
37+
38+
private static final String REDIRECT_PAGE_TEMPLATE = """
39+
<!DOCTYPE html>
40+
<html lang="en">
41+
<head>
42+
<meta charset="utf-8">
43+
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
44+
<meta name="description" content="">
45+
<meta name="author" content="">
46+
<title>Redirect</title>
47+
<link href="{{contextPath}}/default-ui.css" rel="stylesheet" />
48+
</head>
49+
<body>
50+
<div class="content">
51+
<form id="redirectForm" class="redirect-form" method="POST" action="{{action}}">
52+
{{params}}
53+
<button class="primary" type="submit">Click to Continue</button>
54+
</form>
55+
</div>
56+
<script src="{{contextPath}}/form-redirect.js"></script>
57+
</body>
58+
</html>
59+
""";
60+
61+
private static final String HIDDEN_INPUT_TEMPLATE = """
62+
<input name="{{name}}" type="hidden" value="{{value}}" />
63+
""";
64+
65+
@Override
66+
public void sendRedirect(final HttpServletRequest request, final HttpServletResponse response, final String url)
67+
throws IOException {
68+
final UriComponentsBuilder uriComponentsBuilder = UriComponentsBuilder.fromUriString(url);
69+
70+
final StringBuilder hiddenInputsHtmlBuilder = new StringBuilder();
71+
// inputs
72+
for (final Entry<String, List<String>> entry : uriComponentsBuilder.build().getQueryParams().entrySet()) {
73+
final String name = entry.getKey();
74+
for (final String value : entry.getValue()) {
75+
hiddenInputsHtmlBuilder.append(HtmlTemplates.fromTemplate(HIDDEN_INPUT_TEMPLATE)
76+
.withValue("name", name)
77+
.withValue("value", value)
78+
.render());
79+
}
80+
}
81+
82+
final String html = HtmlTemplates.fromTemplate(REDIRECT_PAGE_TEMPLATE)
83+
// clear the query string as we don't want that to be part of the form action
84+
// URL
85+
.withValue("action", uriComponentsBuilder.query(null).build().toUriString())
86+
.withRawHtml("params", hiddenInputsHtmlBuilder.toString())
87+
.withValue("contextPath", request.getContextPath())
88+
.render();
89+
response.setStatus(HttpStatus.OK.value());
90+
response.setContentType(MediaType.TEXT_HTML_VALUE);
91+
response.getWriter().write(html);
92+
response.getWriter().flush();
93+
}
94+
95+
}
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/authentication/ui/DefaultResourcesFilterTests.java

+31
Original file line numberDiff line numberDiff line change
@@ -94,4 +94,35 @@ void toStringPrintsPathAndResource() {
9494

9595
}
9696

97+
@Nested
98+
class FormRedirectJavascriptFilter {
99+
100+
private final DefaultResourcesFilter formRedirectJavascriptFilter = DefaultResourcesFilter
101+
.formRedirectJavascript();
102+
103+
private final MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new Object())
104+
.addFilters(this.formRedirectJavascriptFilter)
105+
.build();
106+
107+
@Test
108+
void doFilterThenRender() throws Exception {
109+
this.mockMvc.perform(get("/form-redirect.js"))
110+
.andExpect(status().isOk())
111+
.andExpect(content().contentType("text/javascript;charset=UTF-8"))
112+
.andExpect(content().string(containsString("submit")));
113+
}
114+
115+
@Test
116+
void doFilterWhenPathDoesNotMatchThenCallsThrough() throws Exception {
117+
this.mockMvc.perform(get("/does-not-match")).andExpect(status().isNotFound());
118+
}
119+
120+
@Test
121+
void toStringPrintsPathAndResource() {
122+
assertThat(this.formRedirectJavascriptFilter.toString()).isEqualTo(
123+
"DefaultResourcesFilter [matcher=Ant [pattern='/form-redirect.js', GET], resource=org/springframework/security/form-redirect.js]");
124+
}
125+
126+
}
127+
97128
}

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,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+
21+
import org.junit.jupiter.api.BeforeEach;
22+
import org.junit.jupiter.api.Test;
23+
24+
import org.springframework.http.HttpStatus;
25+
import org.springframework.http.MediaType;
26+
import org.springframework.mock.web.MockHttpServletRequest;
27+
import org.springframework.mock.web.MockHttpServletResponse;
28+
import org.springframework.mock.web.MockServletContext;
29+
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
30+
31+
import static org.assertj.core.api.Assertions.assertThat;
32+
33+
public class FormRedirectStrategyTests {
34+
35+
private FormRedirectStrategy formRedirectStrategy;
36+
37+
private MockHttpServletRequest request;
38+
39+
private MockHttpServletResponse response;
40+
41+
@BeforeEach
42+
public void beforeEach() {
43+
this.formRedirectStrategy = new FormRedirectStrategy();
44+
final MockServletContext mockServletContext = new MockServletContext();
45+
mockServletContext.setContextPath("/contextPath");
46+
// the request URL doesn't matter
47+
this.request = MockMvcRequestBuilders.get("https://localhost").buildRequest(mockServletContext);
48+
this.response = new MockHttpServletResponse();
49+
}
50+
51+
@Test
52+
public void absoluteUrlNoParametersRedirect() throws IOException {
53+
this.formRedirectStrategy.sendRedirect(this.request, this.response, "https://example.com");
54+
assertThat(this.response.getStatus()).isEqualTo(HttpStatus.OK.value());
55+
assertThat(this.response.getContentType()).isEqualTo(MediaType.TEXT_HTML_VALUE);
56+
assertThat(this.response.getContentAsString()).contains("action=\"https://example.com\"");
57+
}
58+
59+
@Test
60+
public void rootRelativeUrlNoParametersRedirect() throws IOException {
61+
this.formRedirectStrategy.sendRedirect(this.request, this.response, "/test");
62+
assertThat(this.response.getStatus()).isEqualTo(HttpStatus.OK.value());
63+
assertThat(this.response.getContentType()).isEqualTo(MediaType.TEXT_HTML_VALUE);
64+
assertThat(this.response.getContentAsString()).contains("action=\"/test\"");
65+
}
66+
67+
@Test
68+
public void relativeUrlNoParametersRedirect() throws IOException {
69+
this.formRedirectStrategy.sendRedirect(this.request, this.response, "test");
70+
assertThat(this.response.getStatus()).isEqualTo(HttpStatus.OK.value());
71+
assertThat(this.response.getContentType()).isEqualTo(MediaType.TEXT_HTML_VALUE);
72+
assertThat(this.response.getContentAsString()).contains("action=\"test\"");
73+
}
74+
75+
@Test
76+
public void absoluteUrlWithFragmentRedirect() throws IOException {
77+
this.formRedirectStrategy.sendRedirect(this.request, this.response, "https://example.com/path#fragment");
78+
assertThat(this.response.getStatus()).isEqualTo(HttpStatus.OK.value());
79+
assertThat(this.response.getContentType()).isEqualTo(MediaType.TEXT_HTML_VALUE);
80+
assertThat(this.response.getContentAsString()).contains("action=\"https://example.com/path#fragment\"");
81+
}
82+
83+
@Test
84+
public void absoluteUrlWithQueryParamsRedirect() throws IOException {
85+
this.formRedirectStrategy.sendRedirect(this.request, this.response,
86+
"https://example.com/path?param1=one&param2=two#fragment");
87+
assertThat(this.response.getStatus()).isEqualTo(HttpStatus.OK.value());
88+
assertThat(this.response.getContentType()).isEqualTo(MediaType.TEXT_HTML_VALUE);
89+
assertThat(this.response.getContentAsString()).contains("action=\"https://example.com/path#fragment\"");
90+
assertThat(this.response.getContentAsString())
91+
.contains("<input name=\"param1\" type=\"hidden\" value=\"one\" />");
92+
assertThat(this.response.getContentAsString())
93+
.contains("<input name=\"param2\" type=\"hidden\" value=\"two\" />");
94+
}
95+
96+
}

0 commit comments

Comments
 (0)