Skip to content

Commit

Permalink
feat: add support for OAuth2 logout configuration (#20820)
Browse files Browse the repository at this point in the history
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
  • Loading branch information
mcollovati authored Jan 10, 2025
1 parent fb0862f commit 4688eb1
Show file tree
Hide file tree
Showing 2 changed files with 188 additions and 14 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfiguration;
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
import org.springframework.security.oauth2.client.oidc.web.logout.OidcClientInitiatedLogoutSuccessHandler;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
import org.springframework.security.web.DefaultSecurityFilterChain;
import org.springframework.security.web.SecurityFilterChain;
Expand All @@ -53,7 +55,7 @@
import org.springframework.security.web.authentication.HttpStatusEntryPoint;
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
import org.springframework.security.web.authentication.logout.LogoutHandler;
import org.springframework.security.web.authentication.logout.SimpleUrlLogoutSuccessHandler;
import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;
import org.springframework.security.web.csrf.CsrfException;
import org.springframework.security.web.savedrequest.RequestCache;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
Expand Down Expand Up @@ -499,6 +501,10 @@ protected void setLoginView(HttpSecurity http,
/**
* Sets up the login page URI of the OAuth2 provider on the specified
* HttpSecurity instance.
* <p>
* </p>
* This method also configures a logout success handler that redirects to
* the application base URL after logout.
*
* @param http
* the http security from {@link #filterChain(HttpSecurity)}
Expand All @@ -511,10 +517,85 @@ protected void setLoginView(HttpSecurity http,
*/
protected void setOAuth2LoginPage(HttpSecurity http, String oauth2LoginPage)
throws Exception {
setOAuth2LoginPage(http, oauth2LoginPage, "{baseUrl}");
}

/**
* Sets up the login page URI of the OAuth2 provider and the post logout URI
* on the specified HttpSecurity instance.
* <p>
* </p>
* The post logout redirect uri can be relative or absolute URI or a
* template. The supported uri template variables are: {baseScheme},
* {baseHost}, {basePort} and {basePath}.
* <p>
* </p>
* NOTE: "{baseUrl}" is also supported, which is the same as
* "{baseScheme}://{baseHost}{basePort}{basePath}" handler.
* setPostLogoutRedirectUri("{baseUrl}");
*
* @param http
* the http security from {@link #filterChain(HttpSecurity)}
* @param oauth2LoginPage
* the login page of the OAuth2 provider. This Specifies the URL
* to send users to if login is required.
* @param postLogoutRedirectUri
* the post logout redirect uri. Can be a template.
* @throws Exception
* Re-throws the possible exceptions while activating
* OAuth2LoginConfigurer
*/
protected void setOAuth2LoginPage(HttpSecurity http, String oauth2LoginPage,
String postLogoutRedirectUri) throws Exception {
http.oauth2Login(cfg -> cfg.loginPage(oauth2LoginPage).successHandler(
getVaadinSavedRequestAwareAuthenticationSuccessHandler(http))
.permitAll());
accessControl.setLoginView(servletContextPath + oauth2LoginPage);
if (postLogoutRedirectUri != null) {
applicationContext
.getBeanProvider(ClientRegistrationRepository.class)
.getIfAvailable();
var logoutSuccessHandler = oidcLogoutSuccessHandler(
postLogoutRedirectUri);
if (logoutSuccessHandler != null) {
http.logout(
cfg -> cfg.logoutSuccessHandler(logoutSuccessHandler));
}
}
}

/**
* Gets a {@code OidcClientInitiatedLogoutSuccessHandler} instance that
* redirects to the given URL after logout.
* <p>
* </p>
* If a {@code ClientRegistrationRepository} bean is not registered in the
* application context, the method returns {@literal null}.
*
* @param postLogoutRedirectUri
* the post logout redirect uri
* @return a {@code OidcClientInitiatedLogoutSuccessHandler}, or
* {@literal null} if a {@code ClientRegistrationRepository} bean is
* not registered in the application context.
*/
// Using base interface as return type to avoid potential
// ClassNotFoundException when Spring Boot introspect configuration class
// during startup, if spring-security-oauth2-client is not on classpath
protected LogoutSuccessHandler oidcLogoutSuccessHandler(
String postLogoutRedirectUri) {
var clientRegistrationRepository = applicationContext
.getBeanProvider(ClientRegistrationRepository.class)
.getIfAvailable();
if (clientRegistrationRepository != null) {
var logoutHandler = new OidcClientInitiatedLogoutSuccessHandler(
clientRegistrationRepository);
logoutHandler.setRedirectStrategy(new UidlRedirectStrategy());
logoutHandler.setPostLogoutRedirectUri(postLogoutRedirectUri);
return logoutHandler;
}
LoggerFactory.getLogger(VaadinWebSecurity.class).warn(
"Cannot create OidcClientInitiatedLogoutSuccessHandler because ClientRegistrationRepository bean is not available.");
return null;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,29 +20,37 @@
import jakarta.servlet.http.HttpServletResponse;

import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer;

import org.junit.Assert;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mockito;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;
import org.springframework.security.config.ObjectPostProcessor;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.configuration.ObjectPostProcessorConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configurers.LogoutConfigurer;
import org.springframework.security.core.Authentication;
import com.vaadin.flow.spring.security.AuthenticationContext.CompositeLogoutHandler;
import org.springframework.security.oauth2.client.oidc.web.logout.OidcClientInitiatedLogoutSuccessHandler;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.security.web.authentication.logout.LogoutHandler;
import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.util.ReflectionTestUtils;

import com.vaadin.flow.server.auth.NavigationAccessControl;
import com.vaadin.flow.spring.security.AuthenticationContext.CompositeLogoutHandler;

import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;

@RunWith(SpringRunner.class)
Expand Down Expand Up @@ -83,12 +91,7 @@ public void navigationAccessControl_enabledByDefault() throws Exception {
Map.of(ApplicationContext.class, appCtx));
VaadinWebSecurity testConfig = new VaadinWebSecurity() {
};
NavigationAccessControl accessControl = new NavigationAccessControl();
ReflectionTestUtils.setField(testConfig, "accessControl",
accessControl);
RequestUtil requestUtil = mock(RequestUtil.class);
Mockito.when(requestUtil.getUrlMapping()).thenReturn("/*");
ReflectionTestUtils.setField(testConfig, "requestUtil", requestUtil);
mockVaadinWebSecurityInjection(testConfig);

testConfig.filterChain(httpSecurity);
Assert.assertTrue(
Expand All @@ -108,19 +111,101 @@ protected boolean enableNavigationAccessControl() {
return false;
}
};
NavigationAccessControl accessControl = new NavigationAccessControl();
ReflectionTestUtils.setField(testConfig, "accessControl",
accessControl);
RequestUtil requestUtil = mock(RequestUtil.class);
Mockito.when(requestUtil.getUrlMapping()).thenReturn("/*");
ReflectionTestUtils.setField(testConfig, "requestUtil", requestUtil);
mockVaadinWebSecurityInjection(testConfig);

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

@Test
public void filterChain_oauth2login_configuresLoginPageAndLogoutHandler()
throws Exception {
assertOauth2Configuration(null);
assertOauth2Configuration("/session-ended");
}

private void assertOauth2Configuration(String postLogoutUri)
throws Exception {
String expectedLogoutUri = postLogoutUri != null ? postLogoutUri
: "{baseUrl}";
HttpSecurity httpSecurity = new HttpSecurity(postProcessor,
new AuthenticationManagerBuilder(postProcessor),
Map.of(ApplicationContext.class, appCtx));
AtomicReference<String> postLogoutUriHolder = new AtomicReference<>(
"NOT SET");
VaadinWebSecurity testConfig = new VaadinWebSecurity() {
@Override
protected void configure(HttpSecurity http) throws Exception {
super.configure(http);
if (postLogoutUri != null) {
setOAuth2LoginPage(http, "/externalLogin", postLogoutUri);
} else {
setOAuth2LoginPage(http, "/externalLogin");
}
}

@Override
protected LogoutSuccessHandler oidcLogoutSuccessHandler(
String postLogoutRedirectUri) {
postLogoutUriHolder.set(postLogoutRedirectUri);
return super.oidcLogoutSuccessHandler(postLogoutRedirectUri);
}
};
TestNavigationAccessControl accessControl = mockVaadinWebSecurityInjection(
testConfig);
ClientRegistrationRepository repository = mock(
ClientRegistrationRepository.class);
ObjectProvider<ClientRegistrationRepository> provider = new ObjectProvider<ClientRegistrationRepository>() {
@Override
public ClientRegistrationRepository getObject()
throws BeansException {
return repository;
}
};
ApplicationContext appCtx = Mockito.mock(ApplicationContext.class);
Mockito.when(appCtx.getBeanProvider(ClientRegistrationRepository.class))
.thenReturn(provider);
ReflectionTestUtils.setField(testConfig, "applicationContext", appCtx);
httpSecurity.setSharedObject(ClientRegistrationRepository.class,
repository);

testConfig.filterChain(httpSecurity);

Assert.assertEquals("/externalLogin", accessControl.getLoginUrl());
LogoutSuccessHandler logoutSuccessHandler = httpSecurity
.getConfigurer(LogoutConfigurer.class)
.getLogoutSuccessHandler();
Assert.assertNotNull("Expected logout success handler to be configured",
logoutSuccessHandler);
Assert.assertTrue(
"Expected logout success handler to be of type OidcClientInitiatedLogoutSuccessHandler, but was "
+ logoutSuccessHandler.getClass().getName(),
logoutSuccessHandler instanceof OidcClientInitiatedLogoutSuccessHandler);
Assert.assertEquals("Unexpected post logout uri", expectedLogoutUri,
postLogoutUriHolder.get());
}

private static TestNavigationAccessControl mockVaadinWebSecurityInjection(
VaadinWebSecurity testConfig) {
TestNavigationAccessControl accessControl = new TestNavigationAccessControl();
ReflectionTestUtils.setField(testConfig, "accessControl",
accessControl);
RequestUtil requestUtil = mock(RequestUtil.class);
Mockito.when(requestUtil.getUrlMapping()).thenReturn("/*");
Mockito.when(requestUtil.applyUrlMapping(anyString())).then(i -> {
String path = i.getArgument(0, String.class);
if (!path.startsWith("/")) {
path = "/" + path;
}
return path;
});
ReflectionTestUtils.setField(testConfig, "requestUtil", requestUtil);
ReflectionTestUtils.setField(testConfig, "servletContextPath", "");
return accessControl;
}

static class TestConfig extends VaadinWebSecurity {
LogoutHandler handler1 = mock(LogoutHandler.class);
LogoutHandler handler2 = mock(LogoutHandler.class);
Expand All @@ -144,4 +229,12 @@ protected void addLogoutHandlers(Consumer<LogoutHandler> registry) {
}
}

static class TestNavigationAccessControl extends NavigationAccessControl {

@Override
protected String getLoginUrl() {
return super.getLoginUrl();
}
}

}

0 comments on commit 4688eb1

Please sign in to comment.