Skip to content

Commit 0273b84

Browse files
committed
Support for OIDC RP-Initiated Logout
Fixes: spring-projectsgh-5350
1 parent b935281 commit 0273b84

File tree

5 files changed

+367
-10
lines changed

5 files changed

+367
-10
lines changed

config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OAuth2LoginConfigurerTests.java

Lines changed: 67 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,20 @@
1515
*/
1616
package org.springframework.security.config.annotation.web.configurers.oauth2.client;
1717

18+
import java.time.Instant;
19+
import java.util.ArrayList;
20+
import java.util.Arrays;
21+
import java.util.Collections;
22+
import java.util.HashMap;
23+
import java.util.List;
24+
import java.util.Map;
25+
1826
import org.apache.http.HttpHeaders;
1927
import org.junit.After;
2028
import org.junit.Before;
29+
import org.junit.Rule;
2130
import org.junit.Test;
31+
2232
import org.springframework.beans.factory.NoUniqueBeanDefinitionException;
2333
import org.springframework.beans.factory.annotation.Autowired;
2434
import org.springframework.context.ApplicationListener;
@@ -35,17 +45,22 @@
3545
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
3646
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
3747
import org.springframework.security.config.oauth2.client.CommonOAuth2Provider;
48+
import org.springframework.security.config.test.SpringTestRule;
3849
import org.springframework.security.core.Authentication;
3950
import org.springframework.security.core.GrantedAuthority;
51+
import org.springframework.security.core.authority.AuthorityUtils;
4052
import org.springframework.security.core.authority.SimpleGrantedAuthority;
4153
import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper;
4254
import org.springframework.security.core.context.SecurityContextImpl;
55+
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
4356
import org.springframework.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient;
4457
import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest;
58+
import org.springframework.security.oauth2.client.web.oidc.logout.OidcClientInitiatedLogoutSuccessHandler;
4559
import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest;
4660
import org.springframework.security.oauth2.client.registration.ClientRegistration;
4761
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
4862
import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository;
63+
import org.springframework.security.oauth2.client.registration.TestClientRegistrations;
4964
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
5065
import org.springframework.security.oauth2.client.userinfo.OAuth2UserService;
5166
import org.springframework.security.oauth2.client.web.AuthorizationRequestRepository;
@@ -61,6 +76,7 @@
6176
import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser;
6277
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
6378
import org.springframework.security.oauth2.core.oidc.user.OidcUserAuthority;
79+
import org.springframework.security.oauth2.core.oidc.user.TestOidcUsers;
6480
import org.springframework.security.oauth2.core.user.DefaultOAuth2User;
6581
import org.springframework.security.oauth2.core.user.OAuth2User;
6682
import org.springframework.security.oauth2.core.user.OAuth2UserAuthority;
@@ -71,21 +87,18 @@
7187
import org.springframework.security.web.context.HttpRequestResponseHolder;
7288
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
7389
import org.springframework.security.web.context.SecurityContextRepository;
90+
import org.springframework.test.web.servlet.MockMvc;
7491
import org.springframework.web.context.support.AnnotationConfigWebApplicationContext;
7592

76-
import java.time.Instant;
77-
import java.util.ArrayList;
78-
import java.util.Arrays;
79-
import java.util.Collections;
80-
import java.util.HashMap;
81-
import java.util.List;
82-
import java.util.Map;
83-
8493
import static org.assertj.core.api.Assertions.assertThat;
8594
import static org.assertj.core.api.Assertions.assertThatThrownBy;
8695
import static org.mockito.ArgumentMatchers.any;
8796
import static org.mockito.Mockito.mock;
8897
import static org.mockito.Mockito.when;
98+
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
99+
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
100+
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
101+
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl;
89102

