Skip to content

Commit 4688eb1

Browse files
authored
feat: add support for OAuth2 logout configuration (#20820)
Improves setOAuth2LoginPage method in order to configure an OIDC logout succes shandler capable of handling redirection for UIDL requests. Post logout URL is by default the application root, but a method overload allows to specify a custom URL. Related-to #11026
1 parent fb0862f commit 4688eb1

File tree

2 files changed

+188
-14
lines changed

2 files changed

+188
-14
lines changed

vaadin-spring/src/main/java/com/vaadin/flow/spring/security/VaadinWebSecurity.java

+82-1
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@
4343
import org.springframework.security.config.annotation.web.builders.WebSecurity;
4444
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfiguration;
4545
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
46+
import org.springframework.security.oauth2.client.oidc.web.logout.OidcClientInitiatedLogoutSuccessHandler;
47+
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
4648
import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
4749
import org.springframework.security.web.DefaultSecurityFilterChain;
4850
import org.springframework.security.web.SecurityFilterChain;
@@ -53,7 +55,7 @@
5355
import org.springframework.security.web.authentication.HttpStatusEntryPoint;
5456
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
5557
import org.springframework.security.web.authentication.logout.LogoutHandler;
56-
import org.springframework.security.web.authentication.logout.SimpleUrlLogoutSuccessHandler;
58+
import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;
5759
import org.springframework.security.web.csrf.CsrfException;
5860
import org.springframework.security.web.savedrequest.RequestCache;
5961
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
@@ -499,6 +501,10 @@ protected void setLoginView(HttpSecurity http,
499501
/**
500502
* Sets up the login page URI of the OAuth2 provider on the specified
501503
* HttpSecurity instance.
504+
* <p>
505+
* </p>
506+
* This method also configures a logout success handler that redirects to
507+
* the application base URL after logout.
502508
*
503509
* @param http
504510
* the http security from {@link #filterChain(HttpSecurity)}
@@ -511,10 +517,85 @@ protected void setLoginView(HttpSecurity http,
511517
*/
512518
protected void setOAuth2LoginPage(HttpSecurity http, String oauth2LoginPage)
513519
throws Exception {
520+
setOAuth2LoginPage(http, oauth2LoginPage, "{baseUrl}");
521+
}
522+
523+
/**
524+
* Sets up the login page URI of the OAuth2 provider and the post logout URI
525+
* on the specified HttpSecurity instance.
526+
* <p>
527+
* </p>
528+
* The post logout redirect uri can be relative or absolute URI or a
529+
* template. The supported uri template variables are: {baseScheme},
530+
* {baseHost}, {basePort} and {basePath}.
531+
* <p>
532+
* </p>
533+
* NOTE: "{baseUrl}" is also supported, which is the same as
534+
* "{baseScheme}://{baseHost}{basePort}{basePath}" handler.
535+
* setPostLogoutRedirectUri("{baseUrl}");
536+
*
537+
* @param http
538+
* the http security from {@link #filterChain(HttpSecurity)}
539+
* @param oauth2LoginPage
540+
* the login page of the OAuth2 provider. This Specifies the URL
541+
* to send users to if login is required.
542+
* @param postLogoutRedirectUri
543+
* the post logout redirect uri. Can be a template.
544+
* @throws Exception
545+
* Re-throws the possible exceptions while activating
546+
* OAuth2LoginConfigurer
547+
*/
548+
protected void setOAuth2LoginPage(HttpSecurity http, String oauth2LoginPage,
549+
String postLogoutRedirectUri) throws Exception {
514550
http.oauth2Login(cfg -> cfg.loginPage(oauth2LoginPage).successHandler(
515551
getVaadinSavedRequestAwareAuthenticationSuccessHandler(http))
516552
.permitAll());
517553
accessControl.setLoginView(servletContextPath + oauth2LoginPage);
554+
if (postLogoutRedirectUri != null) {
555+
applicationContext
556+
.getBeanProvider(ClientRegistrationRepository.class)
557+
.getIfAvailable();
558+
var logoutSuccessHandler = oidcLogoutSuccessHandler(
559+
postLogoutRedirectUri);
560+
if (logoutSuccessHandler != null) {
561+
http.logout(
562+
cfg -> cfg.logoutSuccessHandler(logoutSuccessHandler));
563+
}
564+
}
565+
}
566+
567+
/**
568+
* Gets a {@code OidcClientInitiatedLogoutSuccessHandler} instance that
569+
* redirects to the given URL after logout.
570+
* <p>
571+
* </p>
572+
* If a {@code ClientRegistrationRepository} bean is not registered in the
573+
* application context, the method returns {@literal null}.
574+
*
575+
* @param postLogoutRedirectUri
576+
* the post logout redirect uri
577+
* @return a {@code OidcClientInitiatedLogoutSuccessHandler}, or
578+
* {@literal null} if a {@code ClientRegistrationRepository} bean is
579+
* not registered in the application context.
580+
*/
581+
// Using base interface as return type to avoid potential
582+
// ClassNotFoundException when Spring Boot introspect configuration class
583+
// during startup, if spring-security-oauth2-client is not on classpath
584+
protected LogoutSuccessHandler oidcLogoutSuccessHandler(
585+
String postLogoutRedirectUri) {
586+
var clientRegistrationRepository = applicationContext
587+
.getBeanProvider(ClientRegistrationRepository.class)
588+
.getIfAvailable();
589+
if (clientRegistrationRepository != null) {
590+
var logoutHandler = new OidcClientInitiatedLogoutSuccessHandler(
591+
clientRegistrationRepository);
592+
logoutHandler.setRedirectStrategy(new UidlRedirectStrategy());
593+
logoutHandler.setPostLogoutRedirectUri(postLogoutRedirectUri);
594+
return logoutHandler;
595+
}
596+
LoggerFactory.getLogger(VaadinWebSecurity.class).warn(
597+
"Cannot create OidcClientInitiatedLogoutSuccessHandler because ClientRegistrationRepository bean is not available.");
598+
return null;
518599
}
519600

520601
/**

vaadin-spring/src/test/java/com/vaadin/flow/spring/security/VaadinWebSecurityTest.java

+106-13
Original file line numberDiff line numberDiff line change
@@ -20,29 +20,37 @@
2020
import jakarta.servlet.http.HttpServletResponse;
2121

2222
import java.util.Map;
23+
import java.util.concurrent.atomic.AtomicReference;
2324
import java.util.function.Consumer;
2425

2526
import org.junit.Assert;
2627
import org.junit.Test;
2728
import org.junit.runner.RunWith;
2829
import org.mockito.Mockito;
30+
import org.springframework.beans.BeansException;
31+
import org.springframework.beans.factory.ObjectProvider;
2932
import org.springframework.beans.factory.annotation.Autowired;
3033
import org.springframework.context.ApplicationContext;
3134
import org.springframework.security.config.ObjectPostProcessor;
3235
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
3336
import org.springframework.security.config.annotation.configuration.ObjectPostProcessorConfiguration;
3437
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
3538
import org.springframework.security.config.annotation.web.builders.WebSecurity;
39+
import org.springframework.security.config.annotation.web.configurers.LogoutConfigurer;
3640
import org.springframework.security.core.Authentication;
37-
import com.vaadin.flow.spring.security.AuthenticationContext.CompositeLogoutHandler;
41+
import org.springframework.security.oauth2.client.oidc.web.logout.OidcClientInitiatedLogoutSuccessHandler;
42+
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
3843
import org.springframework.security.web.authentication.logout.LogoutHandler;
44+
import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;
3945
import org.springframework.test.context.ContextConfiguration;
4046
import org.springframework.test.context.junit4.SpringRunner;
4147
import org.springframework.test.util.ReflectionTestUtils;
4248

4349
import com.vaadin.flow.server.auth.NavigationAccessControl;
50+
import com.vaadin.flow.spring.security.AuthenticationContext.CompositeLogoutHandler;
4451

4552
import static org.mockito.ArgumentMatchers.any;
53+
import static org.mockito.ArgumentMatchers.anyString;
4654
import static org.mockito.Mockito.mock;
4755

4856
@RunWith(SpringRunner.class)
@@ -83,12 +91,7 @@ public void navigationAccessControl_enabledByDefault() throws Exception {
8391
Map.of(ApplicationContext.class, appCtx));
8492
VaadinWebSecurity testConfig = new VaadinWebSecurity() {
8593
};
86-
NavigationAccessControl accessControl = new NavigationAccessControl();
87-
ReflectionTestUtils.setField(testConfig, "accessControl",
88-
accessControl);
89-
RequestUtil requestUtil = mock(RequestUtil.class);
90-
Mockito.when(requestUtil.getUrlMapping()).thenReturn("/*");
91-
ReflectionTestUtils.setField(testConfig, "requestUtil", requestUtil);
94+
mockVaadinWebSecurityInjection(testConfig);
9295

9396
testConfig.filterChain(httpSecurity);
9497
Assert.assertTrue(
@@ -108,19 +111,101 @@ protected boolean enableNavigationAccessControl() {
108111
return false;
109112
}
110113
};
111-
NavigationAccessControl accessControl = new NavigationAccessControl();
112-
ReflectionTestUtils.setField(testConfig, "accessControl",
113-
accessControl);
114-
RequestUtil requestUtil = mock(RequestUtil.class);
115-
Mockito.when(requestUtil.getUrlMapping()).thenReturn("/*");
116-
ReflectionTestUtils.setField(testConfig, "requestUtil", requestUtil);
114+
mockVaadinWebSecurityInjection(testConfig);
117115

118116
testConfig.filterChain(httpSecurity);
119117
Assert.assertFalse(
120118
"Expecting navigation access control to be disable by VaadinWebSecurity subclass",
121119
testConfig.getNavigationAccessControl().isEnabled());
122120
}
123121

122+
@Test
123+
public void filterChain_oauth2login_configuresLoginPageAndLogoutHandler()
124+
throws Exception {
125+
assertOauth2Configuration(null);
126+
assertOauth2Configuration("/session-ended");
127+
}
128+
129+
private void assertOauth2Configuration(String postLogoutUri)
130+
throws Exception {
131+
String expectedLogoutUri = postLogoutUri != null ? postLogoutUri
132+
: "{baseUrl}";
133+
HttpSecurity httpSecurity = new HttpSecurity(postProcessor,
134+
new AuthenticationManagerBuilder(postProcessor),
135+
Map.of(ApplicationContext.class, appCtx));
136+
AtomicReference<String> postLogoutUriHolder = new AtomicReference<>(
137+
"NOT SET");
138+
VaadinWebSecurity testConfig = new VaadinWebSecurity() {
139+
@Override
140+
protected void configure(HttpSecurity http) throws Exception {
141+
super.configure(http);
142+
if (postLogoutUri != null) {
143+
setOAuth2LoginPage(http, "/externalLogin", postLogoutUri);
144+
} else {
145+
setOAuth2LoginPage(http, "/externalLogin");
146+
}
147+
}
148+
149+
@Override
150+
protected LogoutSuccessHandler oidcLogoutSuccessHandler(
151+
String postLogoutRedirectUri) {
152+
postLogoutUriHolder.set(postLogoutRedirectUri);
153+
return super.oidcLogoutSuccessHandler(postLogoutRedirectUri);
154+
}
155+
};
156+
TestNavigationAccessControl accessControl = mockVaadinWebSecurityInjection(
157+
testConfig);
158+
ClientRegistrationRepository repository = mock(
159+
ClientRegistrationRepository.class);
160+
ObjectProvider<ClientRegistrationRepository> provider = new ObjectProvider<ClientRegistrationRepository>() {
161+
@Override
162+
public ClientRegistrationRepository getObject()
163+
throws BeansException {
164+
return repository;
165+
}
166+
};
167+
ApplicationContext appCtx = Mockito.mock(ApplicationContext.class);
168+
Mockito.when(appCtx.getBeanProvider(ClientRegistrationRepository.class))
169+
.thenReturn(provider);
170+
ReflectionTestUtils.setField(testConfig, "applicationContext", appCtx);
171+
httpSecurity.setSharedObject(ClientRegistrationRepository.class,
172+
repository);
173+
174+
testConfig.filterChain(httpSecurity);
175+
176+
Assert.assertEquals("/externalLogin", accessControl.getLoginUrl());
177+
LogoutSuccessHandler logoutSuccessHandler = httpSecurity
178+
.getConfigurer(LogoutConfigurer.class)
179+
.getLogoutSuccessHandler();
180+
Assert.assertNotNull("Expected logout success handler to be configured",
181+
logoutSuccessHandler);
182+
Assert.assertTrue(
183+
"Expected logout success handler to be of type OidcClientInitiatedLogoutSuccessHandler, but was "
184+
+ logoutSuccessHandler.getClass().getName(),
185+
logoutSuccessHandler instanceof OidcClientInitiatedLogoutSuccessHandler);
186+
Assert.assertEquals("Unexpected post logout uri", expectedLogoutUri,
187+
postLogoutUriHolder.get());
188+
}
189+
190+
private static TestNavigationAccessControl mockVaadinWebSecurityInjection(
191+
VaadinWebSecurity testConfig) {
192+
TestNavigationAccessControl accessControl = new TestNavigationAccessControl();
193+
ReflectionTestUtils.setField(testConfig, "accessControl",
194+
accessControl);
195+
RequestUtil requestUtil = mock(RequestUtil.class);
196+
Mockito.when(requestUtil.getUrlMapping()).thenReturn("/*");
197+
Mockito.when(requestUtil.applyUrlMapping(anyString())).then(i -> {
198+
String path = i.getArgument(0, String.class);
199+
if (!path.startsWith("/")) {
200+
path = "/" + path;
201+
}
202+
return path;
203+
});
204+
ReflectionTestUtils.setField(testConfig, "requestUtil", requestUtil);
205+
ReflectionTestUtils.setField(testConfig, "servletContextPath", "");
206+
return accessControl;
207+
}
208+
124209
static class TestConfig extends VaadinWebSecurity {
125210
LogoutHandler handler1 = mock(LogoutHandler.class);
126211
LogoutHandler handler2 = mock(LogoutHandler.class);
@@ -144,4 +229,12 @@ protected void addLogoutHandlers(Consumer<LogoutHandler> registry) {
144229
}
145230
}
146231

232+
static class TestNavigationAccessControl extends NavigationAccessControl {
233+
234+
@Override
235+
protected String getLoginUrl() {
236+
return super.getLoginUrl();
237+
}
238+
}
239+
147240
}

0 commit comments

Comments
 (0)