Skip to content

Add Support ServerFormPostRedirectStrategy #16551

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 3 commits into from
Feb 19, 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/reactive/oauth2/login/logout.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,12 @@ class OAuth2LoginSecurityConfig {
If used, the application's base URL, such as `https://app.example.org`, replaces it at request time.
====

[NOTE]
====
By default, `OidcClientInitiatedServerLogoutSuccessHandler` 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 `FormPostServerRedirectStrategy`, for example with `OidcClientInitiatedServerLogoutSuccessHandler.setRedirectStrategy(new ServerFormPostRedirectStrategy())`.
====

[[configure-provider-initiated-oidc-logout]]
== OpenID Connect 1.0 Back-Channel Logout

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2002-2024 the original author or authors.
* 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.
Expand Down Expand Up @@ -51,7 +51,7 @@
*/
public class OidcClientInitiatedServerLogoutSuccessHandler implements ServerLogoutSuccessHandler {

private final ServerRedirectStrategy redirectStrategy = new DefaultServerRedirectStrategy();
private ServerRedirectStrategy redirectStrategy = new DefaultServerRedirectStrategy();

private final RedirectServerLogoutSuccessHandler serverLogoutSuccessHandler = new RedirectServerLogoutSuccessHandler();

Expand Down Expand Up @@ -199,6 +199,17 @@ public void setRedirectUriResolver(Converter<RedirectUriParameters, Mono<String>
this.redirectUriResolver = redirectUriResolver;
}

/**
* Set the {@link ServerRedirectStrategy} to use, default
* {@link DefaultServerRedirectStrategy}
* @param redirectStrategy {@link ServerRedirectStrategy}
* @since 6.5
*/
public void setRedirectStrategy(ServerRedirectStrategy redirectStrategy) {
Assert.notNull(redirectStrategy, "redirectStrategy cannot be null");
this.redirectStrategy = redirectStrategy;
}

/**
* Parameters, required for redirect URI resolving.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2002-2024 the original author or authors.
* 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.
Expand Down Expand Up @@ -37,14 +37,18 @@
import org.springframework.security.oauth2.client.registration.TestClientRegistrations;
import org.springframework.security.oauth2.core.oidc.user.TestOidcUsers;
import org.springframework.security.oauth2.core.user.TestOAuth2Users;
import org.springframework.security.web.server.ServerRedirectStrategy;
import org.springframework.security.web.server.WebFilterExchange;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.WebFilterChain;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;

/**
* Tests for {@link OidcClientInitiatedServerLogoutSuccessHandler}
Expand Down Expand Up @@ -219,6 +223,27 @@ public void logoutWhenCustomRedirectUriResolverSetThenRedirects() {
assertThat(redirectedUrl(this.exchange)).isEqualTo("https://test.com");
}

@Test
public void setRedirectStrategyWhenGivenNullThenThrowsException() {
assertThatIllegalArgumentException().isThrownBy(() -> this.handler.setRedirectStrategy(null));
}

@Test
public void logoutWhenCustomRedirectStrategySetThenCustomRedirectStrategyUsed() {
ServerRedirectStrategy redirectStrategy = mock(ServerRedirectStrategy.class);
given(redirectStrategy.sendRedirect(any(), any())).willReturn(Mono.empty());
OAuth2AuthenticationToken token = new OAuth2AuthenticationToken(TestOidcUsers.create(),
AuthorityUtils.NO_AUTHORITIES, this.registration.getRegistrationId());
WebFilterExchange filterExchange = new WebFilterExchange(this.exchange, this.chain);
given(this.exchange.getRequest())
.willReturn(MockServerHttpRequest.get("/").queryParam("location", "https://test.com").build());
this.handler.setRedirectStrategy(redirectStrategy);

this.handler.onLogoutSuccess(filterExchange, token).block();

verify(redirectStrategy, times(1)).sendRedirect(any(), any());
}

private String redirectedUrl(ServerWebExchange exchange) {
return exchange.getResponse().getHeaders().getFirst("Location");
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
/*
* 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.server;

import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.List;
import java.util.Map;

import reactor.core.publisher.Mono;

import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.core.io.buffer.DataBufferFactory;
import org.springframework.core.io.buffer.DataBufferUtils;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.security.crypto.keygen.Base64StringKeyGenerator;
import org.springframework.security.crypto.keygen.StringKeyGenerator;
import org.springframework.web.server.ServerWebExchange;
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 Max Batischev
* @author Steve Riesenberg
* @since 6.5
*/
public final class FormPostServerRedirectStrategy implements ServerRedirectStrategy {

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 Mono<Void> sendRedirect(ServerWebExchange exchange, URI location) {
final UriComponentsBuilder uriComponentsBuilder = UriComponentsBuilder.fromUri(location);

final StringBuilder hiddenInputsHtmlBuilder = new StringBuilder();
for (final Map.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

final ServerHttpResponse response = exchange.getResponse();
response.setStatusCode(HttpStatus.OK);
response.getHeaders().setContentType(MediaType.TEXT_HTML);
response.getHeaders().set(CONTENT_SECURITY_POLICY_HEADER, policyDirective);

final DataBufferFactory bufferFactory = response.bufferFactory();
final DataBuffer buffer = bufferFactory.wrap(html.getBytes(StandardCharsets.UTF_8));
return response.writeWith(Mono.just(buffer)).doOnError((error) -> DataBufferUtils.release(buffer));
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/*
* 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.server;

import java.net.URI;

import org.assertj.core.api.ThrowingConsumer;
import org.junit.jupiter.api.Test;

import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
import org.springframework.mock.http.server.reactive.MockServerHttpResponse;
import org.springframework.mock.web.server.MockServerWebExchange;

import static org.assertj.core.api.Assertions.assertThat;

/**
* Tests for {@link FormPostServerRedirectStrategy}.
*
* @author Max Batischev
*/
public class FormPostServerRedirectStrategyTests {

private static final String POLICY_DIRECTIVE_PATTERN = "script-src 'nonce-(.+)'";

private final ServerRedirectStrategy redirectStrategy = new FormPostServerRedirectStrategy();

private final MockServerHttpRequest request = MockServerHttpRequest.get("https://localhost").build();

private final MockServerWebExchange webExchange = MockServerWebExchange.from(this.request);

@Test
public void redirectWhetLocationAbsoluteUriIsPresentThenRedirect() {
this.redirectStrategy.sendRedirect(this.webExchange, URI.create("https://example.com")).block();

MockServerHttpResponse response = this.webExchange.getResponse();
assertThat(response.getBodyAsString().block()).contains("action=\"https://example.com\"");
assertThat(this.webExchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(this.webExchange.getResponse().getHeaders().getContentType()).isEqualTo(MediaType.TEXT_HTML);
assertThat(this.webExchange.getResponse()).satisfies(hasScriptSrcNonce());
}

@Test
public void redirectWhetLocationRootRelativeUriIsPresentThenRedirect() {
this.redirectStrategy.sendRedirect(this.webExchange, URI.create("/test")).block();

MockServerHttpResponse response = this.webExchange.getResponse();
assertThat(response.getBodyAsString().block()).contains("action=\"/test\"");
assertThat(this.webExchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(this.webExchange.getResponse().getHeaders().getContentType()).isEqualTo(MediaType.TEXT_HTML);
assertThat(this.webExchange.getResponse()).satisfies(hasScriptSrcNonce());
}

@Test
public void redirectWhetLocationRelativeUriIsPresentThenRedirect() {
this.redirectStrategy.sendRedirect(this.webExchange, URI.create("test")).block();

MockServerHttpResponse response = this.webExchange.getResponse();
assertThat(response.getBodyAsString().block()).contains("action=\"test\"");
assertThat(this.webExchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(this.webExchange.getResponse().getHeaders().getContentType()).isEqualTo(MediaType.TEXT_HTML);
assertThat(this.webExchange.getResponse()).satisfies(hasScriptSrcNonce());
}

@Test
public void redirectWhenLocationAbsoluteUriWithFragmentIsPresentThenRedirect() {
this.redirectStrategy.sendRedirect(this.webExchange, URI.create("https://example.com/path#fragment")).block();

MockServerHttpResponse response = this.webExchange.getResponse();
assertThat(response.getBodyAsString().block()).contains("action=\"https://example.com/path#fragment\"");
assertThat(this.webExchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(this.webExchange.getResponse().getHeaders().getContentType()).isEqualTo(MediaType.TEXT_HTML);
assertThat(this.webExchange.getResponse()).satisfies(hasScriptSrcNonce());
}

@Test
public void redirectWhenLocationAbsoluteUriWithQueryParamsIsPresentThenRedirect() {
this.redirectStrategy
.sendRedirect(this.webExchange, URI.create("https://example.com/path?param1=one&param2=two#fragment"))
.block();

MockServerHttpResponse response = this.webExchange.getResponse();
String content = response.getBodyAsString().block();
assertThat(this.webExchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(this.webExchange.getResponse().getHeaders().getContentType()).isEqualTo(MediaType.TEXT_HTML);
assertThat(content).contains("action=\"https://example.com/path#fragment\"");
assertThat(content).contains("<input name=\"param1\" type=\"hidden\" value=\"one\" />");
assertThat(content).contains("<input name=\"param2\" type=\"hidden\" value=\"two\" />");
}

private ThrowingConsumer<MockServerHttpResponse> hasScriptSrcNonce() {
return (response) -> {
final String policyDirective = response.getHeaders().getFirst("Content-Security-Policy");
assertThat(policyDirective).isNotEmpty();
assertThat(policyDirective).matches(POLICY_DIRECTIVE_PATTERN);

final String nonce = policyDirective.replaceFirst(POLICY_DIRECTIVE_PATTERN, "$1");
assertThat(response.getBodyAsString().block()).contains("<script nonce=\"%s\">".formatted(nonce));
};
}

}
Loading