90103
/**
91104
* Tests for {@link OAuth2LoginConfigurer}.
@@ -115,6 +128,12 @@ public class OAuth2LoginConfigurerTests {
115128
@Autowired
116129
SecurityContextRepository securityContextRepository;
117130

131+
@Rule
132+
public final SpringTestRule spring = new SpringTestRule();
133+
134+
@Autowired(required = false)
135+
MockMvc mvc;
136+
118137
private MockHttpServletRequest request;
119138
private MockHttpServletResponse response;
120139
private MockFilterChain filterChain;
@@ -455,6 +474,21 @@ public void oidcLoginCustomWithNoUniqueJwtDecoderFactory() {
455474
"available: expected single matching bean but found 2: jwtDecoderFactory1,jwtDecoderFactory2");
456475
}
457476

477+
@Test
478+
public void logoutWhenUsingOidcLogoutHandlerThenRedirects() throws Exception {
479+
this.spring.register(OAuth2LoginConfigWithOidcLogoutSuccessHandler.class).autowire();
480+
481+
OAuth2AuthenticationToken token = new OAuth2AuthenticationToken(
482+
TestOidcUsers.create(),
483+
AuthorityUtils.NO_AUTHORITIES,
484+
"registration-id");
485+
486+
this.mvc.perform(post("/logout")
487+
.with(authentication(token))
488+
.with(csrf()))
489+
.andExpect(redirectedUrl("http://logout?id_token_hint=id-token"));
490+
}
491+
458492
private void loadConfig(Class<?>... configs) {
459493
AnnotationConfigWebApplicationContext applicationContext = new AnnotationConfigWebApplicationContext();
460494
applicationContext.register(configs);
@@ -591,6 +625,31 @@ protected void configure(HttpSecurity http) throws Exception {
591625
}
592626
}
593627

628+
@EnableWebSecurity
629+
static class OAuth2LoginConfigWithOidcLogoutSuccessHandler extends CommonWebSecurityConfigurerAdapter {
630+
@Override
631+
protected void configure(HttpSecurity http) throws Exception {
632+
http
633+
.logout()
634+
.logoutSuccessHandler(oidcLogoutSuccessHandler());
635+
super.configure(http);
636+
}
637+
638+
@Bean
639+
OidcClientInitiatedLogoutSuccessHandler oidcLogoutSuccessHandler() {
640+
return new OidcClientInitiatedLogoutSuccessHandler(clientRegistrationRepository());
641+
}
642+
643+
@Bean
644+
ClientRegistrationRepository clientRegistrationRepository() {
645+
Map<String, Object> providerMetadata =
646+
Collections.singletonMap("end_session_endpoint", "http://logout");
647+
return new InMemoryClientRegistrationRepository(
648+
TestClientRegistrations.clientRegistration()
649+
.providerConfigurationMetadata(providerMetadata).build());
650+
}
651+
}
652+
594653
private static abstract class CommonWebSecurityConfigurerAdapter extends WebSecurityConfigurerAdapter {
595654
@Override
596655
protected void configure(HttpSecurity http) throws Exception {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
/*
2+
* Copyright 2002-2019 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.oauth2.client.web.oidc.logout;
18+
19+
import java.net.URI;
20+
import java.nio.charset.StandardCharsets;
21+
import java.util.Optional;
22+
import javax.servlet.http.HttpServletRequest;
23+
import javax.servlet.http.HttpServletResponse;
24+
25+
import org.springframework.security.core.Authentication;
26+
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
27+
import org.springframework.security.oauth2.client.registration.ClientRegistration;
28+
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
29+
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
30+
import org.springframework.security.web.authentication.logout.SimpleUrlLogoutSuccessHandler;
31+
import org.springframework.util.Assert;
32+
import org.springframework.web.util.UriComponentsBuilder;
33+
34+
/**
35+
* A logout success handler for initiating OIDC logout through the user agent.
36+
*
37+
* @author Josh Cummings
38+
* @since 5.2
39+
* @see <a href="http://openid.net/specs/openid-connect-session-1_0.html#RPLogout">RP-Initiated Logout</a>
40+
* @see org.springframework.security.web.authentication.logout.LogoutSuccessHandler
41+
*/
42+
public final class OidcClientInitiatedLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
43+
private final ClientRegistrationRepository clientRegistrationRepository;
44+
45+
private URI postLogoutRedirectUri;
46+
47+
public OidcClientInitiatedLogoutSuccessHandler(ClientRegistrationRepository clientRegistrationRepository) {
48+
Assert.notNull(clientRegistrationRepository, "clientRegistrationRepository cannot be null");
49+
this.clientRegistrationRepository = clientRegistrationRepository;
50+
}
51+
52+
@Override
53+
protected String determineTargetUrl(HttpServletRequest request,
54+
HttpServletResponse response, Authentication authentication) {
55+
56+
return Optional.of(authentication)
57+
.filter(OAuth2AuthenticationToken.class::isInstance)
58+
.filter(token -> authentication.getPrincipal() instanceof OidcUser)
59+
.map(OAuth2AuthenticationToken.class::cast)
60+
.flatMap(this::endSessionEndpoint)
61+
.map(endSessionEndpoint -> endpointUri(endSessionEndpoint, authentication))
62+
.orElseGet(() -> super.determineTargetUrl(request, response));
63+
}
64+
65+
private Optional<URI> endSessionEndpoint(OAuth2AuthenticationToken token) {
66+
String registrationId = token.getAuthorizedClientRegistrationId();
67+
return Optional.of(
68+
this.clientRegistrationRepository.findByRegistrationId(registrationId))
69+
.map(ClientRegistration::getProviderDetails)
70+
.map(ClientRegistration.ProviderDetails::getConfigurationMetadata)
71+
.map(configurationMetadata -> configurationMetadata.get("end_session_endpoint"))
72+
.map(Object::toString)
73+
.map(URI::create);
74+
}
75+
76+
private String endpointUri(URI endSessionEndpoint, Authentication authentication) {
77+
UriComponentsBuilder builder = UriComponentsBuilder.fromUri(endSessionEndpoint);
78+
builder.queryParam("id_token_hint", idToken(authentication));
79+
if (this.postLogoutRedirectUri != null) {
80+
builder.queryParam("post_logout_redirect_uri", this.postLogoutRedirectUri);
81+
}
82+
return builder.encode(StandardCharsets.UTF_8).build().toUriString();
83+
}
84+
85+
private String idToken(Authentication authentication) {
86+
return ((OidcUser) authentication.getPrincipal()).getIdToken().getTokenValue();
87+
}
88+
89+
/**
90+
* Set the post logout redirect uri to use
91+
*
92+
* @param postLogoutRedirectUri - A valid URL to which the OP should redirect after logging out the user
93+
*/
94+
public void setPostLogoutRedirectUri(URI postLogoutRedirectUri) {
95+
Assert.notNull(postLogoutRedirectUri, "postLogoutRedirectUri cannot be null");
96+
this.postLogoutRedirectUri = postLogoutRedirectUri;
97+
}
98+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/*
2+
* Copyright 2002-2019 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.oauth2.client.authentication;
18+
19+
import java.util.Collection;
20+
21+
import org.springframework.security.core.GrantedAuthority;
22+
import org.springframework.security.core.authority.AuthorityUtils;
23+
import org.springframework.security.oauth2.core.user.OAuth2User;
24+
import org.springframework.security.oauth2.core.user.TestOAuth2Users;
25+
26+
/**
27+
* @author Josh Cummings
28+
* @since 5.2
29+
*/
30+
public class TestOAuth2AuthenticationTokens {
31+
public static OAuth2AuthenticationToken authenticated(String... roles) {
32+
OAuth2User principal = TestOAuth2Users.create();
33+
Collection<GrantedAuthority> authorities = AuthorityUtils.createAuthorityList(roles);
34+
String registrationId = "registration-id";
35+
return new OAuth2AuthenticationToken(principal, authorities, registrationId);
36+
}
37+
}

0 commit comments

Comments
 (0)