diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OAuth2LoginConfigurerTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OAuth2LoginConfigurerTests.java index 4f052871c2..6630fd3a03 100644 --- a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OAuth2LoginConfigurerTests.java +++ b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OAuth2LoginConfigurerTests.java @@ -15,10 +15,20 @@ */ package org.springframework.security.config.annotation.web.configurers.oauth2.client; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + import org.apache.http.HttpHeaders; import org.junit.After; import org.junit.Before; +import org.junit.Rule; import org.junit.Test; + import org.springframework.beans.factory.NoUniqueBeanDefinitionException; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.ApplicationListener; @@ -35,17 +45,22 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; import org.springframework.security.config.oauth2.client.CommonOAuth2Provider; +import org.springframework.security.config.test.SpringTestRule; import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.AuthorityUtils; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper; import org.springframework.security.core.context.SecurityContextImpl; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; import org.springframework.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient; import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest; +import org.springframework.security.oauth2.client.web.oidc.logout.OidcClientInitiatedLogoutSuccessHandler; import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest; import org.springframework.security.oauth2.client.registration.ClientRegistration; import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository; +import org.springframework.security.oauth2.client.registration.TestClientRegistrations; import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; import org.springframework.security.oauth2.client.userinfo.OAuth2UserService; import org.springframework.security.oauth2.client.web.AuthorizationRequestRepository; @@ -61,6 +76,7 @@ import org.springframework.security.oauth2.core.oidc.endpoint.OidcParameterNames import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser; import org.springframework.security.oauth2.core.oidc.user.OidcUser; import org.springframework.security.oauth2.core.oidc.user.OidcUserAuthority; +import org.springframework.security.oauth2.core.oidc.user.TestOidcUsers; import org.springframework.security.oauth2.core.user.DefaultOAuth2User; import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.security.oauth2.core.user.OAuth2UserAuthority; @@ -71,21 +87,18 @@ import org.springframework.security.web.FilterChainProxy; import org.springframework.security.web.context.HttpRequestResponseHolder; import org.springframework.security.web.context.HttpSessionSecurityContextRepository; import org.springframework.security.web.context.SecurityContextRepository; +import org.springframework.test.web.servlet.MockMvc; import org.springframework.web.context.support.AnnotationConfigWebApplicationContext; -import java.time.Instant; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; /** * Tests for {@link OAuth2LoginConfigurer}. @@ -115,6 +128,12 @@ public class OAuth2LoginConfigurerTests { @Autowired SecurityContextRepository securityContextRepository; + @Rule + public final SpringTestRule spring = new SpringTestRule(); + + @Autowired(required = false) + MockMvc mvc; + private MockHttpServletRequest request; private MockHttpServletResponse response; private MockFilterChain filterChain; @@ -455,6 +474,21 @@ public class OAuth2LoginConfigurerTests { "available: expected single matching bean but found 2: jwtDecoderFactory1,jwtDecoderFactory2"); } + @Test + public void logoutWhenUsingOidcLogoutHandlerThenRedirects() throws Exception { + this.spring.register(OAuth2LoginConfigWithOidcLogoutSuccessHandler.class).autowire(); + + OAuth2AuthenticationToken token = new OAuth2AuthenticationToken( + TestOidcUsers.create(), + AuthorityUtils.NO_AUTHORITIES, + "registration-id"); + + this.mvc.perform(post("/logout") + .with(authentication(token)) + .with(csrf())) + .andExpect(redirectedUrl("http://logout?id_token_hint=id-token")); + } + private void loadConfig(Class... configs) { AnnotationConfigWebApplicationContext applicationContext = new AnnotationConfigWebApplicationContext(); applicationContext.register(configs); @@ -591,6 +625,31 @@ public class OAuth2LoginConfigurerTests { } } + @EnableWebSecurity + static class OAuth2LoginConfigWithOidcLogoutSuccessHandler extends CommonWebSecurityConfigurerAdapter { + @Override + protected void configure(HttpSecurity http) throws Exception { + http + .logout() + .logoutSuccessHandler(oidcLogoutSuccessHandler()); + super.configure(http); + } + + @Bean + OidcClientInitiatedLogoutSuccessHandler oidcLogoutSuccessHandler() { + return new OidcClientInitiatedLogoutSuccessHandler(clientRegistrationRepository()); + } + + @Bean + ClientRegistrationRepository clientRegistrationRepository() { + Map providerMetadata = + Collections.singletonMap("end_session_endpoint", "http://logout"); + return new InMemoryClientRegistrationRepository( + TestClientRegistrations.clientRegistration() + .providerConfigurationMetadata(providerMetadata).build()); + } + } + private static abstract class CommonWebSecurityConfigurerAdapter extends WebSecurityConfigurerAdapter { @Override protected void configure(HttpSecurity http) throws Exception { diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/web/oidc/logout/OidcClientInitiatedLogoutSuccessHandler.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/web/oidc/logout/OidcClientInitiatedLogoutSuccessHandler.java new file mode 100644 index 0000000000..c67882ed9f --- /dev/null +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/web/oidc/logout/OidcClientInitiatedLogoutSuccessHandler.java @@ -0,0 +1,98 @@ +/* + * Copyright 2002-2019 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.oauth2.client.web.oidc.logout; + +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.Optional; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; +import org.springframework.security.web.authentication.logout.SimpleUrlLogoutSuccessHandler; +import org.springframework.util.Assert; +import org.springframework.web.util.UriComponentsBuilder; + +/** + * A logout success handler for initiating OIDC logout through the user agent. + * + * @author Josh Cummings + * @since 5.2 + * @see RP-Initiated Logout + * @see org.springframework.security.web.authentication.logout.LogoutSuccessHandler + */ +public final class OidcClientInitiatedLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler { + private final ClientRegistrationRepository clientRegistrationRepository; + + private URI postLogoutRedirectUri; + + public OidcClientInitiatedLogoutSuccessHandler(ClientRegistrationRepository clientRegistrationRepository) { + Assert.notNull(clientRegistrationRepository, "clientRegistrationRepository cannot be null"); + this.clientRegistrationRepository = clientRegistrationRepository; + } + + @Override + protected String determineTargetUrl(HttpServletRequest request, + HttpServletResponse response, Authentication authentication) { + + return Optional.of(authentication) + .filter(OAuth2AuthenticationToken.class::isInstance) + .filter(token -> authentication.getPrincipal() instanceof OidcUser) + .map(OAuth2AuthenticationToken.class::cast) + .flatMap(this::endSessionEndpoint) + .map(endSessionEndpoint -> endpointUri(endSessionEndpoint, authentication)) + .orElseGet(() -> super.determineTargetUrl(request, response)); + } + + private Optional endSessionEndpoint(OAuth2AuthenticationToken token) { + String registrationId = token.getAuthorizedClientRegistrationId(); + return Optional.of( + this.clientRegistrationRepository.findByRegistrationId(registrationId)) + .map(ClientRegistration::getProviderDetails) + .map(ClientRegistration.ProviderDetails::getConfigurationMetadata) + .map(configurationMetadata -> configurationMetadata.get("end_session_endpoint")) + .map(Object::toString) + .map(URI::create); + } + + private String endpointUri(URI endSessionEndpoint, Authentication authentication) { + UriComponentsBuilder builder = UriComponentsBuilder.fromUri(endSessionEndpoint); + builder.queryParam("id_token_hint", idToken(authentication)); + if (this.postLogoutRedirectUri != null) { + builder.queryParam("post_logout_redirect_uri", this.postLogoutRedirectUri); + } + return builder.encode(StandardCharsets.UTF_8).build().toUriString(); + } + + private String idToken(Authentication authentication) { + return ((OidcUser) authentication.getPrincipal()).getIdToken().getTokenValue(); + } + + /** + * Set the post logout redirect uri to use + * + * @param postLogoutRedirectUri - A valid URL to which the OP should redirect after logging out the user + */ + public void setPostLogoutRedirectUri(URI postLogoutRedirectUri) { + Assert.notNull(postLogoutRedirectUri, "postLogoutRedirectUri cannot be null"); + this.postLogoutRedirectUri = postLogoutRedirectUri; + } +} diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/authentication/TestOAuth2AuthenticationTokens.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/authentication/TestOAuth2AuthenticationTokens.java new file mode 100644 index 0000000000..df60ad55a3 --- /dev/null +++ b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/authentication/TestOAuth2AuthenticationTokens.java @@ -0,0 +1,37 @@ +/* + * Copyright 2002-2019 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.oauth2.client.authentication; + +import java.util.Collection; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.security.oauth2.core.user.TestOAuth2Users; + +/** + * @author Josh Cummings + * @since 5.2 + */ +public class TestOAuth2AuthenticationTokens { + public static OAuth2AuthenticationToken authenticated(String... roles) { + OAuth2User principal = TestOAuth2Users.create(); + Collection authorities = AuthorityUtils.createAuthorityList(roles); + String registrationId = "registration-id"; + return new OAuth2AuthenticationToken(principal, authorities, registrationId); + } +} diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/web/oidc/logout/OidcClientInitiatedLogoutSuccessHandlerTests.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/web/oidc/logout/OidcClientInitiatedLogoutSuccessHandlerTests.java new file mode 100644 index 0000000000..24c01ed530 --- /dev/null +++ b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/web/oidc/logout/OidcClientInitiatedLogoutSuccessHandlerTests.java @@ -0,0 +1,153 @@ +/* + * Copyright 2002-2019 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.oauth2.client.web.oidc.logout; + +import java.io.IOException; +import java.net.URI; +import java.util.Collections; +import javax.servlet.ServletException; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.junit.MockitoJUnitRunner; + +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository; +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 static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link OidcClientInitiatedLogoutSuccessHandler} + */ +@RunWith(MockitoJUnitRunner.class) +public class OidcClientInitiatedLogoutSuccessHandlerTests { + ClientRegistration registration = TestClientRegistrations + .clientRegistration() + .providerConfigurationMetadata( + Collections.singletonMap("end_session_endpoint", "http://endpoint")) + .build(); + ClientRegistrationRepository repository = new InMemoryClientRegistrationRepository(registration); + + MockHttpServletRequest request; + MockHttpServletResponse response; + + OidcClientInitiatedLogoutSuccessHandler handler; + + @Before + public void setup() { + this.request = new MockHttpServletRequest(); + this.response = new MockHttpServletResponse(); + this.handler = new OidcClientInitiatedLogoutSuccessHandler(this.repository); + } + + @Test + public void logoutWhenOidcRedirectUrlConfiguredThenRedirects() + throws IOException, ServletException { + OAuth2AuthenticationToken token = new OAuth2AuthenticationToken( + TestOidcUsers.create(), + AuthorityUtils.NO_AUTHORITIES, + this.registration.getRegistrationId()); + + this.request.setUserPrincipal(token); + this.handler.onLogoutSuccess(this.request, this.response, token); + + assertThat(this.response.getRedirectedUrl()).isEqualTo("http://endpoint?id_token_hint=id-token"); + } + + @Test + public void logoutWhenNotOAuth2AuthenticationThenDefaults() + throws IOException, ServletException { + Authentication token = mock(Authentication.class); + + this.request.setUserPrincipal(token); + this.handler.setDefaultTargetUrl("http://default"); + this.handler.onLogoutSuccess(this.request, this.response, token); + + assertThat(this.response.getRedirectedUrl()).isEqualTo("http://default"); + } + + @Test + public void logoutWhenNotOidcUserThenDefaults() + throws IOException, ServletException { + OAuth2AuthenticationToken token = new OAuth2AuthenticationToken( + TestOAuth2Users.create(), + AuthorityUtils.NO_AUTHORITIES, + this.registration.getRegistrationId()); + + this.request.setUserPrincipal(token); + this.handler.setDefaultTargetUrl("http://default"); + this.handler.onLogoutSuccess(this.request, this.response, token); + + assertThat(this.response.getRedirectedUrl()).isEqualTo("http://default"); + } + + @Test + public void logoutWhenClientRegistrationHasNoEndSessionEndpointThenDefaults() + throws Exception { + + ClientRegistration registration = TestClientRegistrations.clientRegistration().build(); + ClientRegistrationRepository repository = new InMemoryClientRegistrationRepository(registration); + OidcClientInitiatedLogoutSuccessHandler handler = new OidcClientInitiatedLogoutSuccessHandler(repository); + + OAuth2AuthenticationToken token = new OAuth2AuthenticationToken( + TestOidcUsers.create(), + AuthorityUtils.NO_AUTHORITIES, + registration.getRegistrationId()); + + this.request.setUserPrincipal(token); + handler.setDefaultTargetUrl("http://default"); + handler.onLogoutSuccess(this.request, this.response, token); + + assertThat(this.response.getRedirectedUrl()).isEqualTo("http://default"); + } + + @Test + public void logoutWhenUsingPostLogoutRedirectUriThenIncludesItInRedirect() + throws IOException, ServletException { + + OAuth2AuthenticationToken token = new OAuth2AuthenticationToken( + TestOidcUsers.create(), + AuthorityUtils.NO_AUTHORITIES, + this.registration.getRegistrationId()); + + this.handler.setPostLogoutRedirectUri(URI.create("http://postlogout?encodedparam=value")); + this.request.setUserPrincipal(token); + this.handler.onLogoutSuccess(this.request, this.response, token); + + assertThat(this.response.getRedirectedUrl()).isEqualTo("http://endpoint?" + + "id_token_hint=id-token&" + + "post_logout_redirect_uri=http://postlogout?encodedparam%3Dvalue"); + } + + @Test + public void setPostLogoutRedirectUriWhenGivenNullThenThrowsException() { + assertThatThrownBy(() -> this.handler.setPostLogoutRedirectUri(null)) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/web/src/main/java/org/springframework/security/web/authentication/AbstractAuthenticationTargetUrlRequestHandler.java b/web/src/main/java/org/springframework/security/web/authentication/AbstractAuthenticationTargetUrlRequestHandler.java index 39500c0b04..80aeeebe3a 100644 --- a/web/src/main/java/org/springframework/security/web/authentication/AbstractAuthenticationTargetUrlRequestHandler.java +++ b/web/src/main/java/org/springframework/security/web/authentication/AbstractAuthenticationTargetUrlRequestHandler.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2016 the original author or authors. + * Copyright 2002-2019 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. @@ -80,7 +80,7 @@ public abstract class AbstractAuthenticationTargetUrlRequestHandler { */ protected void handle(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { - String targetUrl = determineTargetUrl(request, response); + String targetUrl = determineTargetUrl(request, response, authentication); if (response.isCommitted()) { logger.debug("Response has already been committed. Unable to redirect to " @@ -91,6 +91,16 @@ public abstract class AbstractAuthenticationTargetUrlRequestHandler { redirectStrategy.sendRedirect(request, response, targetUrl); } + /** + * Builds the target URL according to the logic defined in the main class Javadoc + * + * @since 5.2 + */ + protected String determineTargetUrl(HttpServletRequest request, + HttpServletResponse response, Authentication authentication) { + return determineTargetUrl(request, response); + } + /** * Builds the target URL according to the logic defined in the main class Javadoc. */