Support for OIDC RP-Initiated Logout

Fixes: gh-5350
This commit is contained in:
Josh Cummings 2018-05-16 14:12:28 -06:00
parent 55e8df1efe
commit 248a8c030b
5 changed files with 367 additions and 10 deletions

View File

@ -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<String, Object> 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 {

View File

@ -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 <a href="http://openid.net/specs/openid-connect-session-1_0.html#RPLogout">RP-Initiated Logout</a>
* @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<URI> 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;
}
}

View File

@ -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<GrantedAuthority> authorities = AuthorityUtils.createAuthorityList(roles);
String registrationId = "registration-id";
return new OAuth2AuthenticationToken(principal, authorities, registrationId);
}
}

View File

@ -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);
}
}

View File

@ -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.
*/