Skip to content

Add FormPostRedirectStrategy to enable POST OIDC Logout #16214

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Feb 3, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/modules/ROOT/pages/servlet/oauth2/login/logout.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -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 = """
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="description" content="">
<meta name="author" content="">
<title>Redirect</title>
</head>
<body>
<form id="redirect-form" method="POST" action="{{action}}">
{{params}}
<noscript>
<p>JavaScript is not enabled for this page.</p>
<button type="submit">Click to continue</button>
</noscript>
</form>
<script nonce="{{nonce}}">
document.getElementById("redirect-form").submit();
</script>
</body>
</html>
""";

private static final String HIDDEN_INPUT_TEMPLATE = """
<input name="{{name}}" type="hidden" value="{{value}}" />
""";

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<String, List<String>> 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();
}

}
Original file line number Diff line number Diff line change
@@ -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&param2=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("<input name=\"param1\" type=\"hidden\" value=\"one\" />");
assertThat(this.response.getContentAsString())
.contains("<input name=\"param2\" type=\"hidden\" value=\"two\" />");
assertThat(this.response).satisfies(hasScriptSrcNonce());
}

private ThrowingConsumer<MockHttpServletResponse> 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("<script nonce=\"%s\">".formatted(nonce));
};
}

}