From b75b3dfe7bb81275b376faac85e02a46f99c410d Mon Sep 17 00:00:00 2001 From: Josh Cummings Date: Tue, 17 Jan 2023 17:25:16 -0700 Subject: [PATCH 01/12] Add OIDC Back-Channel Logout Support --- .../annotation/web/builders/HttpSecurity.java | 11 + .../SessionManagementConfigurer.java | 2 +- .../oauth2/client/OidcLogoutConfigurer.java | 281 ++++++++++ .../client/OidcLogoutConfigurerTests.java | 495 ++++++++++++++++++ .../core/session/SessionInformation.java | 4 + etc/nohttp/allowlist.lines | 3 +- .../logout/LogoutTokenClaimAccessor.java | 98 ++++ .../logout/LogoutTokenClaimNames.java | 71 +++ .../OidcBackChannelLogoutAuthentication.java | 56 ++ ...ackChannelLogoutAuthenticationManager.java | 54 ++ ...cBackChannelLogoutAuthenticationToken.java | 53 ++ .../logout/OidcLogoutToken.java | 219 ++++++++ .../logout/OidcLogoutTokenDecoderFactory.java | 59 +++ .../InMemoryOidcProviderSessionRegistry.java | 118 +++++ .../OidcProviderSessionRegistration.java | 81 +++ ...idcProviderSessionRegistrationDetails.java | 48 ++ .../session/OidcProviderSessionRegistry.java | 66 +++ .../logout/OidcBackChannelLogoutFilter.java | 205 ++++++++ .../NimbusLogoutTokenDecoderFactoryTests.java | 41 ++ .../logout/TestOidcLogoutTokens.java | 50 ++ ...emoryOidcProviderSessionRegistryTests.java | 101 ++++ .../TestOidcProviderSessionRegistrations.java | 34 ++ .../OidcBackChannelLogoutFilterTests.java | 154 ++++++ .../BackchannelLogoutAuthentication.java | 55 ++ .../logout/BackchannelLogoutHandler.java | 83 +++ 25 files changed, 2440 insertions(+), 2 deletions(-) create mode 100644 config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurer.java create mode 100644 config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurerTests.java create mode 100644 oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/LogoutTokenClaimAccessor.java create mode 100644 oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/LogoutTokenClaimNames.java create mode 100644 oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/OidcBackChannelLogoutAuthentication.java create mode 100644 oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/OidcBackChannelLogoutAuthenticationManager.java create mode 100644 oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/OidcBackChannelLogoutAuthenticationToken.java create mode 100644 oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/OidcLogoutToken.java create mode 100644 oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/OidcLogoutTokenDecoderFactory.java create mode 100644 oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/session/InMemoryOidcProviderSessionRegistry.java create mode 100644 oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/session/OidcProviderSessionRegistration.java create mode 100644 oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/session/OidcProviderSessionRegistrationDetails.java create mode 100644 oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/session/OidcProviderSessionRegistry.java create mode 100644 oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/web/authentication/logout/OidcBackChannelLogoutFilter.java create mode 100644 oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/authentication/logout/NimbusLogoutTokenDecoderFactoryTests.java create mode 100644 oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/authentication/logout/TestOidcLogoutTokens.java create mode 100644 oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/authentication/session/InMemoryOidcProviderSessionRegistryTests.java create mode 100644 oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/authentication/session/TestOidcProviderSessionRegistrations.java create mode 100644 oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/web/authentication/logout/OidcBackChannelLogoutFilterTests.java create mode 100644 web/src/main/java/org/springframework/security/web/authentication/logout/BackchannelLogoutAuthentication.java create mode 100644 web/src/main/java/org/springframework/security/web/authentication/logout/BackchannelLogoutHandler.java diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/builders/HttpSecurity.java b/config/src/main/java/org/springframework/security/config/annotation/web/builders/HttpSecurity.java index a72f90c6af7..603184a44ba 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/builders/HttpSecurity.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/builders/HttpSecurity.java @@ -70,6 +70,7 @@ import org.springframework.security.config.annotation.web.configurers.X509Configurer; import org.springframework.security.config.annotation.web.configurers.oauth2.client.OAuth2ClientConfigurer; import org.springframework.security.config.annotation.web.configurers.oauth2.client.OAuth2LoginConfigurer; +import org.springframework.security.config.annotation.web.configurers.oauth2.client.OidcLogoutConfigurer; import org.springframework.security.config.annotation.web.configurers.oauth2.server.resource.OAuth2ResourceServerConfigurer; import org.springframework.security.config.annotation.web.configurers.saml2.Saml2LoginConfigurer; import org.springframework.security.config.annotation.web.configurers.saml2.Saml2LogoutConfigurer; @@ -2835,6 +2836,16 @@ public HttpSecurity oauth2Login(Customizer> return HttpSecurity.this; } + public OidcLogoutConfigurer oauth2Logout() throws Exception { + return getOrApply(new OidcLogoutConfigurer<>()); + } + + public HttpSecurity oauth2Logout(Customizer> oauth2LogoutCustomizer) + throws Exception { + oauth2LogoutCustomizer.customize(getOrApply(new OidcLogoutConfigurer<>())); + return HttpSecurity.this; + } + /** * Configures OAuth 2.0 Client support. * @return the {@link OAuth2ClientConfigurer} for further customizations diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/SessionManagementConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/SessionManagementConfigurer.java index aecc4506904..74229059f06 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/SessionManagementConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/SessionManagementConfigurer.java @@ -296,7 +296,7 @@ public SessionManagementConfigurer sessionAuthenticationStrategy( * @param sessionAuthenticationStrategy * @return the {@link SessionManagementConfigurer} for further customizations */ - SessionManagementConfigurer addSessionAuthenticationStrategy( + public SessionManagementConfigurer addSessionAuthenticationStrategy( SessionAuthenticationStrategy sessionAuthenticationStrategy) { this.sessionAuthenticationStrategies.add(sessionAuthenticationStrategy); return this; diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurer.java new file mode 100644 index 00000000000..0c5f741a12d --- /dev/null +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurer.java @@ -0,0 +1,281 @@ +/* + * Copyright 2002-2023 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.config.annotation.web.configurers.oauth2.client; + +import java.util.function.Consumer; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.beans.factory.NoSuchBeanDefinitionException; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationListener; +import org.springframework.context.event.GenericApplicationListenerAdapter; +import org.springframework.context.event.SmartApplicationListener; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.web.HttpSecurityBuilder; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.annotation.web.configurers.SessionManagementConfigurer; +import org.springframework.security.context.DelegatingApplicationListener; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.session.AbstractSessionEvent; +import org.springframework.security.core.session.SessionDestroyedEvent; +import org.springframework.security.core.session.SessionIdChangedEvent; +import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcBackChannelLogoutAuthenticationManager; +import org.springframework.security.oauth2.client.oidc.authentication.session.InMemoryOidcProviderSessionRegistry; +import org.springframework.security.oauth2.client.oidc.authentication.session.OidcProviderSessionRegistration; +import org.springframework.security.oauth2.client.oidc.authentication.session.OidcProviderSessionRegistry; +import org.springframework.security.oauth2.client.oidc.web.authentication.logout.OidcBackChannelLogoutFilter; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; +import org.springframework.security.web.authentication.logout.BackchannelLogoutHandler; +import org.springframework.security.web.authentication.logout.LogoutHandler; +import org.springframework.security.web.authentication.session.SessionAuthenticationException; +import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy; +import org.springframework.security.web.csrf.CsrfFilter; +import org.springframework.security.web.csrf.CsrfToken; +import org.springframework.util.Assert; + +/** + * An {@link AbstractHttpConfigurer} for OAuth 2.0 Logout flows + * + *

+ * OAuth 2.0 Logout provides an application with the capability to have users log out by + * using their existing account at an OAuth 2.0 or OpenID Connect 1.0 Provider. + * + * + *

Security Filters

+ * + * The following {@code Filter} is populated: + * + *
    + *
  • {@link OidcBackChannelLogoutFilter}
  • + *
+ * + *

Shared Objects Used

+ * + * The following shared objects are used: + * + *
    + *
  • {@link ClientRegistrationRepository}
  • + *
+ * + * @author Josh Cummings + * @since 6.1 + * @see HttpSecurity#oauth2Logout() + * @see OidcBackChannelLogoutFilter + * @see ClientRegistrationRepository + */ +public final class OidcLogoutConfigurer> + extends AbstractHttpConfigurer, B> { + + private BackChannelLogoutConfigurer backChannel; + + /** + * Sets the repository of client registrations. + * @param clientRegistrationRepository the repository of client registrations + * @return the {@link OidcLogoutConfigurer} for further configuration + */ + public OidcLogoutConfigurer backChannel(Consumer backChannelLogoutConfigurer) { + if (this.backChannel == null) { + this.backChannel = new BackChannelLogoutConfigurer(); + } + backChannelLogoutConfigurer.accept(this.backChannel); + return this; + } + + public B and() { + return getBuilder(); + } + + @Override + public void configure(B builder) throws Exception { + if (this.backChannel != null) { + this.backChannel.configure(builder); + } + } + + private void registerDelegateApplicationListener(ApplicationListener delegate) { + DelegatingApplicationListener delegating = getBeanOrNull(DelegatingApplicationListener.class); + if (delegating == null) { + return; + } + SmartApplicationListener smartListener = new GenericApplicationListenerAdapter(delegate); + delegating.addListener(smartListener); + } + + private T getBeanOrNull(Class type) { + ApplicationContext context = getBuilder().getSharedObject(ApplicationContext.class); + if (context == null) { + return null; + } + try { + return context.getBean(type); + } + catch (NoSuchBeanDefinitionException ex) { + return null; + } + } + + public final class BackChannelLogoutConfigurer { + + private LogoutHandler logoutHandler = new BackchannelLogoutHandler(); + + private AuthenticationManager authenticationManager = new OidcBackChannelLogoutAuthenticationManager(); + + private OidcProviderSessionRegistry providerSessionRegistry = new InMemoryOidcProviderSessionRegistry(); + + public BackChannelLogoutConfigurer clientLogoutHandler(LogoutHandler logoutHandler) { + Assert.notNull(logoutHandler, "logoutHandler cannot be null"); + this.logoutHandler = logoutHandler; + return this; + } + + public BackChannelLogoutConfigurer authenticationManager(AuthenticationManager authenticationManager) { + Assert.notNull(authenticationManager, "authenticationManager cannot be null"); + this.authenticationManager = authenticationManager; + return this; + } + + public BackChannelLogoutConfigurer oidcProviderSessionRegistry( + OidcProviderSessionRegistry providerSessionRegistry) { + Assert.notNull(providerSessionRegistry, "providerSessionRegistry cannot be null"); + this.providerSessionRegistry = providerSessionRegistry; + return this; + } + + private AuthenticationManager authenticationManager() { + return this.authenticationManager; + } + + private OidcProviderSessionRegistry oidcProviderSessionRegistry() { + return this.providerSessionRegistry; + } + + private LogoutHandler logoutHandler() { + return this.logoutHandler; + } + + private SessionAuthenticationStrategy sessionAuthenticationStrategy() { + OidcProviderSessionAuthenticationStrategy strategy = new OidcProviderSessionAuthenticationStrategy(); + strategy.setProviderSessionRegistry(oidcProviderSessionRegistry()); + return strategy; + } + + void configure(B http) { + ClientRegistrationRepository clientRegistrationRepository = OAuth2ClientConfigurerUtils + .getClientRegistrationRepository(http); + OidcBackChannelLogoutFilter filter = new OidcBackChannelLogoutFilter(clientRegistrationRepository, + authenticationManager()); + filter.setProviderSessionRegistry(oidcProviderSessionRegistry()); + LogoutHandler expiredStrategy = logoutHandler(); + filter.setLogoutHandler(expiredStrategy); + http.addFilterBefore(filter, CsrfFilter.class); + SessionManagementConfigurer sessionConfigurer = http.getConfigurer(SessionManagementConfigurer.class); + if (sessionConfigurer != null) { + sessionConfigurer.addSessionAuthenticationStrategy(sessionAuthenticationStrategy()); + } + OidcClientSessionEventListener listener = new OidcClientSessionEventListener(); + listener.setProviderSessionRegistry(this.providerSessionRegistry); + registerDelegateApplicationListener(listener); + } + + static final class OidcClientSessionEventListener implements ApplicationListener { + + private final Log logger = LogFactory.getLog(OidcClientSessionEventListener.class); + + private OidcProviderSessionRegistry providerSessionRegistry = new InMemoryOidcProviderSessionRegistry(); + + /** + * {@inheritDoc} + */ + @Override + public void onApplicationEvent(AbstractSessionEvent event) { + if (event instanceof SessionDestroyedEvent destroyed) { + this.logger.debug("Received SessionDestroyedEvent"); + this.providerSessionRegistry.deregister(destroyed.getId()); + return; + } + if (event instanceof SessionIdChangedEvent changed) { + this.logger.debug("Received SessionIdChangedEvent"); + this.providerSessionRegistry.reregister(changed.getOldSessionId(), changed.getNewSessionId()); + } + } + + /** + * The registry where OIDC Provider sessions are linked to the Client session. + * Defaults to in-memory storage. + * @param providerSessionRegistry the {@link OidcProviderSessionRegistry} to + * use + */ + void setProviderSessionRegistry(OidcProviderSessionRegistry providerSessionRegistry) { + Assert.notNull(providerSessionRegistry, "providerSessionRegistry cannot be null"); + this.providerSessionRegistry = providerSessionRegistry; + } + + } + + static final class OidcProviderSessionAuthenticationStrategy implements SessionAuthenticationStrategy { + + private final Log logger = LogFactory.getLog(getClass()); + + private OidcProviderSessionRegistry providerSessionRegistry = new InMemoryOidcProviderSessionRegistry(); + + /** + * {@inheritDoc} + */ + @Override + public void onAuthentication(Authentication authentication, HttpServletRequest request, HttpServletResponse response) throws SessionAuthenticationException { + HttpSession session = request.getSession(false); + if (session == null) { + return; + } + if (authentication == null) { + return; + } + if (!(authentication.getPrincipal() instanceof OidcUser user)) { + return; + } + String sessionId = session.getId(); + CsrfToken csrfToken = (CsrfToken) request.getAttribute(CsrfToken.class.getName()); + OidcProviderSessionRegistration registration = new OidcProviderSessionRegistration(sessionId, csrfToken, user); + if (this.logger.isTraceEnabled()) { + this.logger.trace(String.format("Linking a provider [%s] session to this client's session", user.getIssuer())); + } + this.providerSessionRegistry.register(registration); + } + + /** + * The registration for linking OIDC Provider Session information to the + * Client's session. Defaults to in-memory. + * @param providerSessionRegistry the {@link OidcProviderSessionRegistry} to + * use + */ + void setProviderSessionRegistry(OidcProviderSessionRegistry providerSessionRegistry) { + Assert.notNull(providerSessionRegistry, "providerSessionRegistry cannot be null"); + this.providerSessionRegistry = providerSessionRegistry; + } + + } + + } + +} diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurerTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurerTests.java new file mode 100644 index 00000000000..d59f42124a6 --- /dev/null +++ b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurerTests.java @@ -0,0 +1,495 @@ +/* + * Copyright 2002-2023 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.config.annotation.web.configurers.oauth2.client; + +import java.io.IOException; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.interfaces.RSAPublicKey; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +import com.gargoylesoftware.htmlunit.util.UrlUtils; +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jose.jwk.source.ImmutableJWKSet; +import com.nimbusds.jose.jwk.source.JWKSource; +import com.nimbusds.jose.proc.SecurityContext; +import com.nimbusds.oauth2.sdk.Scope; +import com.nimbusds.oauth2.sdk.token.BearerAccessToken; +import com.nimbusds.openid.connect.sdk.token.OIDCTokens; +import jakarta.annotation.PreDestroy; +import okhttp3.mockwebserver.Dispatcher; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.core.annotation.Order; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.mock.web.MockHttpSession; +import org.springframework.mock.web.MockServletContext; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.test.SpringTestContext; +import org.springframework.security.config.test.SpringTestContextExtension; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.oauth2.client.oidc.authentication.logout.LogoutTokenClaimNames; +import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcBackChannelLogoutAuthentication; +import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcLogoutToken; +import org.springframework.security.oauth2.client.oidc.authentication.logout.TestOidcLogoutTokens; +import org.springframework.security.oauth2.client.oidc.authentication.session.OidcProviderSessionRegistrationDetails; +import org.springframework.security.oauth2.client.oidc.authentication.session.OidcProviderSessionRegistry; +import org.springframework.security.oauth2.client.oidc.authentication.session.TestOidcProviderSessionRegistrations; +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.OidcIdToken; +import org.springframework.security.oauth2.core.oidc.TestOidcIdTokens; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; +import org.springframework.security.oauth2.jwt.JwtClaimsSet; +import org.springframework.security.oauth2.jwt.JwtEncoder; +import org.springframework.security.oauth2.jwt.JwtEncoderParameters; +import org.springframework.security.oauth2.jwt.NimbusJwtEncoder; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.logout.LogoutHandler; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.config.annotation.EnableWebMvc; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@ExtendWith(SpringTestContextExtension.class) +public class OidcLogoutConfigurerTests { + + @Autowired + private MockMvc mvc; + + @Autowired(required = false) + private MockWebServer web; + + @Autowired + private ClientRegistration registration; + + public final SpringTestContext spring = new SpringTestContext(this); + + @Test + void logoutWhenDefaultsThenRemotelyInvalidatesSessions() throws Exception { + this.spring.register(WebServerConfig.class, OidcProviderConfig.class, DefaultConfig.class).autowire(); + MockMvcDispatcher dispatcher = (MockMvcDispatcher) this.web.getDispatcher(); + this.mvc.perform(get("/token/logout")).andExpect(status().isUnauthorized()); + String registrationId = this.registration.getRegistrationId(); + MvcResult result = this.mvc.perform(get("/oauth2/authorization/" + registrationId)) + .andExpect(status().isFound()).andReturn(); + MockHttpSession session = (MockHttpSession) result.getRequest().getSession(); + String redirectUrl = UrlUtils.decode(result.getResponse().getRedirectedUrl()); + String state = this.mvc + .perform(get(redirectUrl) + .with(httpBasic(this.registration.getClientId(), this.registration.getClientSecret()))) + .andReturn().getResponse().getContentAsString(); + result = this.mvc.perform(get("/login/oauth2/code/" + registrationId).param("code", "code") + .param("state", state).session(session)).andExpect(status().isFound()).andReturn(); + session = (MockHttpSession) result.getRequest().getSession(); + dispatcher.registerSession(session); + String logoutToken = this.mvc.perform(get("/token/logout").session(session)).andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + this.mvc.perform(post(this.web.url("/logout/connect/back-channel/" + registrationId).toString()) + .param("logout_token", logoutToken)).andExpect(status().isOk()); + this.mvc.perform(get("/token/logout").session(session)).andExpect(status().isUnauthorized()); + } + + @Test + void logoutWhenInvalidLogoutTokenThenBadRequest() throws Exception { + this.spring.register(WebServerConfig.class, OidcProviderConfig.class, DefaultConfig.class).autowire(); + this.mvc.perform(get("/token/logout")).andExpect(status().isUnauthorized()); + String registrationId = this.registration.getRegistrationId(); + MvcResult result = this.mvc.perform(get("/oauth2/authorization/" + registrationId)) + .andExpect(status().isFound()).andReturn(); + MockHttpSession session = (MockHttpSession) result.getRequest().getSession(); + String redirectUrl = UrlUtils.decode(result.getResponse().getRedirectedUrl()); + String state = this.mvc + .perform(get(redirectUrl) + .with(httpBasic(this.registration.getClientId(), this.registration.getClientSecret()))) + .andReturn().getResponse().getContentAsString(); + result = this.mvc.perform(get("/login/oauth2/code/" + registrationId).param("code", "code") + .param("state", state).session(session)).andExpect(status().isFound()).andReturn(); + session = (MockHttpSession) result.getRequest().getSession(); + this.mvc.perform(post(this.web.url("/logout/connect/back-channel/" + registrationId).toString()) + .param("logout_token", "invalid")).andExpect(status().isBadRequest()); + this.mvc.perform(post("/logout").with(csrf()).session(session)).andExpect(status().isFound()); + } + + @Test + void logoutWhenCustomComponentsThenUses() throws Exception { + this.spring.register(WithCustomComponentsConfig.class).autowire(); + String registrationId = this.registration.getRegistrationId(); + AuthenticationManager authenticationManager = this.spring.getContext().getBean(AuthenticationManager.class); + OidcLogoutToken logoutToken = TestOidcLogoutTokens.withSessionId("issuer", "provider").build(); + given(authenticationManager.authenticate(any())) + .willReturn(new OidcBackChannelLogoutAuthentication(logoutToken, this.registration)); + LogoutHandler logoutHandler = this.spring.getContext().getBean(LogoutHandler.class); + OidcProviderSessionRegistry registry = this.spring.getContext().getBean(OidcProviderSessionRegistry.class); + Set details = Set.of(TestOidcProviderSessionRegistrations.create()); + given(registry.deregister(any(OidcLogoutToken.class))).willReturn(details); + this.mvc.perform(post("/logout/connect/back-channel/" + registrationId).param("logout_token", "token")) + .andExpect(status().isOk()); + verify(registry).deregister(any(OidcLogoutToken.class)); + verify(authenticationManager).authenticate(any()); + verify(logoutHandler).logout(any(), any(), any()); + } + + @Configuration + static class RegistrationConfig { + + @Autowired(required = false) + MockWebServer web; + + @Bean + ClientRegistration registration() { + if (this.web == null) { + return TestClientRegistrations.clientRegistration().build(); + } + String issuer = this.web.url("/").toString(); + return TestClientRegistrations.clientRegistration().issuerUri(issuer).jwkSetUri(issuer + "jwks") + .tokenUri(issuer + "token").userInfoUri(issuer + "user").scope("openid").build(); + } + + @Bean + ClientRegistrationRepository registrations(ClientRegistration registration) { + return new InMemoryClientRegistrationRepository(registration); + } + + } + + @Configuration + @EnableWebSecurity + @Import(RegistrationConfig.class) + static class DefaultConfig { + + @Bean + @Order(1) + SecurityFilterChain filters(HttpSecurity http) throws Exception { + // @formatter:off + http + .authorizeHttpRequests((authorize) -> authorize.anyRequest().authenticated()) + .oauth2Login(Customizer.withDefaults()) + .oauth2Logout((oauth2) -> oauth2. + backChannel((backchannel) -> { }) + ); + // @formatter:on + + return http.build(); + } + + } + + @Configuration + @EnableWebSecurity + @Import(RegistrationConfig.class) + static class WithCustomComponentsConfig { + + AuthenticationManager authenticationManager = mock(AuthenticationManager.class); + + LogoutHandler logoutHandler = mock(LogoutHandler.class); + + OidcProviderSessionRegistry registry = mock(OidcProviderSessionRegistry.class); + + @Bean + @Order(1) + SecurityFilterChain filters(HttpSecurity http) throws Exception { + // @formatter:off + http + .authorizeHttpRequests((authorize) -> authorize.anyRequest().authenticated()) + .oauth2Login(Customizer.withDefaults()) + .oauth2Logout((oauth2) -> oauth2. + backChannel((backchannel) -> backchannel + .clientLogoutHandler(this.logoutHandler) + .authenticationManager(this.authenticationManager) + .oidcProviderSessionRegistry(this.registry) + ) + ); + // @formatter:on + + return http.build(); + } + + @Bean + AuthenticationManager authenticationManager() { + return this.authenticationManager; + } + + @Bean + LogoutHandler logoutHandler() { + return this.logoutHandler; + } + + @Bean + OidcProviderSessionRegistry providerSessionRegistry() { + return this.registry; + } + + } + + @Configuration + @EnableWebSecurity + @EnableWebMvc + @RestController + static class OidcProviderConfig { + + private static final RSAKey key = key(); + + private static final JWKSource jwks = jwks(key); + + private static RSAKey key() { + try { + KeyPair pair = KeyPairGenerator.getInstance("RSA").generateKeyPair(); + return new RSAKey.Builder((RSAPublicKey) pair.getPublic()).privateKey(pair.getPrivate()).build(); + } + catch (Exception ex) { + throw new RuntimeException(ex); + } + } + + private static JWKSource jwks(RSAKey key) { + try { + return new ImmutableJWKSet<>(new JWKSet(key)); + } + catch (Exception ex) { + throw new RuntimeException(ex); + } + } + + private final String username = "user"; + + private final String sessionId = "session-id"; + + private final JwtEncoder encoder = new NimbusJwtEncoder(jwks); + + private String nonce; + + @Autowired + ClientRegistration registration; + + @Bean + @Order(0) + SecurityFilterChain authorizationServer(HttpSecurity http, ClientRegistration registration) throws Exception { + // @formatter:off + http + .securityMatcher("/jwks", "/login/oauth/authorize", "/nonce", "/token", "/token/logout", "/user") + .authorizeHttpRequests((authorize) -> authorize + .requestMatchers("/jwks").permitAll() + .anyRequest().authenticated() + ) + .httpBasic(Customizer.withDefaults()) + .oauth2ResourceServer((oauth2) -> oauth2 + .jwt().jwkSetUri(registration.getProviderDetails().getJwkSetUri()) + ); + // @formatter:off + + return http.build(); + } + + @Bean + UserDetailsService users(ClientRegistration registration) { + return new InMemoryUserDetailsManager(User.withUsername(registration.getClientId()) + .password("{noop}" + registration.getClientSecret()).authorities("APP").build()); + } + + @GetMapping("/login/oauth/authorize") + String nonce(@RequestParam("nonce") String nonce, @RequestParam("state") String state) { + this.nonce = nonce; + return state; + } + + @PostMapping("/token") + Map accessToken() { + JwtEncoderParameters parameters = JwtEncoderParameters + .from(JwtClaimsSet.builder().id("id").subject(this.username) + .issuer(this.registration.getProviderDetails().getIssuerUri()).issuedAt(Instant.now()) + .expiresAt(Instant.now().plusSeconds(86400)).claim("scope", "openid").build()); + String token = this.encoder.encode(parameters).getTokenValue(); + return new OIDCTokens(idToken(), new BearerAccessToken(token, 86400, new Scope("openid")), null) + .toJSONObject(); + } + + String idToken() { + OidcIdToken token = TestOidcIdTokens.idToken().issuer(this.registration.getProviderDetails().getIssuerUri()) + .subject(this.username).expiresAt(Instant.now().plusSeconds(86400)) + .audience(List.of(this.registration.getClientId())).nonce(this.nonce) + .claim(LogoutTokenClaimNames.SID, this.sessionId).build(); + JwtEncoderParameters parameters = JwtEncoderParameters + .from(JwtClaimsSet.builder().claims((claims) -> claims.putAll(token.getClaims())).build()); + return this.encoder.encode(parameters).getTokenValue(); + } + + @GetMapping("/user") + Map userinfo() { + return Map.of("sub", this.username, "id", this.username); + } + + @GetMapping("/jwks") + String jwks() { + return new JWKSet(key).toString(); + } + + @GetMapping("/token/logout") + String logoutToken(@AuthenticationPrincipal OidcUser user) { + OidcLogoutToken token = TestOidcLogoutTokens.withUser(user) + .audience(List.of(this.registration.getClientId())).build(); + JwtEncoderParameters parameters = JwtEncoderParameters + .from(JwtClaimsSet.builder().claims((claims) -> claims.putAll(token.getClaims())).build()); + return this.encoder.encode(parameters).getTokenValue(); + } + } + + @Configuration + static class WebServerConfig { + + private final MockWebServer server = new MockWebServer(); + + @Bean + MockWebServer web(ObjectProvider mvc) { + this.server.setDispatcher(new MockMvcDispatcher(mvc)); + return this.server; + } + + @PreDestroy + void shutdown() throws IOException { + this.server.shutdown(); + } + + } + + private static class MockMvcDispatcher extends Dispatcher { + + private final Map session = new ConcurrentHashMap<>(); + + private final ObjectProvider mvcProvider; + + private MockMvc mvc; + + MockMvcDispatcher(ObjectProvider mvc) { + this.mvcProvider = mvc; + } + + @Override + public MockResponse dispatch(RecordedRequest request) throws InterruptedException { + this.mvc = this.mvcProvider.getObject(); + String method = request.getMethod(); + String path = request.getPath(); + String csrf = request.getHeader("X-CSRF-TOKEN"); + MockHttpSession session = session(request); + MockHttpServletRequestBuilder builder; + if ("GET".equals(method)) { + builder = get(path); + } + else { + builder = post(path).content(request.getBody().readUtf8()); + if (csrf != null) { + builder.header("X-CSRF-TOKEN", csrf); + } + else { + builder.with(csrf()); + } + } + for (Map.Entry> header : request.getHeaders().toMultimap().entrySet()) { + builder.header(header.getKey(), header.getValue().iterator().next()); + } + MockHttpServletResponse mvcResponse = perform(builder.session(session)).andReturn().getResponse(); + return toMockResponse(mvcResponse); + } + + void registerSession(MockHttpSession session) { + this.session.put(session.getId(), session); + } + + private MockHttpSession session(RecordedRequest request) { + String cookieHeaderValue = request.getHeader("Cookie"); + if (cookieHeaderValue == null) { + return new MockHttpSession(); + } + String[] cookies = cookieHeaderValue.split(";"); + for (String cookie : cookies) { + String[] parts = cookie.split("="); + if ("JSESSIONID".equals(parts[0])) { + return this.session.computeIfAbsent(parts[1], + (k) -> new MockHttpSession(new MockServletContext(), parts[1])); + } + } + return new MockHttpSession(); + } + + private ResultActions perform(MockHttpServletRequestBuilder builder) { + try { + return this.mvc.perform(builder); + } + catch (Exception ex) { + throw new RuntimeException(ex); + } + } + + private MockResponse toMockResponse(MockHttpServletResponse mvcResponse) { + MockResponse response = new MockResponse(); + response.setResponseCode(mvcResponse.getStatus()); + for (String name : mvcResponse.getHeaderNames()) { + response.addHeader(name, mvcResponse.getHeaderValue(name)); + } + response.setBody(getContentAsString(mvcResponse)); + return response; + } + + private String getContentAsString(MockHttpServletResponse response) { + try { + return response.getContentAsString(); + } + catch (Exception ex) { + throw new RuntimeException(ex); + } + } + + } + +} diff --git a/core/src/main/java/org/springframework/security/core/session/SessionInformation.java b/core/src/main/java/org/springframework/security/core/session/SessionInformation.java index 54b05bbbb08..ce0babe88f2 100644 --- a/core/src/main/java/org/springframework/security/core/session/SessionInformation.java +++ b/core/src/main/java/org/springframework/security/core/session/SessionInformation.java @@ -49,6 +49,10 @@ public class SessionInformation implements Serializable { private boolean expired = false; + public SessionInformation(Object principal, String sessionId) { + this(principal, sessionId, new Date()); + } + public SessionInformation(Object principal, String sessionId, Date lastRequest) { Assert.notNull(principal, "Principal required"); Assert.hasText(sessionId, "SessionId required"); diff --git a/etc/nohttp/allowlist.lines b/etc/nohttp/allowlist.lines index a378625640d..330ed0f5cec 100644 --- a/etc/nohttp/allowlist.lines +++ b/etc/nohttp/allowlist.lines @@ -10,4 +10,5 @@ ^http://www.w3.org/2001/04/xmlenc ^http://www.springframework.org/schema/security/.* ^http://openoffice.org/.* -^http://www.w3.org/2003/g/data-view \ No newline at end of file +^http://www.w3.org/2003/g/data-view +^http://schemas.openid.net/event/backchannel-logout diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/LogoutTokenClaimAccessor.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/LogoutTokenClaimAccessor.java new file mode 100644 index 00000000000..2a1b34aab3f --- /dev/null +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/LogoutTokenClaimAccessor.java @@ -0,0 +1,98 @@ +/* + * Copyright 2002-2023 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.oidc.authentication.logout; + +import java.net.URL; +import java.time.Instant; +import java.util.List; +import java.util.Map; + +import org.springframework.security.oauth2.core.ClaimAccessor; +import org.springframework.security.oauth2.jwt.JwtClaimAccessor; + +/** + * A {@link ClaimAccessor} for the "claims" that can be returned in OIDC + * Backchannel Logout Tokens + * + * @author Josh Cummings + * @since 6.1 + * @see OidcLogoutToken + * @see Logout + * Token + */ +public interface LogoutTokenClaimAccessor extends JwtClaimAccessor { + + /** + * Returns the Issuer identifier {@code (iss)}. + * @return the Issuer identifier + */ + default URL getIssuer() { + return this.getClaimAsURL(LogoutTokenClaimNames.ISS); + } + + /** + * Returns the Subject identifier {@code (sub)}. + * @return the Subject identifier + */ + @Override + default String getSubject() { + return this.getClaimAsString(LogoutTokenClaimNames.SUB); + } + + /** + * Returns the Audience(s) {@code (aud)} that this ID Token is intended for. + * @return the Audience(s) that this ID Token is intended for + */ + default List getAudience() { + return this.getClaimAsStringList(LogoutTokenClaimNames.AUD); + } + + /** + * Returns the time at which the ID Token was issued {@code (iat)}. + * @return the time at which the ID Token was issued + */ + default Instant getIssuedAt() { + return this.getClaimAsInstant(LogoutTokenClaimNames.IAT); + } + + /** + * Returns a {@link Map} that identifies this token as a logout token + * @return the identifying {@link Map} + */ + default Map getEvents() { + return getClaimAsMap(LogoutTokenClaimNames.EVENTS); + } + + /** + * Returns a {@code String} value {@code (sid)} representing the OIDC Provider session + * @return the value representing the OIDC Provider session + */ + default String getSessionId() { + return getClaimAsString(LogoutTokenClaimNames.SID); + } + + /** + * Returns the JWT ID {@code (jti)} claim which provides a unique identifier for the + * JWT. + * @return the JWT ID claim which provides a unique identifier for the JWT + */ + default String getId() { + return this.getClaimAsString(LogoutTokenClaimNames.JTI); + } + +} diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/LogoutTokenClaimNames.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/LogoutTokenClaimNames.java new file mode 100644 index 00000000000..5f00470ba37 --- /dev/null +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/LogoutTokenClaimNames.java @@ -0,0 +1,71 @@ +/* + * Copyright 2002-2022 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.oidc.authentication.logout; + +/** + * The names of the "claims" defined by the OpenID Backchannel Logout 1.0 + * specification that can be returned in a Logout Token. + * + * @author Josh Cummings + * @since 6.1 + * @see OidcLogoutToken + * @see Logout + * Token + */ + +public final class LogoutTokenClaimNames { + + /** + * {@code jti} - the JTI identifier + */ + public static final String JTI = "jti"; + + /** + * {@code iss} - the Issuer identifier + */ + public static final String ISS = "iss"; + + /** + * {@code sub} - the Subject identifier + */ + public static final String SUB = "sub"; + + /** + * {@code aud} - the Audience(s) that the ID Token is intended for + */ + public static final String AUD = "aud"; + + /** + * {@code iat} - the time at which the ID Token was issued + */ + public static final String IAT = "iat"; + + /** + * {@code events} - a JSON object that identifies this token as a logout token + */ + public static final String EVENTS = "events"; + + /** + * {@code sid} - the session id for the OIDC provider + */ + public static final String SID = "sid"; + + private LogoutTokenClaimNames() { + } + +} diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/OidcBackChannelLogoutAuthentication.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/OidcBackChannelLogoutAuthentication.java new file mode 100644 index 00000000000..6106026b6cf --- /dev/null +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/OidcBackChannelLogoutAuthentication.java @@ -0,0 +1,56 @@ +/* + * Copyright 2002-2023 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.oidc.authentication.logout; + +import java.util.Collections; + +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.client.registration.ClientRegistration; + +public class OidcBackChannelLogoutAuthentication extends AbstractAuthenticationToken { + + private final OidcLogoutToken logoutToken; + + private final ClientRegistration clientRegistration; + + public OidcBackChannelLogoutAuthentication(OidcLogoutToken logoutToken, ClientRegistration clientRegistration) { + super(Collections.singleton(new SimpleGrantedAuthority("BACKCHANNEL_LOGOUT"))); + this.logoutToken = logoutToken; + this.clientRegistration = clientRegistration; + setAuthenticated(true); + } + + @Override + public OidcLogoutToken getPrincipal() { + return this.logoutToken; + } + + @Override + public OidcLogoutToken getCredentials() { + return this.logoutToken; + } + + public OidcLogoutToken getLogoutToken() { + return this.logoutToken; + } + + public ClientRegistration getClientRegistration() { + return this.clientRegistration; + } + +} diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/OidcBackChannelLogoutAuthenticationManager.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/OidcBackChannelLogoutAuthenticationManager.java new file mode 100644 index 00000000000..d740e504851 --- /dev/null +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/OidcBackChannelLogoutAuthenticationManager.java @@ -0,0 +1,54 @@ +/* + * Copyright 2002-2023 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.oidc.authentication.logout; + +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.AuthenticationServiceException; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.jwt.BadJwtException; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtDecoderFactory; +import org.springframework.security.oauth2.jwt.JwtException; + +public class OidcBackChannelLogoutAuthenticationManager implements AuthenticationManager { + + private final JwtDecoderFactory logoutTokenDecoderFactory = new OidcLogoutTokenDecoderFactory(); + + @Override + public Authentication authenticate(Authentication authentication) throws AuthenticationException { + if (!(authentication instanceof OidcBackChannelLogoutAuthenticationToken token)) { + return null; + } + String logoutToken = token.getLogoutToken(); + ClientRegistration registration = token.getClientRegistration(); + JwtDecoder logoutTokenDecoder = this.logoutTokenDecoderFactory.createDecoder(registration); + try { + return new OidcBackChannelLogoutAuthentication(OidcLogoutToken.withTokenValue(logoutToken) + .claims((claims) -> claims.putAll(logoutTokenDecoder.decode(logoutToken).getClaims())).build(), registration); + } + catch (BadJwtException failed) { + throw new BadCredentialsException(failed.getMessage(), failed); + } + catch (JwtException failed) { + throw new AuthenticationServiceException(failed.getMessage(), failed); + } + } + +} diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/OidcBackChannelLogoutAuthenticationToken.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/OidcBackChannelLogoutAuthenticationToken.java new file mode 100644 index 00000000000..71ee33bd8fb --- /dev/null +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/OidcBackChannelLogoutAuthenticationToken.java @@ -0,0 +1,53 @@ +/* + * Copyright 2002-2023 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.oidc.authentication.logout; + +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.security.oauth2.client.registration.ClientRegistration; + +public class OidcBackChannelLogoutAuthenticationToken extends AbstractAuthenticationToken { + + private final String logoutToken; + + private final ClientRegistration clientRegistration; + + public OidcBackChannelLogoutAuthenticationToken(String logoutToken, ClientRegistration clientRegistration) { + super(AuthorityUtils.NO_AUTHORITIES); + this.logoutToken = logoutToken; + this.clientRegistration = clientRegistration; + } + + @Override + public String getCredentials() { + return this.logoutToken; + } + + @Override + public String getPrincipal() { + return this.logoutToken; + } + + public String getLogoutToken() { + return this.logoutToken; + } + + public ClientRegistration getClientRegistration() { + return this.clientRegistration; + } + +} diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/OidcLogoutToken.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/OidcLogoutToken.java new file mode 100644 index 00000000000..3407e0da945 --- /dev/null +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/OidcLogoutToken.java @@ -0,0 +1,219 @@ +/* + * Copyright 2002-2023 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.oidc.authentication.logout; + +import java.time.Instant; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.function.Consumer; + +import org.springframework.security.oauth2.core.AbstractOAuth2Token; +import org.springframework.security.oauth2.core.oidc.IdTokenClaimNames; +import org.springframework.security.oauth2.core.oidc.OidcIdToken; +import org.springframework.util.Assert; + +/** + * An implementation of an {@link AbstractOAuth2Token} representing an OpenID Backchannel + * Logout Token. + * + *

+ * The {@code OidcLogoutToken} is a security token that contains "claims" about + * terminating sessions for a given OIDC Provider session id or End User. + * + * @author Josh Cummings + * @since 6.1 + * @see AbstractOAuth2Token + * @see LogoutTokenClaimAccessor + * @see Logout + * Token + */ +public class OidcLogoutToken extends AbstractOAuth2Token implements LogoutTokenClaimAccessor { + + private static final String LOGOUT_TOKEN_EVENT_NAME = "http://schemas.openid.net/event/backchannel-logout"; + + private final Map claims; + + /** + * Constructs a {@code OidcLogoutToken} using the provided parameters. + * @param tokenValue the Logout Token value + * @param issuedAt the time at which the Logout Token was issued {@code (iat)} + * @param claims the claims about the logout statement + */ + OidcLogoutToken(String tokenValue, Instant issuedAt, Map claims) { + super(tokenValue, issuedAt, Instant.MAX); + this.claims = Collections.unmodifiableMap(claims); + Assert.notNull(claims, "claims must not be null"); + } + + @Override + public Map getClaims() { + return this.claims; + } + + /** + * Create a {@link OidcLogoutToken.Builder} based on the given token value + * @param tokenValue the token value to use + * @return the {@link OidcLogoutToken.Builder} for further configuration + */ + public static Builder withTokenValue(String tokenValue) { + return new Builder(tokenValue); + } + + /** + * A builder for {@link OidcLogoutToken}s + * + * @author Josh Cummings + */ + public static final class Builder { + + private String tokenValue; + + private final Map claims = new LinkedHashMap<>(); + + private Builder(String tokenValue) { + this.tokenValue = tokenValue; + this.claims.put(LogoutTokenClaimNames.EVENTS, + Collections.singletonMap(LOGOUT_TOKEN_EVENT_NAME, Collections.emptyMap())); + } + + /** + * Use this token value in the resulting {@link OidcIdToken} + * @param tokenValue The token value to use + * @return the {@link Builder} for further configurations + */ + public Builder tokenValue(String tokenValue) { + this.tokenValue = tokenValue; + return this; + } + + /** + * Use this claim in the resulting {@link OidcIdToken} + * @param name The claim name + * @param value The claim value + * @return the {@link Builder} for further configurations + */ + public Builder claim(String name, Object value) { + this.claims.put(name, value); + return this; + } + + /** + * Provides access to every {@link #claim(String, Object)} declared so far with + * the possibility to add, replace, or remove. + * @param claimsConsumer the consumer + * @return the {@link Builder} for further configurations + */ + public Builder claims(Consumer> claimsConsumer) { + claimsConsumer.accept(this.claims); + return this; + } + + /** + * Use this audience in the resulting {@link OidcIdToken} + * @param audience The audience(s) to use + * @return the {@link Builder} for further configurations + */ + public Builder audience(Collection audience) { + return claim(LogoutTokenClaimNames.AUD, audience); + } + + /** + * Use this issued-at timestamp in the resulting {@link OidcIdToken} + * @param issuedAt The issued-at timestamp to use + * @return the {@link Builder} for further configurations + */ + public Builder issuedAt(Instant issuedAt) { + return claim(LogoutTokenClaimNames.IAT, issuedAt); + } + + /** + * Use this issuer in the resulting {@link OidcIdToken} + * @param issuer The issuer to use + * @return the {@link Builder} for further configurations + */ + public Builder issuer(String issuer) { + return claim(LogoutTokenClaimNames.ISS, issuer); + } + + public Builder jti(String id) { + return claim(LogoutTokenClaimNames.JTI, id); + } + + /** + * Use this subject in the resulting {@link OidcIdToken} + * @param subject The subject to use + * @return the {@link Builder} for further configurations + */ + public Builder subject(String subject) { + return claim(LogoutTokenClaimNames.SUB, subject); + } + + /** + * A JSON object that identifies this token as a logout token + * @param events The JSON object to use + * @return the {@link Builder} for further configurations + */ + public Builder events(Map events) { + return claim(LogoutTokenClaimNames.EVENTS, events); + } + + /** + * Use this session id to correlate the OIDC Provider session + * @param sessionId The session id to use + * @return the {@link Builder} for further configurations + */ + public Builder sessionId(String sessionId) { + return claim(LogoutTokenClaimNames.SID, sessionId); + } + + public OidcLogoutToken build() { + Assert.notNull(this.claims.get(LogoutTokenClaimNames.ISS), "issuer must not be null"); + Assert.isInstanceOf(Collection.class, this.claims.get(LogoutTokenClaimNames.AUD), + "audience must be a collection"); + Assert.notEmpty((Collection) this.claims.get(LogoutTokenClaimNames.AUD), "audience must not be empty"); + Assert.notNull(this.claims.get(LogoutTokenClaimNames.JTI), "jti must not be null"); + Assert.isTrue(hasLogoutTokenIdentifyingMember(), + "logout token must contain an events claim that contains a member called " + + "'http://schemas.openid.net/event/backchannel-logout' whose value is an empty Map"); + Assert.isNull(this.claims.get("nonce"), "logout token must not contain a nonce claim"); + Instant iat = toInstant(this.claims.get(IdTokenClaimNames.IAT)); + return new OidcLogoutToken(this.tokenValue, iat, this.claims); + } + + private boolean hasLogoutTokenIdentifyingMember() { + if (!(this.claims.get(LogoutTokenClaimNames.EVENTS) instanceof Map events)) { + return false; + } + if (!(events.get("http://schemas.openid.net/event/backchannel-logout") instanceof Map object)) { + return false; + } + return object.isEmpty(); + } + + private Instant toInstant(Object timestamp) { + if (timestamp != null) { + Assert.isInstanceOf(Instant.class, timestamp, "timestamps must be of type Instant"); + } + return (Instant) timestamp; + } + + } + +} diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/OidcLogoutTokenDecoderFactory.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/OidcLogoutTokenDecoderFactory.java new file mode 100644 index 00000000000..a1f0e398026 --- /dev/null +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/OidcLogoutTokenDecoderFactory.java @@ -0,0 +1,59 @@ +/* + * Copyright 2002-2023 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.oidc.authentication.logout; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import com.nimbusds.oauth2.sdk.id.ClientID; +import com.nimbusds.oauth2.sdk.id.Issuer; +import com.nimbusds.openid.connect.sdk.validators.LogoutTokenClaimsVerifier; + +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtDecoderFactory; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; + +/** + * A {@link JwtDecoderFactory} that decodes and verifies OIDC Logout Tokens. + * + * @author Josh Cummings + * @since 6.1 + * @see OidcLogoutToken + * @see Logout + * Token + */ +public final class OidcLogoutTokenDecoderFactory implements JwtDecoderFactory { + + private final Map jwtDecoderByRegistrationId = new ConcurrentHashMap<>(); + + /** + * {@inheritDoc} + */ + @Override + public JwtDecoder createDecoder(ClientRegistration context) { + ClientID clientId = new ClientID(context.getClientId()); + Issuer issuer = new Issuer(context.getProviderDetails().getIssuerUri()); + LogoutTokenClaimsVerifier verifier = new LogoutTokenClaimsVerifier(issuer, clientId); + return this.jwtDecoderByRegistrationId.computeIfAbsent(context.getRegistrationId(), + (k) -> NimbusJwtDecoder.withJwkSetUri(context.getProviderDetails().getJwkSetUri()) + .jwtProcessorCustomizer((jwtProcessor) -> jwtProcessor.setJWTClaimsSetVerifier(verifier)) + .build()); + } + +} diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/session/InMemoryOidcProviderSessionRegistry.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/session/InMemoryOidcProviderSessionRegistry.java new file mode 100644 index 00000000000..b1e2925f82d --- /dev/null +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/session/InMemoryOidcProviderSessionRegistry.java @@ -0,0 +1,118 @@ +/* + * Copyright 2002-2023 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.oidc.authentication.session; + +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Predicate; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.security.oauth2.client.oidc.authentication.logout.LogoutTokenClaimNames; +import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcLogoutToken; + +/** + * An in-memory implementation of {@link OidcProviderSessionRegistry} + * + * @author Josh Cummings + * @since 6.1 + */ +public final class InMemoryOidcProviderSessionRegistry implements OidcProviderSessionRegistry { + + private final Log logger = LogFactory.getLog(InMemoryOidcProviderSessionRegistry.class); + + private final Map sessions = new ConcurrentHashMap<>(); + + @Override + public void register(OidcProviderSessionRegistrationDetails registration) { + this.sessions.put(registration.getClientSessionId(), registration); + } + + @Override + public void reregister(String oldClientSessionId, String newClientSessionId) { + OidcProviderSessionRegistrationDetails old = this.sessions.remove(oldClientSessionId); + if (old == null) { + this.logger.debug("Failed to register new session id since old session id was not found in registry"); + return; + } + register(new OidcProviderSessionRegistration(newClientSessionId, old.getCsrfToken(), old.getPrincipal())); + } + + @Override + public OidcProviderSessionRegistrationDetails deregister(String clientSessionId) { + OidcProviderSessionRegistrationDetails details = this.sessions.remove(clientSessionId); + if (details != null) { + this.logger.trace("Removed client session"); + } + return details; + } + + @Override + public Iterable deregister(OidcLogoutToken token) { + String issuer = token.getIssuer().toString(); + String subject = token.getSubject(); + String providerSessionId = token.getSessionId(); + Predicate matcher = (providerSessionId != null) + ? sessionIdMatcher(issuer, providerSessionId) : subjectMatcher(issuer, subject); + if (this.logger.isTraceEnabled()) { + String message = "Looking up sessions by issuer [%s] and %s [%s]"; + if (providerSessionId != null) { + this.logger.trace(String.format(message, issuer, LogoutTokenClaimNames.SID, providerSessionId)); + } + else { + this.logger.trace(String.format(message, issuer, LogoutTokenClaimNames.SUB, subject)); + } + } + int size = this.sessions.size(); + Set infos = new HashSet<>(); + this.sessions.values().removeIf((info) -> { + boolean result = matcher.test(info); + if (result) { + infos.add(info); + } + return result; + }); + if (infos.isEmpty()) { + this.logger.debug("Failed to remove any sessions since none matched"); + } + else if (this.logger.isTraceEnabled()) { + String message = "Found and removed %d session(s) from mapping of %d session(s)"; + this.logger.trace(String.format(message, infos.size(), size)); + } + return infos; + } + + private static Predicate sessionIdMatcher(String issuer, String sessionId) { + return (session) -> { + String thatIssuer = session.getPrincipal().getIssuer().toString(); + String thatSessionId = session.getPrincipal().getClaimAsString(LogoutTokenClaimNames.SID); + return issuer.equals(thatIssuer) && sessionId.equals(thatSessionId); + }; + } + + private static Predicate subjectMatcher(String issuer, String subject) { + return (session) -> { + String thatIssuer = session.getPrincipal().getIssuer().toString(); + String thatSubject = session.getPrincipal().getSubject(); + return issuer.equals(thatIssuer) && subject.equals(thatSubject); + }; + } + +} diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/session/OidcProviderSessionRegistration.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/session/OidcProviderSessionRegistration.java new file mode 100644 index 00000000000..45a95e95e1e --- /dev/null +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/session/OidcProviderSessionRegistration.java @@ -0,0 +1,81 @@ +/* + * Copyright 2002-2023 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.oidc.authentication.session; + +import org.springframework.security.oauth2.core.oidc.user.OidcUser; +import org.springframework.security.web.csrf.CsrfToken; +import org.springframework.security.web.csrf.DefaultCsrfToken; + +/** + * The default implementation for {@link OidcProviderSessionRegistrationDetails}. Handy + * for in-memory registries. + * + * @author Josh Cummings + * @since 6.1 + */ +public class OidcProviderSessionRegistration implements OidcProviderSessionRegistrationDetails { + + private final String clientSessionId; + + private final CsrfToken token; + + private final OidcUser user; + + /** + * Construct an {@link OidcProviderSessionRegistration} + * @param clientSessionId the Client's session id + * @param token the Client's CSRF token for this session + * @param user the OIDC Provider's session and end user + */ + public OidcProviderSessionRegistration(String clientSessionId, CsrfToken token, OidcUser user) { + this.clientSessionId = clientSessionId; + this.token = extract(token); + this.user = user; + } + + private static CsrfToken extract(CsrfToken token) { + if (token == null) { + return null; + } + return new DefaultCsrfToken(token.getHeaderName(), token.getParameterName(), token.getToken()); + } + + /** + * {@inheritDoc} + */ + @Override + public String getClientSessionId() { + return this.clientSessionId; + } + + /** + * {@inheritDoc} + */ + @Override + public CsrfToken getCsrfToken() { + return this.token; + } + + /** + * {@inheritDoc} + */ + @Override + public OidcUser getPrincipal() { + return this.user; + } + +} diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/session/OidcProviderSessionRegistrationDetails.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/session/OidcProviderSessionRegistrationDetails.java new file mode 100644 index 00000000000..a9ab946ec6d --- /dev/null +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/session/OidcProviderSessionRegistrationDetails.java @@ -0,0 +1,48 @@ +/* + * Copyright 2002-2023 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.oidc.authentication.session; + +import org.springframework.security.oauth2.core.oidc.user.OidcUser; +import org.springframework.security.web.csrf.CsrfToken; + +/** + * A registration of the OIDC Provider Session with the Client's session + * + * @author Josh Cummings + * @since 6.1 + */ +public interface OidcProviderSessionRegistrationDetails { + + /** + * The Client's session id, typically the browser {@code JSESSIONID} + * @return the client session id + */ + String getClientSessionId(); + + /** + * The Client's CSRF Token tied to the client's session + * @return the {@link CsrfToken} + */ + CsrfToken getCsrfToken(); + + /** + * The Provider's End User, including any indicated session id + * @return + */ + OidcUser getPrincipal(); + +} diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/session/OidcProviderSessionRegistry.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/session/OidcProviderSessionRegistry.java new file mode 100644 index 00000000000..a384b903e3b --- /dev/null +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/session/OidcProviderSessionRegistry.java @@ -0,0 +1,66 @@ +/* + * Copyright 2002-2023 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.oidc.authentication.session; + +import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcLogoutToken; + +/** + * A registry to record the tie between the OIDC Provider session and the Client session. + * This is handy when a provider makes a logout request that indicates the OIDC Provider + * session or the End User. + * + * @author Josh Cummings + * @since 6.1 + * @see Logout + * Token + */ +public interface OidcProviderSessionRegistry { + + /** + * Register a OIDC Provider session with the provided client session. Generally + * speaking, the client session should be the session tied to the current login. + * @param details the {@link OidcProviderSessionRegistrationDetails} to use + */ + void register(OidcProviderSessionRegistrationDetails details); + + /** + * Update the entry for a Client when their session id changes. This is handy, for + * example, when the id changes for session fixation protection. + * @param oldClientSessionId the Client's old session id + * @param newClientSessionId the Client's new session id + */ + void reregister(String oldClientSessionId, String newClientSessionId); + + /** + * Deregister the OIDC Provider session tied to the provided client session. Generally + * speaking, the client session should be the session tied to the current logout. + * @param clientSessionId the client session + * @return any found {@link OidcProviderSessionRegistrationDetails}, could be + * {@code null} + */ + OidcProviderSessionRegistrationDetails deregister(String clientSessionId); + + /** + * Deregister the OIDC Provider sessions referenced by the provided OIDC Logout Token + * by its session id or its subject. + * @param logoutToken the {@link OidcLogoutToken} + * @return any found {@link OidcProviderSessionRegistrationDetails}s, could be empty + */ + Iterable deregister(OidcLogoutToken logoutToken); + +} diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/web/authentication/logout/OidcBackChannelLogoutFilter.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/web/authentication/logout/OidcBackChannelLogoutFilter.java new file mode 100644 index 00000000000..2baaba12405 --- /dev/null +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/web/authentication/logout/OidcBackChannelLogoutFilter.java @@ -0,0 +1,205 @@ +/* + * Copyright 2002-2023 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.oidc.web.authentication.logout; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcBackChannelLogoutAuthenticationToken; +import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcLogoutToken; +import org.springframework.security.oauth2.client.oidc.authentication.session.InMemoryOidcProviderSessionRegistry; +import org.springframework.security.oauth2.client.oidc.authentication.session.OidcProviderSessionRegistrationDetails; +import org.springframework.security.oauth2.client.oidc.authentication.session.OidcProviderSessionRegistry; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.oauth2.jwt.JwtDecoderFactory; +import org.springframework.security.web.authentication.logout.BackchannelLogoutAuthentication; +import org.springframework.security.web.authentication.logout.BackchannelLogoutHandler; +import org.springframework.security.web.authentication.logout.LogoutHandler; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatcher; +import org.springframework.util.Assert; +import org.springframework.web.filter.OncePerRequestFilter; + +/** + * A filter for the Client-side OIDC Backchannel Logout endpoint + * + * @author Josh Cummings + * @since 6.1 + * @see OIDC Backchannel Logout + * Spec + */ +public class OidcBackChannelLogoutFilter extends OncePerRequestFilter { + + private final Log logger = LogFactory.getLog(getClass()); + + private static final String ERROR_MESSAGE = "{ \"error\" : \"%s\", \"error_description\" : \"%s\" }"; + + private final ClientRegistrationRepository clientRegistrationRepository; + + private final AuthenticationManager authenticationManager; + + private RequestMatcher requestMatcher = new AntPathRequestMatcher("/logout/connect/back-channel/{registrationId}", + "POST"); + + private OidcProviderSessionRegistry providerSessionRegistry = new InMemoryOidcProviderSessionRegistry(); + + private LogoutHandler logoutHandler = new BackchannelLogoutHandler(); + + /** + * Construct an {@link OidcBackChannelLogoutFilter} + * @param clients the {@link ClientRegistrationRepository} for deriving Logout Token + * validation + * @param logoutTokenDecoderFactory the {@link JwtDecoderFactory} for constructing + * Logout Token validators + */ + public OidcBackChannelLogoutFilter(ClientRegistrationRepository clients, + AuthenticationManager authenticationManager) { + this.clientRegistrationRepository = clients; + this.authenticationManager = authenticationManager; + } + + /** + * {@inheritDoc} + */ + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws ServletException, IOException { + RequestMatcher.MatchResult result = this.requestMatcher.matcher(request); + if (!result.isMatch()) { + chain.doFilter(request, response); + return; + } + String registrationId = result.getVariables().get("registrationId"); + ClientRegistration registration = this.clientRegistrationRepository.findByRegistrationId(registrationId); + if (registration == null) { + this.logger.debug("Did not process OIDC Backchannel Logout since no ClientRegistration was found"); + chain.doFilter(request, response); + return; + } + String logoutToken = request.getParameter("logout_token"); + if (logoutToken == null) { + String error = "Failed to process OIDC Backchannel Logout since no logout token was found"; + this.logger.debug(error); + String message = String.format(ERROR_MESSAGE, "invalid_request", error); + response.sendError(400, message); + return; + } + OidcLogoutToken token; + try { + token = authenticate(logoutToken, registration); + } + catch (AuthenticationException ex) { + this.logger.debug("Failed to process OIDC Backchannel Logout", ex); + String message = String.format(ERROR_MESSAGE, "invalid_request", ex.getMessage()); + response.sendError(400, message); + return; + } + int sessionCount = 0; + int loggedOutCount = 0; + List messages = new ArrayList<>(); + Iterable sessions = this.providerSessionRegistry.deregister(token); + for (OidcProviderSessionRegistrationDetails session : sessions) { + BackchannelLogoutAuthentication authentication = new BackchannelLogoutAuthentication( + session.getClientSessionId(), session.getCsrfToken()); + try { + if (this.logger.isTraceEnabled()) { + String message = "Logging out session #%d from result set for issuer [%s]"; + this.logger.trace(String.format(message, sessionCount, token.getIssuer())); + } + this.logoutHandler.logout(request, response, authentication); + loggedOutCount++; + } + catch (Exception ex) { + this.providerSessionRegistry.register(session); + if (this.logger.isDebugEnabled()) { + String message = "Failed to invalidate session #%d from result set for issuer [%s]"; + this.logger.debug(String.format(message, sessionCount, token.getIssuer()), ex); + } + messages.add(ex.getMessage()); + } + sessionCount++; + } + if (this.logger.isTraceEnabled()) { + this.logger.trace(String.format("Invalidated %d/%d linked sessions for issuer [%s]", loggedOutCount, + sessionCount, token.getIssuer())); + } + if (messages.isEmpty()) { + return; + } + if (messages.size() == sessionCount) { + this.logger.trace("Returning a 400 since all linked sessions for issuer [%s] failed termination"); + String message = String.format(ERROR_MESSAGE, "logout_failed", messages.iterator().next(), + token.getIssuer()); + response.sendError(400, message); + return; + } + if (messages.size() < sessionCount) { + this.logger.trace( + "Returning a 400 since not all linked sessions for issuer [%s] were successfully terminated"); + String message = String.format(ERROR_MESSAGE, "incomplete_logout", messages.iterator().next(), + token.getIssuer()); + response.sendError(400, message); + } + } + + private OidcLogoutToken authenticate(String logoutToken, ClientRegistration registration) { + OidcBackChannelLogoutAuthenticationToken token = new OidcBackChannelLogoutAuthenticationToken(logoutToken, + registration); + return (OidcLogoutToken) this.authenticationManager.authenticate(token).getPrincipal(); + } + + /** + * The logout endpoint. Defaults to {@code /oauth2/{registrationId}/logout}. + * @param requestMatcher the {@link RequestMatcher} to use + */ + public void setRequestMatcher(RequestMatcher requestMatcher) { + Assert.notNull(requestMatcher, "requestMatcher cannot be null"); + this.requestMatcher = requestMatcher; + } + + /** + * The registry for linking Client sessions to OIDC Provider sessions and End Users + * @param providerSessionRegistry the {@link OidcProviderSessionRegistry} to use + */ + public void setProviderSessionRegistry(OidcProviderSessionRegistry providerSessionRegistry) { + Assert.notNull(providerSessionRegistry, "providerSessionRegistry cannot be null"); + this.providerSessionRegistry = providerSessionRegistry; + } + + /** + * The strategy for expiring each Client session indicated by the logout request. + * Defaults to {@link BackchannelLogoutHandler}. + * @param logoutHandler the {@link LogoutHandler} to use + */ + public void setLogoutHandler(LogoutHandler logoutHandler) { + Assert.notNull(logoutHandler, "logoutHandler cannot be null"); + this.logoutHandler = logoutHandler; + } + +} diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/authentication/logout/NimbusLogoutTokenDecoderFactoryTests.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/authentication/logout/NimbusLogoutTokenDecoderFactoryTests.java new file mode 100644 index 00000000000..a3cc2e8aaf9 --- /dev/null +++ b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/authentication/logout/NimbusLogoutTokenDecoderFactoryTests.java @@ -0,0 +1,41 @@ +/* + * Copyright 2002-2023 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.oidc.authentication.logout; + +import org.junit.jupiter.api.Test; + +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.registration.TestClientRegistrations; + +import static org.assertj.core.api.Assertions.assertThat; + +public class NimbusLogoutTokenDecoderFactoryTests { + + // @formatter:off + private ClientRegistration.Builder registration = TestClientRegistrations + .clientRegistration() + .scope("openid"); + // @formatter:on + + OidcLogoutTokenDecoderFactory factory = new OidcLogoutTokenDecoderFactory(); + + @Test + public void createDecoderWhenClientRegistrationValidThenReturnDecoder() { + assertThat(this.factory.createDecoder(this.registration.build())).isNotNull(); + } + +} diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/authentication/logout/TestOidcLogoutTokens.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/authentication/logout/TestOidcLogoutTokens.java new file mode 100644 index 00000000000..86942d69ee4 --- /dev/null +++ b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/authentication/logout/TestOidcLogoutTokens.java @@ -0,0 +1,50 @@ +/* + * Copyright 2002-2023 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.oidc.authentication.logout; + +import java.time.Instant; +import java.util.Collections; + +import org.springframework.security.oauth2.core.oidc.user.OidcUser; + +public final class TestOidcLogoutTokens { + + public static OidcLogoutToken.Builder withUser(OidcUser user) { + OidcLogoutToken.Builder builder = OidcLogoutToken.withTokenValue("token") + .audience(Collections.singleton("audience")).issuedAt(Instant.now()).issuer(user.getIssuer().toString()) + .jti("id").subject(user.getSubject()); + if (user.hasClaim(LogoutTokenClaimNames.SID)) { + builder.sessionId(user.getClaimAsString(LogoutTokenClaimNames.SID)); + } + return builder; + } + + public static OidcLogoutToken.Builder withSessionId(String issuer, String sessionId) { + return OidcLogoutToken.withTokenValue("token").audience(Collections.singleton("audience")) + .issuedAt(Instant.now()).issuer(issuer).jti("id").sessionId(sessionId); + } + + public static OidcLogoutToken.Builder withSubject(String issuer, String subject) { + return OidcLogoutToken.withTokenValue("token").audience(Collections.singleton("audience")) + .issuedAt(Instant.now()).issuer(issuer).jti("id").subject(subject); + } + + private TestOidcLogoutTokens() { + + } + +} diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/authentication/session/InMemoryOidcProviderSessionRegistryTests.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/authentication/session/InMemoryOidcProviderSessionRegistryTests.java new file mode 100644 index 00000000000..7c6d48ba345 --- /dev/null +++ b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/authentication/session/InMemoryOidcProviderSessionRegistryTests.java @@ -0,0 +1,101 @@ +/* + * Copyright 2002-2023 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.oidc.authentication.session; + +import org.junit.jupiter.api.Test; + +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcLogoutToken; +import org.springframework.security.oauth2.client.oidc.authentication.logout.TestOidcLogoutTokens; +import org.springframework.security.oauth2.core.oidc.OidcIdToken; +import org.springframework.security.oauth2.core.oidc.TestOidcIdTokens; +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.TestOidcUsers; + +import static org.assertj.core.api.Assertions.assertThat; + +public class InMemoryOidcProviderSessionRegistryTests { + + @Test + public void registerWhenDefaultsThenStoresSessionInformation() { + InMemoryOidcProviderSessionRegistry registry = new InMemoryOidcProviderSessionRegistry(); + String sessionId = "client"; + OidcUser user = TestOidcUsers.create(); + OidcProviderSessionRegistrationDetails info = new OidcProviderSessionRegistration(sessionId, null, user); + registry.register(info); + assertThat(info.getClientSessionId()).isSameAs(sessionId); + assertThat(info.getPrincipal()).isSameAs(user); + Iterable infos = registry + .deregister(TestOidcLogoutTokens.withUser(user).build()); + assertThat(infos).containsExactly(info); + } + + @Test + public void registerWhenIdTokenHasSessionIdThenStoresSessionInformation() { + InMemoryOidcProviderSessionRegistry registry = new InMemoryOidcProviderSessionRegistry(); + OidcIdToken token = TestOidcIdTokens.idToken().claim("sid", "provider").build(); + OidcUser user = new DefaultOidcUser(AuthorityUtils.NO_AUTHORITIES, token); + OidcProviderSessionRegistrationDetails info = new OidcProviderSessionRegistration("client", null, user); + registry.register(info); + OidcLogoutToken logoutToken = TestOidcLogoutTokens.withSessionId(token.getIssuer().toString(), "provider") + .build(); + Iterable infos = registry.deregister(logoutToken); + assertThat(infos).containsExactly(info); + } + + @Test + public void unregisterWhenMultipleSessionsThenRemovesAllMatching() { + InMemoryOidcProviderSessionRegistry registry = new InMemoryOidcProviderSessionRegistry(); + OidcIdToken token = TestOidcIdTokens.idToken().claim("sid", "providerOne").subject("otheruser").build(); + OidcUser user = new DefaultOidcUser(AuthorityUtils.NO_AUTHORITIES, token); + OidcProviderSessionRegistrationDetails one = new OidcProviderSessionRegistration("clientOne", null, user); + registry.register(one); + token = TestOidcIdTokens.idToken().claim("sid", "providerTwo").build(); + user = new DefaultOidcUser(AuthorityUtils.NO_AUTHORITIES, token); + OidcProviderSessionRegistrationDetails two = new OidcProviderSessionRegistration("clientTwo", null, user); + registry.register(two); + token = TestOidcIdTokens.idToken().claim("sid", "providerThree").build(); + user = new DefaultOidcUser(AuthorityUtils.NO_AUTHORITIES, token); + OidcProviderSessionRegistrationDetails three = new OidcProviderSessionRegistration("clientThree", null, user); + registry.register(three); + OidcLogoutToken logoutToken = TestOidcLogoutTokens.withSubject(token.getIssuer().toString(), token.getSubject()) + .build(); + Iterable infos = registry.deregister(logoutToken); + assertThat(infos).containsExactlyInAnyOrder(two, three); + logoutToken = TestOidcLogoutTokens.withSubject(token.getIssuer().toString(), "otheruser").build(); + infos = registry.deregister(logoutToken); + assertThat(infos).containsExactly(one); + } + + @Test + public void unregisterWhenNoSessionsThenEmptyList() { + InMemoryOidcProviderSessionRegistry registry = new InMemoryOidcProviderSessionRegistry(); + OidcIdToken token = TestOidcIdTokens.idToken().claim("sid", "provider").build(); + OidcUser user = new DefaultOidcUser(AuthorityUtils.NO_AUTHORITIES, token); + registry.register(new OidcProviderSessionRegistration("client", null, user)); + OidcLogoutToken logoutToken = TestOidcLogoutTokens.withSessionId(token.getIssuer().toString(), "wrong").build(); + Iterable infos = registry.deregister(logoutToken); + assertThat(infos).isNotNull(); + assertThat(infos).isEmpty(); + logoutToken = TestOidcLogoutTokens.withSessionId("https://wrong", "provider").build(); + infos = registry.deregister(logoutToken); + assertThat(infos).isNotNull(); + assertThat(infos).isEmpty(); + } + +} diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/authentication/session/TestOidcProviderSessionRegistrations.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/authentication/session/TestOidcProviderSessionRegistrations.java new file mode 100644 index 00000000000..098c80b5e61 --- /dev/null +++ b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/authentication/session/TestOidcProviderSessionRegistrations.java @@ -0,0 +1,34 @@ +/* + * Copyright 2002-2023 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.oidc.authentication.session; + +import org.springframework.security.oauth2.core.oidc.user.TestOidcUsers; +import org.springframework.security.web.csrf.CsrfToken; +import org.springframework.security.web.csrf.DefaultCsrfToken; + +public final class TestOidcProviderSessionRegistrations { + + public static OidcProviderSessionRegistration create() { + CsrfToken token = new DefaultCsrfToken("header", "parameter", "token"); + return new OidcProviderSessionRegistration("sessionId", token, TestOidcUsers.create()); + } + + private TestOidcProviderSessionRegistrations() { + + } + +} diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/web/authentication/logout/OidcBackChannelLogoutFilterTests.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/web/authentication/logout/OidcBackChannelLogoutFilterTests.java new file mode 100644 index 00000000000..fe0b9b50327 --- /dev/null +++ b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/web/authentication/logout/OidcBackChannelLogoutFilterTests.java @@ -0,0 +1,154 @@ +/* + * Copyright 2002-2023 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.oidc.web.authentication.logout; + +import java.util.Set; + +import jakarta.servlet.FilterChain; +import org.junit.jupiter.api.Test; + +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcBackChannelLogoutAuthentication; +import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcLogoutToken; +import org.springframework.security.oauth2.client.oidc.authentication.logout.TestOidcLogoutTokens; +import org.springframework.security.oauth2.client.oidc.authentication.session.OidcProviderSessionRegistration; +import org.springframework.security.oauth2.client.oidc.authentication.session.OidcProviderSessionRegistrationDetails; +import org.springframework.security.oauth2.client.oidc.authentication.session.OidcProviderSessionRegistry; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.oauth2.client.registration.TestClientRegistrations; +import org.springframework.security.oauth2.core.oidc.user.TestOidcUsers; +import org.springframework.security.web.authentication.logout.LogoutHandler; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +public class OidcBackChannelLogoutFilterTests { + + @Test + public void doFilterRequestDoesNotMatchThenDoesNotRun() throws Exception { + ClientRegistrationRepository clients = mock(ClientRegistrationRepository.class); + AuthenticationManager factory = mock(AuthenticationManager.class); + OidcBackChannelLogoutFilter filter = new OidcBackChannelLogoutFilter(clients, factory); + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain chain = mock(FilterChain.class); + filter.doFilter(request, response, chain); + verifyNoInteractions(clients, factory); + verify(chain).doFilter(request, response); + } + + @Test + public void doFilterRequestDoesNotMatchContainLogoutTokenThenBadRequest() throws Exception { + ClientRegistration registration = TestClientRegistrations.clientRegistration().build(); + ClientRegistrationRepository clients = mock(ClientRegistrationRepository.class); + given(clients.findByRegistrationId(any())).willReturn(registration); + AuthenticationManager factory = mock(AuthenticationManager.class); + OidcBackChannelLogoutFilter filter = new OidcBackChannelLogoutFilter(clients, factory); + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/logout/connect/back-channel/id"); + request.setServletPath("/logout/connect/back-channel/id"); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain chain = mock(FilterChain.class); + filter.doFilter(request, response, chain); + verifyNoInteractions(factory, chain); + assertThat(response.getStatus()).isEqualTo(400); + } + + @Test + public void doFilterWithNoMatchingClientThenDoesNotRun() throws Exception { + ClientRegistrationRepository clients = mock(ClientRegistrationRepository.class); + AuthenticationManager factory = mock(AuthenticationManager.class); + OidcBackChannelLogoutFilter filter = new OidcBackChannelLogoutFilter(clients, factory); + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/logout/connect/back-channel/id"); + request.setServletPath("/logout/connect/back-channel/id"); + request.setParameter("logout_token", "logout_token"); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain chain = mock(FilterChain.class); + filter.doFilter(request, response, chain); + verify(clients).findByRegistrationId("id"); + verifyNoInteractions(factory); + verify(chain).doFilter(request, response); + } + + @Test + public void doFilterWithSessionMatchingLogoutTokenThenInvalidates() throws Exception { + ClientRegistration registration = TestClientRegistrations.clientRegistration().build(); + ClientRegistrationRepository clients = mock(ClientRegistrationRepository.class); + given(clients.findByRegistrationId(any())).willReturn(registration); + AuthenticationManager factory = mock(AuthenticationManager.class); + OidcLogoutToken token = TestOidcLogoutTokens.withSessionId("issuer", "provider").build(); + given(factory.authenticate(any())).willReturn(new OidcBackChannelLogoutAuthentication(token, registration)); + OidcProviderSessionRegistry registry = mock(OidcProviderSessionRegistry.class); + Iterable infos = Set.of( + (OidcProviderSessionRegistrationDetails) new OidcProviderSessionRegistration("clientOne", null, + TestOidcUsers.create()), + new OidcProviderSessionRegistration("clientTwo", null, TestOidcUsers.create())); + given(registry.deregister(any(OidcLogoutToken.class))).willReturn(infos); + LogoutHandler logoutHandler = mock(LogoutHandler.class); + OidcBackChannelLogoutFilter filter = new OidcBackChannelLogoutFilter(clients, factory); + filter.setProviderSessionRegistry(registry); + filter.setLogoutHandler(logoutHandler); + MockHttpServletRequest request = new MockHttpServletRequest("POST", + "/oauth2/" + registration.getRegistrationId() + "/logout"); + request.setServletPath("/logout/connect/back-channel/id"); + request.setParameter("logout_token", "logout_token"); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain chain = mock(FilterChain.class); + filter.doFilter(request, response, chain); + verify(logoutHandler, times(2)).logout(any(), any(), any()); + verifyNoInteractions(chain); + assertThat(response.getStatus()).isEqualTo(200); + } + + @Test + public void doFilterWhenInvalidJwtThenBadRequest() throws Exception { + ClientRegistration registration = TestClientRegistrations.clientRegistration().build(); + ClientRegistrationRepository clients = mock(ClientRegistrationRepository.class); + given(clients.findByRegistrationId(any())).willReturn(registration); + AuthenticationManager factory = mock(AuthenticationManager.class); + given(factory.authenticate(any())).willThrow(new BadCredentialsException("bad")); + OidcProviderSessionRegistry registry = mock(OidcProviderSessionRegistry.class); + Iterable infos = Set.of( + (OidcProviderSessionRegistrationDetails) new OidcProviderSessionRegistration("clientOne", null, + TestOidcUsers.create()), + new OidcProviderSessionRegistration("clientTwo", null, TestOidcUsers.create())); + given(registry.deregister(any(OidcLogoutToken.class))).willReturn(infos); + LogoutHandler logoutHandler = mock(LogoutHandler.class); + OidcBackChannelLogoutFilter filter = new OidcBackChannelLogoutFilter(clients, factory); + filter.setProviderSessionRegistry(registry); + filter.setLogoutHandler(logoutHandler); + MockHttpServletRequest request = new MockHttpServletRequest("POST", + "/oauth2/" + registration.getRegistrationId() + "/logout"); + request.setServletPath("/logout/connect/back-channel/id"); + request.setParameter("logout_token", "logout_token"); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain chain = mock(FilterChain.class); + filter.doFilter(request, response, chain); + verifyNoInteractions(registry, logoutHandler, chain); + assertThat(response.getStatus()).isEqualTo(400); + assertThat(response.getErrorMessage()).contains("bad"); + } + +} diff --git a/web/src/main/java/org/springframework/security/web/authentication/logout/BackchannelLogoutAuthentication.java b/web/src/main/java/org/springframework/security/web/authentication/logout/BackchannelLogoutAuthentication.java new file mode 100644 index 00000000000..8172bd16f5a --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/authentication/logout/BackchannelLogoutAuthentication.java @@ -0,0 +1,55 @@ +/* + * Copyright 2002-2023 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.web.authentication.logout; + +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.security.web.csrf.CsrfToken; +import org.springframework.util.Assert; + +public class BackchannelLogoutAuthentication extends AbstractAuthenticationToken { + + private final String sessionId; + + private final CsrfToken csrfToken; + + public BackchannelLogoutAuthentication(String sessionId, CsrfToken csrfToken) { + super(AuthorityUtils.createAuthorityList("BACKCHANNEL_LOGOUT")); + Assert.notNull(sessionId, "sessionId cannot be null"); + this.sessionId = sessionId; + this.csrfToken = csrfToken; + } + + @Override + public String getPrincipal() { + return this.sessionId; + } + + public String getSessionId() { + return this.sessionId; + } + + @Override + public CsrfToken getCredentials() { + return this.csrfToken; + } + + public CsrfToken getCsrfToken() { + return this.csrfToken; + } + +} diff --git a/web/src/main/java/org/springframework/security/web/authentication/logout/BackchannelLogoutHandler.java b/web/src/main/java/org/springframework/security/web/authentication/logout/BackchannelLogoutHandler.java new file mode 100644 index 00000000000..dff3349c163 --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/authentication/logout/BackchannelLogoutHandler.java @@ -0,0 +1,83 @@ +/* + * Copyright 2002-2023 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.web.authentication.logout; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.csrf.CsrfToken; +import org.springframework.util.Assert; +import org.springframework.web.client.RestOperations; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +public final class BackchannelLogoutHandler implements LogoutHandler { + + private final Log logger = LogFactory.getLog(getClass()); + + private RestOperations rest = new RestTemplate(); + + private String logoutEndpointName = "/logout"; + + private String clientSessionCookieName = "JSESSIONID"; + + @Override + public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) { + if (!(authentication instanceof BackchannelLogoutAuthentication token)) { + if (this.logger.isDebugEnabled()) { + String message = "Did not perform Backchannel Logout since authentication [%s] was of the wrong type"; + this.logger.debug(String.format(message, authentication.getClass().getSimpleName())); + } + return; + } + HttpHeaders headers = new HttpHeaders(); + headers.add(HttpHeaders.COOKIE, this.clientSessionCookieName + "=" + token.getSessionId()); + CsrfToken csrfToken = token.getCsrfToken(); + if (csrfToken != null) { + headers.add(csrfToken.getHeaderName(), csrfToken.getToken()); + } + String url = request.getRequestURL().toString(); + String logout = UriComponentsBuilder.fromHttpUrl(url).replacePath(this.logoutEndpointName).build() + .toUriString(); + HttpEntity entity = new HttpEntity<>(null, headers); + this.rest.postForEntity(logout, entity, Object.class); + if (this.logger.isTraceEnabled()) { + this.logger.trace(String.format("Invalidated session [%s]", token.getSessionId())); + } + } + + public void setRestOperations(RestOperations rest) { + Assert.notNull(rest, "rest cannot be null"); + this.rest = rest; + } + + public void setLogoutEndpointName(String logoutEndpointName) { + Assert.hasText(logoutEndpointName, "logoutEndpointName cannot be empty"); + this.logoutEndpointName = logoutEndpointName; + } + + public void setClientSessionCookieName(String clientSessionCookieName) { + Assert.hasText(clientSessionCookieName, "clientSessionCookieName cannot be empty"); + this.clientSessionCookieName = clientSessionCookieName; + } + +} From 9beef692914c394d552b91a3b95c93985d31d723 Mon Sep 17 00:00:00 2001 From: Josh Cummings Date: Mon, 3 Jul 2023 12:09:46 -0600 Subject: [PATCH 02/12] Respond to Feedback --- .../oauth2/client/OidcLogoutConfigurer.java | 33 ++++++------- .../client/OidcLogoutConfigurerTests.java | 14 +++--- ....java => InMemoryOidcSessionRegistry.java} | 31 ++++++------ ...idcProviderSessionRegistrationDetails.java | 48 ------------------ ...tion.java => OidcSessionRegistration.java} | 47 ++++++------------ ...Registry.java => OidcSessionRegistry.java} | 17 +++---- .../logout/OidcBackChannelLogoutFilter.java | 49 +++++++++---------- ... => InMemoryOidcSessionRegistryTests.java} | 35 ++++++------- ...java => TestOidcSessionRegistrations.java} | 18 +++++-- .../OidcBackChannelLogoutFilterTests.java | 23 ++++----- .../BackchannelLogoutAuthentication.java | 10 +++- 11 files changed, 133 insertions(+), 192 deletions(-) rename oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/session/{InMemoryOidcProviderSessionRegistry.java => InMemoryOidcSessionRegistry.java} (69%) delete mode 100644 oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/session/OidcProviderSessionRegistrationDetails.java rename oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/session/{OidcProviderSessionRegistration.java => OidcSessionRegistration.java} (54%) rename oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/session/{OidcProviderSessionRegistry.java => OidcSessionRegistry.java} (77%) rename oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/authentication/session/{InMemoryOidcProviderSessionRegistryTests.java => InMemoryOidcSessionRegistryTests.java} (69%) rename oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/authentication/session/{TestOidcProviderSessionRegistrations.java => TestOidcSessionRegistrations.java} (60%) diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurer.java index 0c5f741a12d..c642aa8daa2 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurer.java @@ -40,12 +40,13 @@ import org.springframework.security.core.session.SessionDestroyedEvent; import org.springframework.security.core.session.SessionIdChangedEvent; import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcBackChannelLogoutAuthenticationManager; -import org.springframework.security.oauth2.client.oidc.authentication.session.InMemoryOidcProviderSessionRegistry; -import org.springframework.security.oauth2.client.oidc.authentication.session.OidcProviderSessionRegistration; -import org.springframework.security.oauth2.client.oidc.authentication.session.OidcProviderSessionRegistry; +import org.springframework.security.oauth2.client.oidc.authentication.session.InMemoryOidcSessionRegistry; +import org.springframework.security.oauth2.client.oidc.authentication.session.OidcSessionRegistration; +import org.springframework.security.oauth2.client.oidc.authentication.session.OidcSessionRegistry; import org.springframework.security.oauth2.client.oidc.web.authentication.logout.OidcBackChannelLogoutFilter; import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; import org.springframework.security.oauth2.core.oidc.user.OidcUser; +import org.springframework.security.web.authentication.logout.BackchannelLogoutAuthentication; import org.springframework.security.web.authentication.logout.BackchannelLogoutHandler; import org.springframework.security.web.authentication.logout.LogoutHandler; import org.springframework.security.web.authentication.session.SessionAuthenticationException; @@ -141,7 +142,7 @@ public final class BackChannelLogoutConfigurer { private AuthenticationManager authenticationManager = new OidcBackChannelLogoutAuthenticationManager(); - private OidcProviderSessionRegistry providerSessionRegistry = new InMemoryOidcProviderSessionRegistry(); + private OidcSessionRegistry providerSessionRegistry = new InMemoryOidcSessionRegistry(); public BackChannelLogoutConfigurer clientLogoutHandler(LogoutHandler logoutHandler) { Assert.notNull(logoutHandler, "logoutHandler cannot be null"); @@ -155,8 +156,7 @@ public BackChannelLogoutConfigurer authenticationManager(AuthenticationManager a return this; } - public BackChannelLogoutConfigurer oidcProviderSessionRegistry( - OidcProviderSessionRegistry providerSessionRegistry) { + public BackChannelLogoutConfigurer oidcProviderSessionRegistry(OidcSessionRegistry providerSessionRegistry) { Assert.notNull(providerSessionRegistry, "providerSessionRegistry cannot be null"); this.providerSessionRegistry = providerSessionRegistry; return this; @@ -166,7 +166,7 @@ private AuthenticationManager authenticationManager() { return this.authenticationManager; } - private OidcProviderSessionRegistry oidcProviderSessionRegistry() { + private OidcSessionRegistry oidcProviderSessionRegistry() { return this.providerSessionRegistry; } @@ -202,7 +202,7 @@ static final class OidcClientSessionEventListener implements ApplicationListener private final Log logger = LogFactory.getLog(OidcClientSessionEventListener.class); - private OidcProviderSessionRegistry providerSessionRegistry = new InMemoryOidcProviderSessionRegistry(); + private OidcSessionRegistry providerSessionRegistry = new InMemoryOidcSessionRegistry(); /** * {@inheritDoc} @@ -216,17 +216,16 @@ public void onApplicationEvent(AbstractSessionEvent event) { } if (event instanceof SessionIdChangedEvent changed) { this.logger.debug("Received SessionIdChangedEvent"); - this.providerSessionRegistry.reregister(changed.getOldSessionId(), changed.getNewSessionId()); + this.providerSessionRegistry.register(changed.getOldSessionId(), changed.getNewSessionId()); } } /** * The registry where OIDC Provider sessions are linked to the Client session. * Defaults to in-memory storage. - * @param providerSessionRegistry the {@link OidcProviderSessionRegistry} to - * use + * @param providerSessionRegistry the {@link OidcSessionRegistry} to use */ - void setProviderSessionRegistry(OidcProviderSessionRegistry providerSessionRegistry) { + void setProviderSessionRegistry(OidcSessionRegistry providerSessionRegistry) { Assert.notNull(providerSessionRegistry, "providerSessionRegistry cannot be null"); this.providerSessionRegistry = providerSessionRegistry; } @@ -237,7 +236,7 @@ static final class OidcProviderSessionAuthenticationStrategy implements SessionA private final Log logger = LogFactory.getLog(getClass()); - private OidcProviderSessionRegistry providerSessionRegistry = new InMemoryOidcProviderSessionRegistry(); + private OidcSessionRegistry providerSessionRegistry = new InMemoryOidcSessionRegistry(); /** * {@inheritDoc} @@ -256,7 +255,8 @@ public void onAuthentication(Authentication authentication, HttpServletRequest r } String sessionId = session.getId(); CsrfToken csrfToken = (CsrfToken) request.getAttribute(CsrfToken.class.getName()); - OidcProviderSessionRegistration registration = new OidcProviderSessionRegistration(sessionId, csrfToken, user); + BackchannelLogoutAuthentication logoutAuthentication = new BackchannelLogoutAuthentication(sessionId, csrfToken); + OidcSessionRegistration registration = new OidcSessionRegistration(sessionId, user, logoutAuthentication); if (this.logger.isTraceEnabled()) { this.logger.trace(String.format("Linking a provider [%s] session to this client's session", user.getIssuer())); } @@ -266,10 +266,9 @@ public void onAuthentication(Authentication authentication, HttpServletRequest r /** * The registration for linking OIDC Provider Session information to the * Client's session. Defaults to in-memory. - * @param providerSessionRegistry the {@link OidcProviderSessionRegistry} to - * use + * @param providerSessionRegistry the {@link OidcSessionRegistry} to use */ - void setProviderSessionRegistry(OidcProviderSessionRegistry providerSessionRegistry) { + void setProviderSessionRegistry(OidcSessionRegistry providerSessionRegistry) { Assert.notNull(providerSessionRegistry, "providerSessionRegistry cannot be null"); this.providerSessionRegistry = providerSessionRegistry; } diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurerTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurerTests.java index d59f42124a6..89f470b564c 100644 --- a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurerTests.java +++ b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurerTests.java @@ -65,9 +65,9 @@ import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcBackChannelLogoutAuthentication; import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcLogoutToken; import org.springframework.security.oauth2.client.oidc.authentication.logout.TestOidcLogoutTokens; -import org.springframework.security.oauth2.client.oidc.authentication.session.OidcProviderSessionRegistrationDetails; -import org.springframework.security.oauth2.client.oidc.authentication.session.OidcProviderSessionRegistry; -import org.springframework.security.oauth2.client.oidc.authentication.session.TestOidcProviderSessionRegistrations; +import org.springframework.security.oauth2.client.oidc.authentication.session.OidcSessionRegistration; +import org.springframework.security.oauth2.client.oidc.authentication.session.OidcSessionRegistry; +import org.springframework.security.oauth2.client.oidc.authentication.session.TestOidcSessionRegistrations; import org.springframework.security.oauth2.client.registration.ClientRegistration; import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository; @@ -171,8 +171,8 @@ void logoutWhenCustomComponentsThenUses() throws Exception { given(authenticationManager.authenticate(any())) .willReturn(new OidcBackChannelLogoutAuthentication(logoutToken, this.registration)); LogoutHandler logoutHandler = this.spring.getContext().getBean(LogoutHandler.class); - OidcProviderSessionRegistry registry = this.spring.getContext().getBean(OidcProviderSessionRegistry.class); - Set details = Set.of(TestOidcProviderSessionRegistrations.create()); + OidcSessionRegistry registry = this.spring.getContext().getBean(OidcSessionRegistry.class); + Set details = Set.of(TestOidcSessionRegistrations.create()); given(registry.deregister(any(OidcLogoutToken.class))).willReturn(details); this.mvc.perform(post("/logout/connect/back-channel/" + registrationId).param("logout_token", "token")) .andExpect(status().isOk()); @@ -235,7 +235,7 @@ static class WithCustomComponentsConfig { LogoutHandler logoutHandler = mock(LogoutHandler.class); - OidcProviderSessionRegistry registry = mock(OidcProviderSessionRegistry.class); + OidcSessionRegistry registry = mock(OidcSessionRegistry.class); @Bean @Order(1) @@ -267,7 +267,7 @@ LogoutHandler logoutHandler() { } @Bean - OidcProviderSessionRegistry providerSessionRegistry() { + OidcSessionRegistry providerSessionRegistry() { return this.registry; } diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/session/InMemoryOidcProviderSessionRegistry.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/session/InMemoryOidcSessionRegistry.java similarity index 69% rename from oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/session/InMemoryOidcProviderSessionRegistry.java rename to oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/session/InMemoryOidcSessionRegistry.java index b1e2925f82d..fb854f2541f 100644 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/session/InMemoryOidcProviderSessionRegistry.java +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/session/InMemoryOidcSessionRegistry.java @@ -29,35 +29,36 @@ import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcLogoutToken; /** - * An in-memory implementation of {@link OidcProviderSessionRegistry} + * An in-memory implementation of {@link OidcSessionRegistry} * * @author Josh Cummings * @since 6.1 */ -public final class InMemoryOidcProviderSessionRegistry implements OidcProviderSessionRegistry { +public final class InMemoryOidcSessionRegistry implements OidcSessionRegistry { - private final Log logger = LogFactory.getLog(InMemoryOidcProviderSessionRegistry.class); + private final Log logger = LogFactory.getLog(InMemoryOidcSessionRegistry.class); - private final Map sessions = new ConcurrentHashMap<>(); + private final Map sessions = new ConcurrentHashMap<>(); @Override - public void register(OidcProviderSessionRegistrationDetails registration) { + public void register(OidcSessionRegistration registration) { this.sessions.put(registration.getClientSessionId(), registration); } @Override - public void reregister(String oldClientSessionId, String newClientSessionId) { - OidcProviderSessionRegistrationDetails old = this.sessions.remove(oldClientSessionId); + public void register(String oldClientSessionId, String newClientSessionId) { + OidcSessionRegistration old = this.sessions.remove(oldClientSessionId); if (old == null) { this.logger.debug("Failed to register new session id since old session id was not found in registry"); return; } - register(new OidcProviderSessionRegistration(newClientSessionId, old.getCsrfToken(), old.getPrincipal())); + register(new OidcSessionRegistration(newClientSessionId, old.getPrincipal(), + old.getLogoutAuthenticationToken())); } @Override - public OidcProviderSessionRegistrationDetails deregister(String clientSessionId) { - OidcProviderSessionRegistrationDetails details = this.sessions.remove(clientSessionId); + public OidcSessionRegistration deregister(String clientSessionId) { + OidcSessionRegistration details = this.sessions.remove(clientSessionId); if (details != null) { this.logger.trace("Removed client session"); } @@ -65,11 +66,11 @@ public OidcProviderSessionRegistrationDetails deregister(String clientSessionId) } @Override - public Iterable deregister(OidcLogoutToken token) { + public Iterable deregister(OidcLogoutToken token) { String issuer = token.getIssuer().toString(); String subject = token.getSubject(); String providerSessionId = token.getSessionId(); - Predicate matcher = (providerSessionId != null) + Predicate matcher = (providerSessionId != null) ? sessionIdMatcher(issuer, providerSessionId) : subjectMatcher(issuer, subject); if (this.logger.isTraceEnabled()) { String message = "Looking up sessions by issuer [%s] and %s [%s]"; @@ -81,7 +82,7 @@ public Iterable deregister(OidcLogoutTok } } int size = this.sessions.size(); - Set infos = new HashSet<>(); + Set infos = new HashSet<>(); this.sessions.values().removeIf((info) -> { boolean result = matcher.test(info); if (result) { @@ -99,7 +100,7 @@ else if (this.logger.isTraceEnabled()) { return infos; } - private static Predicate sessionIdMatcher(String issuer, String sessionId) { + private static Predicate sessionIdMatcher(String issuer, String sessionId) { return (session) -> { String thatIssuer = session.getPrincipal().getIssuer().toString(); String thatSessionId = session.getPrincipal().getClaimAsString(LogoutTokenClaimNames.SID); @@ -107,7 +108,7 @@ private static Predicate sessionIdMatche }; } - private static Predicate subjectMatcher(String issuer, String subject) { + private static Predicate subjectMatcher(String issuer, String subject) { return (session) -> { String thatIssuer = session.getPrincipal().getIssuer().toString(); String thatSubject = session.getPrincipal().getSubject(); diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/session/OidcProviderSessionRegistrationDetails.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/session/OidcProviderSessionRegistrationDetails.java deleted file mode 100644 index a9ab946ec6d..00000000000 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/session/OidcProviderSessionRegistrationDetails.java +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright 2002-2023 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.oidc.authentication.session; - -import org.springframework.security.oauth2.core.oidc.user.OidcUser; -import org.springframework.security.web.csrf.CsrfToken; - -/** - * A registration of the OIDC Provider Session with the Client's session - * - * @author Josh Cummings - * @since 6.1 - */ -public interface OidcProviderSessionRegistrationDetails { - - /** - * The Client's session id, typically the browser {@code JSESSIONID} - * @return the client session id - */ - String getClientSessionId(); - - /** - * The Client's CSRF Token tied to the client's session - * @return the {@link CsrfToken} - */ - CsrfToken getCsrfToken(); - - /** - * The Provider's End User, including any indicated session id - * @return - */ - OidcUser getPrincipal(); - -} diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/session/OidcProviderSessionRegistration.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/session/OidcSessionRegistration.java similarity index 54% rename from oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/session/OidcProviderSessionRegistration.java rename to oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/session/OidcSessionRegistration.java index 45a95e95e1e..ddbf482c057 100644 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/session/OidcProviderSessionRegistration.java +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/session/OidcSessionRegistration.java @@ -16,64 +16,45 @@ package org.springframework.security.oauth2.client.oidc.authentication.session; +import org.springframework.security.core.Authentication; import org.springframework.security.oauth2.core.oidc.user.OidcUser; -import org.springframework.security.web.csrf.CsrfToken; -import org.springframework.security.web.csrf.DefaultCsrfToken; /** - * The default implementation for {@link OidcProviderSessionRegistrationDetails}. Handy - * for in-memory registries. + * The default implementation for {@link OidcSessionRegistration}. Handy for in-memory + * registries. * * @author Josh Cummings - * @since 6.1 + * @since 6.2 */ -public class OidcProviderSessionRegistration implements OidcProviderSessionRegistrationDetails { +public class OidcSessionRegistration { private final String clientSessionId; - private final CsrfToken token; - private final OidcUser user; + private final Authentication logoutAuthenticationToken; + /** - * Construct an {@link OidcProviderSessionRegistration} + * Construct an {@link OidcSessionRegistration} * @param clientSessionId the Client's session id - * @param token the Client's CSRF token for this session + * @param logoutAuthenticationToken the Client's CSRF logoutAuthenticationToken for + * this session * @param user the OIDC Provider's session and end user */ - public OidcProviderSessionRegistration(String clientSessionId, CsrfToken token, OidcUser user) { + public OidcSessionRegistration(String clientSessionId, OidcUser user, Authentication logoutAuthenticationToken) { this.clientSessionId = clientSessionId; - this.token = extract(token); this.user = user; + this.logoutAuthenticationToken = logoutAuthenticationToken; } - private static CsrfToken extract(CsrfToken token) { - if (token == null) { - return null; - } - return new DefaultCsrfToken(token.getHeaderName(), token.getParameterName(), token.getToken()); - } - - /** - * {@inheritDoc} - */ - @Override public String getClientSessionId() { return this.clientSessionId; } - /** - * {@inheritDoc} - */ - @Override - public CsrfToken getCsrfToken() { - return this.token; + public Authentication getLogoutAuthenticationToken() { + return this.logoutAuthenticationToken; } - /** - * {@inheritDoc} - */ - @Override public OidcUser getPrincipal() { return this.user; } diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/session/OidcProviderSessionRegistry.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/session/OidcSessionRegistry.java similarity index 77% rename from oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/session/OidcProviderSessionRegistry.java rename to oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/session/OidcSessionRegistry.java index a384b903e3b..a8c0a793204 100644 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/session/OidcProviderSessionRegistry.java +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/session/OidcSessionRegistry.java @@ -29,14 +29,14 @@ * "https://openid.net/specs/openid-connect-backchannel-1_0.html#LogoutToken">Logout * Token */ -public interface OidcProviderSessionRegistry { +public interface OidcSessionRegistry { /** * Register a OIDC Provider session with the provided client session. Generally * speaking, the client session should be the session tied to the current login. - * @param details the {@link OidcProviderSessionRegistrationDetails} to use + * @param details the {@link OidcSessionRegistration} to use */ - void register(OidcProviderSessionRegistrationDetails details); + void register(OidcSessionRegistration details); /** * Update the entry for a Client when their session id changes. This is handy, for @@ -44,23 +44,22 @@ public interface OidcProviderSessionRegistry { * @param oldClientSessionId the Client's old session id * @param newClientSessionId the Client's new session id */ - void reregister(String oldClientSessionId, String newClientSessionId); + void register(String oldClientSessionId, String newClientSessionId); /** * Deregister the OIDC Provider session tied to the provided client session. Generally * speaking, the client session should be the session tied to the current logout. * @param clientSessionId the client session - * @return any found {@link OidcProviderSessionRegistrationDetails}, could be - * {@code null} + * @return any found {@link OidcSessionRegistration}, could be {@code null} */ - OidcProviderSessionRegistrationDetails deregister(String clientSessionId); + OidcSessionRegistration deregister(String clientSessionId); /** * Deregister the OIDC Provider sessions referenced by the provided OIDC Logout Token * by its session id or its subject. * @param logoutToken the {@link OidcLogoutToken} - * @return any found {@link OidcProviderSessionRegistrationDetails}s, could be empty + * @return any found {@link OidcSessionRegistration}s, could be empty */ - Iterable deregister(OidcLogoutToken logoutToken); + Iterable deregister(OidcLogoutToken logoutToken); } diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/web/authentication/logout/OidcBackChannelLogoutFilter.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/web/authentication/logout/OidcBackChannelLogoutFilter.java index 2baaba12405..e39b4fda386 100644 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/web/authentication/logout/OidcBackChannelLogoutFilter.java +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/web/authentication/logout/OidcBackChannelLogoutFilter.java @@ -31,13 +31,12 @@ import org.springframework.security.core.AuthenticationException; import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcBackChannelLogoutAuthenticationToken; import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcLogoutToken; -import org.springframework.security.oauth2.client.oidc.authentication.session.InMemoryOidcProviderSessionRegistry; -import org.springframework.security.oauth2.client.oidc.authentication.session.OidcProviderSessionRegistrationDetails; -import org.springframework.security.oauth2.client.oidc.authentication.session.OidcProviderSessionRegistry; +import org.springframework.security.oauth2.client.oidc.authentication.session.InMemoryOidcSessionRegistry; +import org.springframework.security.oauth2.client.oidc.authentication.session.OidcSessionRegistration; +import org.springframework.security.oauth2.client.oidc.authentication.session.OidcSessionRegistry; import org.springframework.security.oauth2.client.registration.ClientRegistration; import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; -import org.springframework.security.oauth2.jwt.JwtDecoderFactory; -import org.springframework.security.web.authentication.logout.BackchannelLogoutAuthentication; +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; import org.springframework.security.web.authentication.logout.BackchannelLogoutHandler; import org.springframework.security.web.authentication.logout.LogoutHandler; import org.springframework.security.web.util.matcher.AntPathRequestMatcher; @@ -46,16 +45,18 @@ import org.springframework.web.filter.OncePerRequestFilter; /** - * A filter for the Client-side OIDC Backchannel Logout endpoint + * A filter for the Client-side OIDC Back-Channel Logout endpoint * * @author Josh Cummings * @since 6.1 * @see OIDC Backchannel Logout + * "https://openid.net/specs/openid-connect-backchannel-1_0.html">OIDC Back-Channel Logout * Spec */ public class OidcBackChannelLogoutFilter extends OncePerRequestFilter { + private static final String DEFAULT_LOGOUT_URI = "/logout/connect/back-channel/{registrationId}"; + private final Log logger = LogFactory.getLog(getClass()); private static final String ERROR_MESSAGE = "{ \"error\" : \"%s\", \"error_description\" : \"%s\" }"; @@ -64,10 +65,9 @@ public class OidcBackChannelLogoutFilter extends OncePerRequestFilter { private final AuthenticationManager authenticationManager; - private RequestMatcher requestMatcher = new AntPathRequestMatcher("/logout/connect/back-channel/{registrationId}", - "POST"); + private RequestMatcher requestMatcher = new AntPathRequestMatcher(DEFAULT_LOGOUT_URI, "POST"); - private OidcProviderSessionRegistry providerSessionRegistry = new InMemoryOidcProviderSessionRegistry(); + private OidcSessionRegistry providerSessionRegistry = new InMemoryOidcSessionRegistry(); private LogoutHandler logoutHandler = new BackchannelLogoutHandler(); @@ -75,8 +75,8 @@ public class OidcBackChannelLogoutFilter extends OncePerRequestFilter { * Construct an {@link OidcBackChannelLogoutFilter} * @param clients the {@link ClientRegistrationRepository} for deriving Logout Token * validation - * @param logoutTokenDecoderFactory the {@link JwtDecoderFactory} for constructing - * Logout Token validators + * @param authenticationManager the {@link AuthenticationManager} for authenticating + * Logout Tokens */ public OidcBackChannelLogoutFilter(ClientRegistrationRepository clients, AuthenticationManager authenticationManager) { @@ -98,15 +98,15 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse String registrationId = result.getVariables().get("registrationId"); ClientRegistration registration = this.clientRegistrationRepository.findByRegistrationId(registrationId); if (registration == null) { - this.logger.debug("Did not process OIDC Backchannel Logout since no ClientRegistration was found"); + this.logger.debug("Did not process OIDC Back-Channel Logout since no ClientRegistration was found"); chain.doFilter(request, response); return; } String logoutToken = request.getParameter("logout_token"); if (logoutToken == null) { - String error = "Failed to process OIDC Backchannel Logout since no logout token was found"; + String error = "Failed to process OIDC Back-Channel Logout since no logout token was found"; this.logger.debug(error); - String message = String.format(ERROR_MESSAGE, "invalid_request", error); + String message = String.format(ERROR_MESSAGE, OAuth2ErrorCodes.INVALID_REQUEST, error); response.sendError(400, message); return; } @@ -115,24 +115,22 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse token = authenticate(logoutToken, registration); } catch (AuthenticationException ex) { - this.logger.debug("Failed to process OIDC Backchannel Logout", ex); - String message = String.format(ERROR_MESSAGE, "invalid_request", ex.getMessage()); + this.logger.debug("Failed to process OIDC Back-Channel Logout", ex); + String message = String.format(ERROR_MESSAGE, OAuth2ErrorCodes.INVALID_REQUEST, ex.getMessage()); response.sendError(400, message); return; } int sessionCount = 0; int loggedOutCount = 0; List messages = new ArrayList<>(); - Iterable sessions = this.providerSessionRegistry.deregister(token); - for (OidcProviderSessionRegistrationDetails session : sessions) { - BackchannelLogoutAuthentication authentication = new BackchannelLogoutAuthentication( - session.getClientSessionId(), session.getCsrfToken()); + Iterable sessions = this.providerSessionRegistry.deregister(token); + for (OidcSessionRegistration session : sessions) { try { if (this.logger.isTraceEnabled()) { String message = "Logging out session #%d from result set for issuer [%s]"; this.logger.trace(String.format(message, sessionCount, token.getIssuer())); } - this.logoutHandler.logout(request, response, authentication); + this.logoutHandler.logout(request, response, session.getLogoutAuthenticationToken()); loggedOutCount++; } catch (Exception ex) { @@ -175,7 +173,8 @@ private OidcLogoutToken authenticate(String logoutToken, ClientRegistration regi } /** - * The logout endpoint. Defaults to {@code /oauth2/{registrationId}/logout}. + * The logout endpoint. Defaults to + * {@code /logout/connect/back-channel/{registrationId}}. * @param requestMatcher the {@link RequestMatcher} to use */ public void setRequestMatcher(RequestMatcher requestMatcher) { @@ -185,9 +184,9 @@ public void setRequestMatcher(RequestMatcher requestMatcher) { /** * The registry for linking Client sessions to OIDC Provider sessions and End Users - * @param providerSessionRegistry the {@link OidcProviderSessionRegistry} to use + * @param providerSessionRegistry the {@link OidcSessionRegistry} to use */ - public void setProviderSessionRegistry(OidcProviderSessionRegistry providerSessionRegistry) { + public void setProviderSessionRegistry(OidcSessionRegistry providerSessionRegistry) { Assert.notNull(providerSessionRegistry, "providerSessionRegistry cannot be null"); this.providerSessionRegistry = providerSessionRegistry; } diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/authentication/session/InMemoryOidcProviderSessionRegistryTests.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/authentication/session/InMemoryOidcSessionRegistryTests.java similarity index 69% rename from oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/authentication/session/InMemoryOidcProviderSessionRegistryTests.java rename to oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/authentication/session/InMemoryOidcSessionRegistryTests.java index 7c6d48ba345..ca495d4181d 100644 --- a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/authentication/session/InMemoryOidcProviderSessionRegistryTests.java +++ b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/authentication/session/InMemoryOidcSessionRegistryTests.java @@ -25,57 +25,53 @@ import org.springframework.security.oauth2.core.oidc.TestOidcIdTokens; 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.TestOidcUsers; import static org.assertj.core.api.Assertions.assertThat; -public class InMemoryOidcProviderSessionRegistryTests { +public class InMemoryOidcSessionRegistryTests { @Test public void registerWhenDefaultsThenStoresSessionInformation() { - InMemoryOidcProviderSessionRegistry registry = new InMemoryOidcProviderSessionRegistry(); + InMemoryOidcSessionRegistry registry = new InMemoryOidcSessionRegistry(); String sessionId = "client"; - OidcUser user = TestOidcUsers.create(); - OidcProviderSessionRegistrationDetails info = new OidcProviderSessionRegistration(sessionId, null, user); + OidcSessionRegistration info = TestOidcSessionRegistrations.create(sessionId); registry.register(info); - assertThat(info.getClientSessionId()).isSameAs(sessionId); - assertThat(info.getPrincipal()).isSameAs(user); - Iterable infos = registry - .deregister(TestOidcLogoutTokens.withUser(user).build()); + OidcLogoutToken token = TestOidcLogoutTokens.withUser(info.getPrincipal()).build(); + Iterable infos = registry.deregister(token); assertThat(infos).containsExactly(info); } @Test public void registerWhenIdTokenHasSessionIdThenStoresSessionInformation() { - InMemoryOidcProviderSessionRegistry registry = new InMemoryOidcProviderSessionRegistry(); + InMemoryOidcSessionRegistry registry = new InMemoryOidcSessionRegistry(); OidcIdToken token = TestOidcIdTokens.idToken().claim("sid", "provider").build(); OidcUser user = new DefaultOidcUser(AuthorityUtils.NO_AUTHORITIES, token); - OidcProviderSessionRegistrationDetails info = new OidcProviderSessionRegistration("client", null, user); + OidcSessionRegistration info = TestOidcSessionRegistrations.create("client", user); registry.register(info); OidcLogoutToken logoutToken = TestOidcLogoutTokens.withSessionId(token.getIssuer().toString(), "provider") .build(); - Iterable infos = registry.deregister(logoutToken); + Iterable infos = registry.deregister(logoutToken); assertThat(infos).containsExactly(info); } @Test public void unregisterWhenMultipleSessionsThenRemovesAllMatching() { - InMemoryOidcProviderSessionRegistry registry = new InMemoryOidcProviderSessionRegistry(); + InMemoryOidcSessionRegistry registry = new InMemoryOidcSessionRegistry(); OidcIdToken token = TestOidcIdTokens.idToken().claim("sid", "providerOne").subject("otheruser").build(); OidcUser user = new DefaultOidcUser(AuthorityUtils.NO_AUTHORITIES, token); - OidcProviderSessionRegistrationDetails one = new OidcProviderSessionRegistration("clientOne", null, user); + OidcSessionRegistration one = TestOidcSessionRegistrations.create("clientOne", user); registry.register(one); token = TestOidcIdTokens.idToken().claim("sid", "providerTwo").build(); user = new DefaultOidcUser(AuthorityUtils.NO_AUTHORITIES, token); - OidcProviderSessionRegistrationDetails two = new OidcProviderSessionRegistration("clientTwo", null, user); + OidcSessionRegistration two = TestOidcSessionRegistrations.create("clientTwo", user); registry.register(two); token = TestOidcIdTokens.idToken().claim("sid", "providerThree").build(); user = new DefaultOidcUser(AuthorityUtils.NO_AUTHORITIES, token); - OidcProviderSessionRegistrationDetails three = new OidcProviderSessionRegistration("clientThree", null, user); + OidcSessionRegistration three = TestOidcSessionRegistrations.create("clientThree", user); registry.register(three); OidcLogoutToken logoutToken = TestOidcLogoutTokens.withSubject(token.getIssuer().toString(), token.getSubject()) .build(); - Iterable infos = registry.deregister(logoutToken); + Iterable infos = registry.deregister(logoutToken); assertThat(infos).containsExactlyInAnyOrder(two, three); logoutToken = TestOidcLogoutTokens.withSubject(token.getIssuer().toString(), "otheruser").build(); infos = registry.deregister(logoutToken); @@ -84,10 +80,11 @@ public void unregisterWhenMultipleSessionsThenRemovesAllMatching() { @Test public void unregisterWhenNoSessionsThenEmptyList() { - InMemoryOidcProviderSessionRegistry registry = new InMemoryOidcProviderSessionRegistry(); + InMemoryOidcSessionRegistry registry = new InMemoryOidcSessionRegistry(); OidcIdToken token = TestOidcIdTokens.idToken().claim("sid", "provider").build(); OidcUser user = new DefaultOidcUser(AuthorityUtils.NO_AUTHORITIES, token); - registry.register(new OidcProviderSessionRegistration("client", null, user)); + OidcSessionRegistration registration = TestOidcSessionRegistrations.create("client", user); + registry.register(registration); OidcLogoutToken logoutToken = TestOidcLogoutTokens.withSessionId(token.getIssuer().toString(), "wrong").build(); Iterable infos = registry.deregister(logoutToken); assertThat(infos).isNotNull(); diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/authentication/session/TestOidcProviderSessionRegistrations.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/authentication/session/TestOidcSessionRegistrations.java similarity index 60% rename from oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/authentication/session/TestOidcProviderSessionRegistrations.java rename to oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/authentication/session/TestOidcSessionRegistrations.java index 098c80b5e61..f29601508a9 100644 --- a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/authentication/session/TestOidcProviderSessionRegistrations.java +++ b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/authentication/session/TestOidcSessionRegistrations.java @@ -16,18 +16,28 @@ package org.springframework.security.oauth2.client.oidc.authentication.session; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; import org.springframework.security.oauth2.core.oidc.user.TestOidcUsers; +import org.springframework.security.web.authentication.logout.BackchannelLogoutAuthentication; import org.springframework.security.web.csrf.CsrfToken; import org.springframework.security.web.csrf.DefaultCsrfToken; -public final class TestOidcProviderSessionRegistrations { +public final class TestOidcSessionRegistrations { - public static OidcProviderSessionRegistration create() { + public static OidcSessionRegistration create() { + return create("sessionId"); + } + + public static OidcSessionRegistration create(String sessionId) { + return create(sessionId, TestOidcUsers.create()); + } + + public static OidcSessionRegistration create(String sessionId, OidcUser user) { CsrfToken token = new DefaultCsrfToken("header", "parameter", "token"); - return new OidcProviderSessionRegistration("sessionId", token, TestOidcUsers.create()); + return new OidcSessionRegistration(sessionId, user, new BackchannelLogoutAuthentication(sessionId, token)); } - private TestOidcProviderSessionRegistrations() { + private TestOidcSessionRegistrations() { } diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/web/authentication/logout/OidcBackChannelLogoutFilterTests.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/web/authentication/logout/OidcBackChannelLogoutFilterTests.java index fe0b9b50327..fd170b33bd8 100644 --- a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/web/authentication/logout/OidcBackChannelLogoutFilterTests.java +++ b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/web/authentication/logout/OidcBackChannelLogoutFilterTests.java @@ -28,13 +28,12 @@ import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcBackChannelLogoutAuthentication; import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcLogoutToken; import org.springframework.security.oauth2.client.oidc.authentication.logout.TestOidcLogoutTokens; -import org.springframework.security.oauth2.client.oidc.authentication.session.OidcProviderSessionRegistration; -import org.springframework.security.oauth2.client.oidc.authentication.session.OidcProviderSessionRegistrationDetails; -import org.springframework.security.oauth2.client.oidc.authentication.session.OidcProviderSessionRegistry; +import org.springframework.security.oauth2.client.oidc.authentication.session.OidcSessionRegistration; +import org.springframework.security.oauth2.client.oidc.authentication.session.OidcSessionRegistry; +import org.springframework.security.oauth2.client.oidc.authentication.session.TestOidcSessionRegistrations; import org.springframework.security.oauth2.client.registration.ClientRegistration; import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; import org.springframework.security.oauth2.client.registration.TestClientRegistrations; -import org.springframework.security.oauth2.core.oidc.user.TestOidcUsers; import org.springframework.security.web.authentication.logout.LogoutHandler; import static org.assertj.core.api.Assertions.assertThat; @@ -100,11 +99,9 @@ public void doFilterWithSessionMatchingLogoutTokenThenInvalidates() throws Excep AuthenticationManager factory = mock(AuthenticationManager.class); OidcLogoutToken token = TestOidcLogoutTokens.withSessionId("issuer", "provider").build(); given(factory.authenticate(any())).willReturn(new OidcBackChannelLogoutAuthentication(token, registration)); - OidcProviderSessionRegistry registry = mock(OidcProviderSessionRegistry.class); - Iterable infos = Set.of( - (OidcProviderSessionRegistrationDetails) new OidcProviderSessionRegistration("clientOne", null, - TestOidcUsers.create()), - new OidcProviderSessionRegistration("clientTwo", null, TestOidcUsers.create())); + OidcSessionRegistry registry = mock(OidcSessionRegistry.class); + Iterable infos = Set.of(TestOidcSessionRegistrations.create("clientOne"), + TestOidcSessionRegistrations.create("clientTwo")); given(registry.deregister(any(OidcLogoutToken.class))).willReturn(infos); LogoutHandler logoutHandler = mock(LogoutHandler.class); OidcBackChannelLogoutFilter filter = new OidcBackChannelLogoutFilter(clients, factory); @@ -129,11 +126,9 @@ public void doFilterWhenInvalidJwtThenBadRequest() throws Exception { given(clients.findByRegistrationId(any())).willReturn(registration); AuthenticationManager factory = mock(AuthenticationManager.class); given(factory.authenticate(any())).willThrow(new BadCredentialsException("bad")); - OidcProviderSessionRegistry registry = mock(OidcProviderSessionRegistry.class); - Iterable infos = Set.of( - (OidcProviderSessionRegistrationDetails) new OidcProviderSessionRegistration("clientOne", null, - TestOidcUsers.create()), - new OidcProviderSessionRegistration("clientTwo", null, TestOidcUsers.create())); + OidcSessionRegistry registry = mock(OidcSessionRegistry.class); + Iterable infos = Set.of(TestOidcSessionRegistrations.create("clientOne"), + TestOidcSessionRegistrations.create("clientTwo")); given(registry.deregister(any(OidcLogoutToken.class))).willReturn(infos); LogoutHandler logoutHandler = mock(LogoutHandler.class); OidcBackChannelLogoutFilter filter = new OidcBackChannelLogoutFilter(clients, factory); diff --git a/web/src/main/java/org/springframework/security/web/authentication/logout/BackchannelLogoutAuthentication.java b/web/src/main/java/org/springframework/security/web/authentication/logout/BackchannelLogoutAuthentication.java index 8172bd16f5a..d042c9b8c18 100644 --- a/web/src/main/java/org/springframework/security/web/authentication/logout/BackchannelLogoutAuthentication.java +++ b/web/src/main/java/org/springframework/security/web/authentication/logout/BackchannelLogoutAuthentication.java @@ -19,6 +19,7 @@ import org.springframework.security.authentication.AbstractAuthenticationToken; import org.springframework.security.core.authority.AuthorityUtils; import org.springframework.security.web.csrf.CsrfToken; +import org.springframework.security.web.csrf.DefaultCsrfToken; import org.springframework.util.Assert; public class BackchannelLogoutAuthentication extends AbstractAuthenticationToken { @@ -31,7 +32,14 @@ public BackchannelLogoutAuthentication(String sessionId, CsrfToken csrfToken) { super(AuthorityUtils.createAuthorityList("BACKCHANNEL_LOGOUT")); Assert.notNull(sessionId, "sessionId cannot be null"); this.sessionId = sessionId; - this.csrfToken = csrfToken; + this.csrfToken = extract(csrfToken); + } + + private static CsrfToken extract(CsrfToken token) { + if (token == null) { + return null; + } + return new DefaultCsrfToken(token.getHeaderName(), token.getParameterName(), token.getToken()); } @Override From b7bc2fe1a02a33d5003d4077c6b6bebb10a8df1e Mon Sep 17 00:00:00 2001 From: Josh Cummings Date: Thu, 6 Jul 2023 12:06:14 -0600 Subject: [PATCH 03/12] Responding to Feedback --- .../annotation/web/builders/HttpSecurity.java | 6 +- .../oauth2/client/OidcLogoutConfigurer.java | 7 +- .../client/OidcLogoutConfigurerTests.java | 4 +- ...efaultOidcLogoutTokenValidatorFactory.java | 35 ++++++ .../logout/LogoutTokenClaimAccessor.java | 4 +- ...ackChannelLogoutAuthenticationManager.java | 28 ++++- .../logout/OidcLogoutTokenDecoderFactory.java | 59 --------- .../logout/OidcLogoutTokenValidator.java | 113 ++++++++++++++++++ .../logout/OidcBackChannelLogoutFilter.java | 81 +++++-------- ...ava => OidcLogoutTokenValidatorTests.java} | 7 +- .../session/TestOidcSessionRegistrations.java | 8 +- .../OidcBackChannelLogoutFilterTests.java | 8 +- .../BackchannelLogoutAuthentication.java | 31 ++--- .../logout/BackchannelLogoutHandler.java | 20 ++-- 14 files changed, 248 insertions(+), 163 deletions(-) create mode 100644 oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/DefaultOidcLogoutTokenValidatorFactory.java delete mode 100644 oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/OidcLogoutTokenDecoderFactory.java create mode 100644 oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/OidcLogoutTokenValidator.java rename oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/authentication/logout/{NimbusLogoutTokenDecoderFactoryTests.java => OidcLogoutTokenValidatorTests.java} (85%) diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/builders/HttpSecurity.java b/config/src/main/java/org/springframework/security/config/annotation/web/builders/HttpSecurity.java index 603184a44ba..84f2a629621 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/builders/HttpSecurity.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/builders/HttpSecurity.java @@ -2836,13 +2836,13 @@ public HttpSecurity oauth2Login(Customizer> return HttpSecurity.this; } - public OidcLogoutConfigurer oauth2Logout() throws Exception { + public OidcLogoutConfigurer oidcLogout() throws Exception { return getOrApply(new OidcLogoutConfigurer<>()); } - public HttpSecurity oauth2Logout(Customizer> oauth2LogoutCustomizer) + public HttpSecurity oidcLogout(Customizer> oidcLogoutCustomizer) throws Exception { - oauth2LogoutCustomizer.customize(getOrApply(new OidcLogoutConfigurer<>())); + oidcLogoutCustomizer.customize(getOrApply(new OidcLogoutConfigurer<>())); return HttpSecurity.this; } diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurer.java index c642aa8daa2..c7e855e92bc 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurer.java @@ -16,6 +16,8 @@ package org.springframework.security.config.annotation.web.configurers.oauth2.client; +import java.util.Collections; +import java.util.Map; import java.util.function.Consumer; import jakarta.servlet.http.HttpServletRequest; @@ -81,7 +83,7 @@ * * @author Josh Cummings * @since 6.1 - * @see HttpSecurity#oauth2Logout() + * @see HttpSecurity#oidcLogout() * @see OidcBackChannelLogoutFilter * @see ClientRegistrationRepository */ @@ -255,7 +257,8 @@ public void onAuthentication(Authentication authentication, HttpServletRequest r } String sessionId = session.getId(); CsrfToken csrfToken = (CsrfToken) request.getAttribute(CsrfToken.class.getName()); - BackchannelLogoutAuthentication logoutAuthentication = new BackchannelLogoutAuthentication(sessionId, csrfToken); + Map credentials = (csrfToken != null) ? Map.of(csrfToken.getHeaderName(), csrfToken.getToken()) : Collections.emptyMap(); + BackchannelLogoutAuthentication logoutAuthentication = new BackchannelLogoutAuthentication(sessionId, credentials); OidcSessionRegistration registration = new OidcSessionRegistration(sessionId, user, logoutAuthentication); if (this.logger.isTraceEnabled()) { this.logger.trace(String.format("Linking a provider [%s] session to this client's session", user.getIssuer())); diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurerTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurerTests.java index 89f470b564c..39bab192ab1 100644 --- a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurerTests.java +++ b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurerTests.java @@ -216,7 +216,7 @@ SecurityFilterChain filters(HttpSecurity http) throws Exception { http .authorizeHttpRequests((authorize) -> authorize.anyRequest().authenticated()) .oauth2Login(Customizer.withDefaults()) - .oauth2Logout((oauth2) -> oauth2. + .oidcLogout((oauth2) -> oauth2. backChannel((backchannel) -> { }) ); // @formatter:on @@ -244,7 +244,7 @@ SecurityFilterChain filters(HttpSecurity http) throws Exception { http .authorizeHttpRequests((authorize) -> authorize.anyRequest().authenticated()) .oauth2Login(Customizer.withDefaults()) - .oauth2Logout((oauth2) -> oauth2. + .oidcLogout((oauth2) -> oauth2. backChannel((backchannel) -> backchannel .clientLogoutHandler(this.logoutHandler) .authenticationManager(this.authenticationManager) diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/DefaultOidcLogoutTokenValidatorFactory.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/DefaultOidcLogoutTokenValidatorFactory.java new file mode 100644 index 00000000000..847b67f827a --- /dev/null +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/DefaultOidcLogoutTokenValidatorFactory.java @@ -0,0 +1,35 @@ +/* + * Copyright 2002-2023 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.oidc.authentication.logout; + +import java.util.function.Function; + +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator; +import org.springframework.security.oauth2.core.OAuth2TokenValidator; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtTimestampValidator; + +class DefaultOidcLogoutTokenValidatorFactory implements Function> { + + @Override + public OAuth2TokenValidator apply(ClientRegistration clientRegistration) { + return new DelegatingOAuth2TokenValidator<>(new JwtTimestampValidator(), + new OidcLogoutTokenValidator(clientRegistration)); + } + +} diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/LogoutTokenClaimAccessor.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/LogoutTokenClaimAccessor.java index 2a1b34aab3f..1fe9c78c466 100644 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/LogoutTokenClaimAccessor.java +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/LogoutTokenClaimAccessor.java @@ -22,7 +22,6 @@ import java.util.Map; import org.springframework.security.oauth2.core.ClaimAccessor; -import org.springframework.security.oauth2.jwt.JwtClaimAccessor; /** * A {@link ClaimAccessor} for the "claims" that can be returned in OIDC @@ -35,7 +34,7 @@ * "https://openid.net/specs/openid-connect-backchannel-1_0.html#LogoutToken">Logout * Token */ -public interface LogoutTokenClaimAccessor extends JwtClaimAccessor { +public interface LogoutTokenClaimAccessor extends ClaimAccessor { /** * Returns the Issuer identifier {@code (iss)}. @@ -49,7 +48,6 @@ default URL getIssuer() { * Returns the Subject identifier {@code (sub)}. * @return the Subject identifier */ - @Override default String getSubject() { return this.getClaimAsString(LogoutTokenClaimNames.SUB); } diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/OidcBackChannelLogoutAuthenticationManager.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/OidcBackChannelLogoutAuthenticationManager.java index d740e504851..9c8945c6bde 100644 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/OidcBackChannelLogoutAuthenticationManager.java +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/OidcBackChannelLogoutAuthenticationManager.java @@ -21,15 +21,29 @@ import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; +import org.springframework.security.oauth2.client.oidc.authentication.OidcIdTokenDecoderFactory; import org.springframework.security.oauth2.client.registration.ClientRegistration; import org.springframework.security.oauth2.jwt.BadJwtException; +import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.jwt.JwtDecoder; import org.springframework.security.oauth2.jwt.JwtDecoderFactory; import org.springframework.security.oauth2.jwt.JwtException; +import org.springframework.util.Assert; -public class OidcBackChannelLogoutAuthenticationManager implements AuthenticationManager { +public final class OidcBackChannelLogoutAuthenticationManager implements AuthenticationManager { - private final JwtDecoderFactory logoutTokenDecoderFactory = new OidcLogoutTokenDecoderFactory(); + private JwtDecoderFactory logoutTokenDecoderFactory; + + public OidcBackChannelLogoutAuthenticationManager() { + OidcIdTokenDecoderFactory logoutTokenDecoderFactory = new OidcIdTokenDecoderFactory(); + logoutTokenDecoderFactory.setJwtValidatorFactory(new DefaultOidcLogoutTokenValidatorFactory()); + this.logoutTokenDecoderFactory = logoutTokenDecoderFactory; + } + + public void setLogoutTokenDecoderFactory(JwtDecoderFactory logoutTokenDecoderFactory) { + Assert.notNull(logoutTokenDecoderFactory, "logoutTokenDecoderFactory cannot be null"); + this.logoutTokenDecoderFactory = logoutTokenDecoderFactory; + } @Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { @@ -38,10 +52,16 @@ public Authentication authenticate(Authentication authentication) throws Authent } String logoutToken = token.getLogoutToken(); ClientRegistration registration = token.getClientRegistration(); + Jwt jwt = decode(registration, logoutToken); + OidcLogoutToken oidcLogoutToken = OidcLogoutToken.withTokenValue(logoutToken) + .claims((claims) -> claims.putAll(jwt.getClaims())).build(); + return new OidcBackChannelLogoutAuthentication(oidcLogoutToken, registration); + } + + private Jwt decode(ClientRegistration registration, String token) { JwtDecoder logoutTokenDecoder = this.logoutTokenDecoderFactory.createDecoder(registration); try { - return new OidcBackChannelLogoutAuthentication(OidcLogoutToken.withTokenValue(logoutToken) - .claims((claims) -> claims.putAll(logoutTokenDecoder.decode(logoutToken).getClaims())).build(), registration); + return logoutTokenDecoder.decode(token); } catch (BadJwtException failed) { throw new BadCredentialsException(failed.getMessage(), failed); diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/OidcLogoutTokenDecoderFactory.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/OidcLogoutTokenDecoderFactory.java deleted file mode 100644 index a1f0e398026..00000000000 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/OidcLogoutTokenDecoderFactory.java +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Copyright 2002-2023 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.oidc.authentication.logout; - -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; - -import com.nimbusds.oauth2.sdk.id.ClientID; -import com.nimbusds.oauth2.sdk.id.Issuer; -import com.nimbusds.openid.connect.sdk.validators.LogoutTokenClaimsVerifier; - -import org.springframework.security.oauth2.client.registration.ClientRegistration; -import org.springframework.security.oauth2.jwt.JwtDecoder; -import org.springframework.security.oauth2.jwt.JwtDecoderFactory; -import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; - -/** - * A {@link JwtDecoderFactory} that decodes and verifies OIDC Logout Tokens. - * - * @author Josh Cummings - * @since 6.1 - * @see OidcLogoutToken - * @see Logout - * Token - */ -public final class OidcLogoutTokenDecoderFactory implements JwtDecoderFactory { - - private final Map jwtDecoderByRegistrationId = new ConcurrentHashMap<>(); - - /** - * {@inheritDoc} - */ - @Override - public JwtDecoder createDecoder(ClientRegistration context) { - ClientID clientId = new ClientID(context.getClientId()); - Issuer issuer = new Issuer(context.getProviderDetails().getIssuerUri()); - LogoutTokenClaimsVerifier verifier = new LogoutTokenClaimsVerifier(issuer, clientId); - return this.jwtDecoderByRegistrationId.computeIfAbsent(context.getRegistrationId(), - (k) -> NimbusJwtDecoder.withJwkSetUri(context.getProviderDetails().getJwkSetUri()) - .jwtProcessorCustomizer((jwtProcessor) -> jwtProcessor.setJWTClaimsSetVerifier(verifier)) - .build()); - } - -} diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/OidcLogoutTokenValidator.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/OidcLogoutTokenValidator.java new file mode 100644 index 00000000000..680820c134a --- /dev/null +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/OidcLogoutTokenValidator.java @@ -0,0 +1,113 @@ +/* + * Copyright 2002-2023 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.oidc.authentication.logout; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; + +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; +import org.springframework.security.oauth2.core.OAuth2TokenValidator; +import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtDecoderFactory; + +/** + * A {@link JwtDecoderFactory} that decodes and verifies OIDC Logout Tokens. + * + * @author Josh Cummings + * @since 6.2 + * @see OidcLogoutToken + * @see Logout + * Token + */ +public final class OidcLogoutTokenValidator implements OAuth2TokenValidator { + + private static final String LOGOUT_VALIDATION_URL = "https://openid.net/specs/openid-connect-backchannel-1_0.html#Validation"; + + private static final String BACK_CHANNEL_LOGOUT_EVENT = "http://schemas.openid.net/event/backchannel-logout"; + + private final String audience; + + private final String issuer; + + OidcLogoutTokenValidator(ClientRegistration clientRegistration) { + this.audience = clientRegistration.getClientId(); + this.issuer = clientRegistration.getProviderDetails().getIssuerUri(); + } + + @Override + public OAuth2TokenValidatorResult validate(Jwt jwt) { + Collection errors = new ArrayList<>(); + + LogoutTokenClaimAccessor logoutClaims = jwt::getClaims; + Map events = logoutClaims.getEvents(); + if (events == null) { + errors.add(invalidLogoutToken("events claim must not be null")); + } + else if (events.get(BACK_CHANNEL_LOGOUT_EVENT) == null) { + errors.add(invalidLogoutToken("events claim map must contain \"" + BACK_CHANNEL_LOGOUT_EVENT + "\" key")); + } + + String issuer = logoutClaims.getIssuer().toExternalForm(); + if (issuer == null) { + errors.add(invalidLogoutToken("iss claim must not be null")); + } + else if (!this.issuer.equals(issuer)) { + errors.add(invalidLogoutToken( + "iss claim value must match `ClientRegistration#getProviderDetails#getIssuerUri`")); + } + + List audience = logoutClaims.getAudience(); + if (audience == null) { + errors.add(invalidLogoutToken("aud claim must not be null")); + } + else if (!audience.contains(this.audience)) { + errors.add(invalidLogoutToken("aud claim value must include `ClientRegistration#getClientId`")); + } + + Instant issuedAt = logoutClaims.getIssuedAt(); + if (issuedAt == null) { + errors.add(invalidLogoutToken("iat claim must not be null")); + } + + String jwtId = logoutClaims.getId(); + if (jwtId == null) { + errors.add(invalidLogoutToken("jti claim must not be null")); + } + + if (logoutClaims.getSubject() == null && logoutClaims.getSessionId() == null) { + errors.add(invalidLogoutToken("sub and sid claims must not both be null")); + } + + if (logoutClaims.getClaim("nonce") != null) { + errors.add(invalidLogoutToken("nonce claim must not be present")); + } + + return OAuth2TokenValidatorResult.failure(errors); + } + + private static OAuth2Error invalidLogoutToken(String description) { + return new OAuth2Error(OAuth2ErrorCodes.INVALID_TOKEN, description, LOGOUT_VALIDATION_URL); + } + +} diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/web/authentication/logout/OidcBackChannelLogoutFilter.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/web/authentication/logout/OidcBackChannelLogoutFilter.java index e39b4fda386..95710205d0c 100644 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/web/authentication/logout/OidcBackChannelLogoutFilter.java +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/web/authentication/logout/OidcBackChannelLogoutFilter.java @@ -17,8 +17,6 @@ package org.springframework.security.oauth2.client.oidc.web.authentication.logout; import java.io.IOException; -import java.util.ArrayList; -import java.util.List; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; @@ -27,7 +25,9 @@ import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; +import org.springframework.http.server.ServletServerHttpResponse; import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.AuthenticationServiceException; import org.springframework.security.core.AuthenticationException; import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcBackChannelLogoutAuthenticationToken; import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcLogoutToken; @@ -36,7 +36,9 @@ import org.springframework.security.oauth2.client.oidc.authentication.session.OidcSessionRegistry; import org.springframework.security.oauth2.client.registration.ClientRegistration; import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.oauth2.core.OAuth2Error; import org.springframework.security.oauth2.core.OAuth2ErrorCodes; +import org.springframework.security.oauth2.core.http.converter.OAuth2ErrorHttpMessageConverter; import org.springframework.security.web.authentication.logout.BackchannelLogoutHandler; import org.springframework.security.web.authentication.logout.LogoutHandler; import org.springframework.security.web.util.matcher.AntPathRequestMatcher; @@ -59,12 +61,12 @@ public class OidcBackChannelLogoutFilter extends OncePerRequestFilter { private final Log logger = LogFactory.getLog(getClass()); - private static final String ERROR_MESSAGE = "{ \"error\" : \"%s\", \"error_description\" : \"%s\" }"; - private final ClientRegistrationRepository clientRegistrationRepository; private final AuthenticationManager authenticationManager; + private final OAuth2ErrorHttpMessageConverter errorHttpMessageConverter = new OAuth2ErrorHttpMessageConverter(); + private RequestMatcher requestMatcher = new AntPathRequestMatcher(DEFAULT_LOGOUT_URI, "POST"); private OidcSessionRegistry providerSessionRegistry = new InMemoryOidcSessionRegistry(); @@ -73,14 +75,16 @@ public class OidcBackChannelLogoutFilter extends OncePerRequestFilter { /** * Construct an {@link OidcBackChannelLogoutFilter} - * @param clients the {@link ClientRegistrationRepository} for deriving Logout Token - * validation + * @param clientRegistrationRepository the {@link ClientRegistrationRepository} for + * deriving Logout Token authentication * @param authenticationManager the {@link AuthenticationManager} for authenticating * Logout Tokens */ - public OidcBackChannelLogoutFilter(ClientRegistrationRepository clients, + public OidcBackChannelLogoutFilter(ClientRegistrationRepository clientRegistrationRepository, AuthenticationManager authenticationManager) { - this.clientRegistrationRepository = clients; + Assert.notNull(clientRegistrationRepository, "clientRegistrationRepository cannot be null"); + Assert.notNull(authenticationManager, "authenticationManager cannot be null"); + this.clientRegistrationRepository = clientRegistrationRepository; this.authenticationManager = authenticationManager; } @@ -99,70 +103,45 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse ClientRegistration registration = this.clientRegistrationRepository.findByRegistrationId(registrationId); if (registration == null) { this.logger.debug("Did not process OIDC Back-Channel Logout since no ClientRegistration was found"); - chain.doFilter(request, response); + response.sendError(HttpServletResponse.SC_BAD_REQUEST); return; } String logoutToken = request.getParameter("logout_token"); if (logoutToken == null) { - String error = "Failed to process OIDC Back-Channel Logout since no logout token was found"; - this.logger.debug(error); - String message = String.format(ERROR_MESSAGE, OAuth2ErrorCodes.INVALID_REQUEST, error); - response.sendError(400, message); + this.logger.debug("Failed to process OIDC Back-Channel Logout since no logout token was found"); + response.sendError(HttpServletResponse.SC_BAD_REQUEST); return; } OidcLogoutToken token; try { token = authenticate(logoutToken, registration); } + catch (AuthenticationServiceException ex) { + this.logger.debug("Failed to process OIDC Back-Channel Logout", ex); + response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, ex.getMessage()); + return; + } catch (AuthenticationException ex) { this.logger.debug("Failed to process OIDC Back-Channel Logout", ex); - String message = String.format(ERROR_MESSAGE, OAuth2ErrorCodes.INVALID_REQUEST, ex.getMessage()); - response.sendError(400, message); + OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.INVALID_REQUEST, ex.getMessage(), + "https://openid.net/specs/openid-connect-backchannel-1_0.html#Validation"); + response.setStatus(HttpServletResponse.SC_BAD_REQUEST); + this.errorHttpMessageConverter.write(error, null, new ServletServerHttpResponse(response)); return; } int sessionCount = 0; - int loggedOutCount = 0; - List messages = new ArrayList<>(); Iterable sessions = this.providerSessionRegistry.deregister(token); for (OidcSessionRegistration session : sessions) { - try { - if (this.logger.isTraceEnabled()) { - String message = "Logging out session #%d from result set for issuer [%s]"; - this.logger.trace(String.format(message, sessionCount, token.getIssuer())); - } - this.logoutHandler.logout(request, response, session.getLogoutAuthenticationToken()); - loggedOutCount++; - } - catch (Exception ex) { - this.providerSessionRegistry.register(session); - if (this.logger.isDebugEnabled()) { - String message = "Failed to invalidate session #%d from result set for issuer [%s]"; - this.logger.debug(String.format(message, sessionCount, token.getIssuer()), ex); - } - messages.add(ex.getMessage()); + if (this.logger.isTraceEnabled()) { + String message = "Logging out session #%d from result set for issuer [%s]"; + this.logger.trace(String.format(message, sessionCount, token.getIssuer())); } + this.logoutHandler.logout(request, response, session.getLogoutAuthenticationToken()); sessionCount++; } if (this.logger.isTraceEnabled()) { - this.logger.trace(String.format("Invalidated %d/%d linked sessions for issuer [%s]", loggedOutCount, - sessionCount, token.getIssuer())); - } - if (messages.isEmpty()) { - return; - } - if (messages.size() == sessionCount) { - this.logger.trace("Returning a 400 since all linked sessions for issuer [%s] failed termination"); - String message = String.format(ERROR_MESSAGE, "logout_failed", messages.iterator().next(), - token.getIssuer()); - response.sendError(400, message); - return; - } - if (messages.size() < sessionCount) { - this.logger.trace( - "Returning a 400 since not all linked sessions for issuer [%s] were successfully terminated"); - String message = String.format(ERROR_MESSAGE, "incomplete_logout", messages.iterator().next(), - token.getIssuer()); - response.sendError(400, message); + this.logger.trace(String.format("Invalidated all %d linked sessions for issuer [%s]", sessionCount, + token.getIssuer())); } } diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/authentication/logout/NimbusLogoutTokenDecoderFactoryTests.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/authentication/logout/OidcLogoutTokenValidatorTests.java similarity index 85% rename from oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/authentication/logout/NimbusLogoutTokenDecoderFactoryTests.java rename to oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/authentication/logout/OidcLogoutTokenValidatorTests.java index a3cc2e8aaf9..dcff8a917f2 100644 --- a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/authentication/logout/NimbusLogoutTokenDecoderFactoryTests.java +++ b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/authentication/logout/OidcLogoutTokenValidatorTests.java @@ -23,7 +23,7 @@ import static org.assertj.core.api.Assertions.assertThat; -public class NimbusLogoutTokenDecoderFactoryTests { +public class OidcLogoutTokenValidatorTests { // @formatter:off private ClientRegistration.Builder registration = TestClientRegistrations @@ -31,11 +31,10 @@ public class NimbusLogoutTokenDecoderFactoryTests { .scope("openid"); // @formatter:on - OidcLogoutTokenDecoderFactory factory = new OidcLogoutTokenDecoderFactory(); - @Test public void createDecoderWhenClientRegistrationValidThenReturnDecoder() { - assertThat(this.factory.createDecoder(this.registration.build())).isNotNull(); + OidcLogoutTokenValidator validator = new OidcLogoutTokenValidator(this.registration.build()); + assertThat(validator).isNotNull(); } } diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/authentication/session/TestOidcSessionRegistrations.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/authentication/session/TestOidcSessionRegistrations.java index f29601508a9..9257a0cfed0 100644 --- a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/authentication/session/TestOidcSessionRegistrations.java +++ b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/authentication/session/TestOidcSessionRegistrations.java @@ -16,11 +16,11 @@ package org.springframework.security.oauth2.client.oidc.authentication.session; +import java.util.Map; + import org.springframework.security.oauth2.core.oidc.user.OidcUser; import org.springframework.security.oauth2.core.oidc.user.TestOidcUsers; import org.springframework.security.web.authentication.logout.BackchannelLogoutAuthentication; -import org.springframework.security.web.csrf.CsrfToken; -import org.springframework.security.web.csrf.DefaultCsrfToken; public final class TestOidcSessionRegistrations { @@ -33,8 +33,8 @@ public static OidcSessionRegistration create(String sessionId) { } public static OidcSessionRegistration create(String sessionId, OidcUser user) { - CsrfToken token = new DefaultCsrfToken("header", "parameter", "token"); - return new OidcSessionRegistration(sessionId, user, new BackchannelLogoutAuthentication(sessionId, token)); + return new OidcSessionRegistration(sessionId, user, + new BackchannelLogoutAuthentication(sessionId, Map.of("_csrf", "token"))); } private TestOidcSessionRegistrations() { diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/web/authentication/logout/OidcBackChannelLogoutFilterTests.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/web/authentication/logout/OidcBackChannelLogoutFilterTests.java index fd170b33bd8..f81f80385ae 100644 --- a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/web/authentication/logout/OidcBackChannelLogoutFilterTests.java +++ b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/web/authentication/logout/OidcBackChannelLogoutFilterTests.java @@ -76,7 +76,7 @@ public void doFilterRequestDoesNotMatchContainLogoutTokenThenBadRequest() throws } @Test - public void doFilterWithNoMatchingClientThenDoesNotRun() throws Exception { + public void doFilterWithNoMatchingClientThenBadRequest() throws Exception { ClientRegistrationRepository clients = mock(ClientRegistrationRepository.class); AuthenticationManager factory = mock(AuthenticationManager.class); OidcBackChannelLogoutFilter filter = new OidcBackChannelLogoutFilter(clients, factory); @@ -87,8 +87,8 @@ public void doFilterWithNoMatchingClientThenDoesNotRun() throws Exception { FilterChain chain = mock(FilterChain.class); filter.doFilter(request, response, chain); verify(clients).findByRegistrationId("id"); - verifyNoInteractions(factory); - verify(chain).doFilter(request, response); + verifyNoInteractions(factory, chain); + assertThat(response.getStatus()).isEqualTo(400); } @Test @@ -143,7 +143,7 @@ public void doFilterWhenInvalidJwtThenBadRequest() throws Exception { filter.doFilter(request, response, chain); verifyNoInteractions(registry, logoutHandler, chain); assertThat(response.getStatus()).isEqualTo(400); - assertThat(response.getErrorMessage()).contains("bad"); + assertThat(response.getContentAsString()).contains("bad"); } } diff --git a/web/src/main/java/org/springframework/security/web/authentication/logout/BackchannelLogoutAuthentication.java b/web/src/main/java/org/springframework/security/web/authentication/logout/BackchannelLogoutAuthentication.java index d042c9b8c18..585c7ba7608 100644 --- a/web/src/main/java/org/springframework/security/web/authentication/logout/BackchannelLogoutAuthentication.java +++ b/web/src/main/java/org/springframework/security/web/authentication/logout/BackchannelLogoutAuthentication.java @@ -16,30 +16,25 @@ package org.springframework.security.web.authentication.logout; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + import org.springframework.security.authentication.AbstractAuthenticationToken; -import org.springframework.security.core.authority.AuthorityUtils; -import org.springframework.security.web.csrf.CsrfToken; -import org.springframework.security.web.csrf.DefaultCsrfToken; import org.springframework.util.Assert; public class BackchannelLogoutAuthentication extends AbstractAuthenticationToken { private final String sessionId; - private final CsrfToken csrfToken; + private final Map credentials; - public BackchannelLogoutAuthentication(String sessionId, CsrfToken csrfToken) { - super(AuthorityUtils.createAuthorityList("BACKCHANNEL_LOGOUT")); + public BackchannelLogoutAuthentication(String sessionId, Map credentials) { + super(Collections.emptyList()); Assert.notNull(sessionId, "sessionId cannot be null"); this.sessionId = sessionId; - this.csrfToken = extract(csrfToken); - } - - private static CsrfToken extract(CsrfToken token) { - if (token == null) { - return null; - } - return new DefaultCsrfToken(token.getHeaderName(), token.getParameterName(), token.getToken()); + this.credentials = new LinkedHashMap<>(credentials); + setAuthenticated(true); } @Override @@ -52,12 +47,8 @@ public String getSessionId() { } @Override - public CsrfToken getCredentials() { - return this.csrfToken; - } - - public CsrfToken getCsrfToken() { - return this.csrfToken; + public Map getCredentials() { + return this.credentials; } } diff --git a/web/src/main/java/org/springframework/security/web/authentication/logout/BackchannelLogoutHandler.java b/web/src/main/java/org/springframework/security/web/authentication/logout/BackchannelLogoutHandler.java index dff3349c163..640fe620784 100644 --- a/web/src/main/java/org/springframework/security/web/authentication/logout/BackchannelLogoutHandler.java +++ b/web/src/main/java/org/springframework/security/web/authentication/logout/BackchannelLogoutHandler.java @@ -16,6 +16,8 @@ package org.springframework.security.web.authentication.logout; +import java.util.Map; + import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.apache.commons.logging.Log; @@ -24,8 +26,8 @@ import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.security.core.Authentication; -import org.springframework.security.web.csrf.CsrfToken; import org.springframework.util.Assert; +import org.springframework.web.client.RestClientException; import org.springframework.web.client.RestOperations; import org.springframework.web.client.RestTemplate; import org.springframework.web.util.UriComponentsBuilder; @@ -51,17 +53,21 @@ public void logout(HttpServletRequest request, HttpServletResponse response, Aut } HttpHeaders headers = new HttpHeaders(); headers.add(HttpHeaders.COOKIE, this.clientSessionCookieName + "=" + token.getSessionId()); - CsrfToken csrfToken = token.getCsrfToken(); - if (csrfToken != null) { - headers.add(csrfToken.getHeaderName(), csrfToken.getToken()); + for (Map.Entry credential : token.getCredentials().entrySet()) { + headers.add(credential.getKey(), credential.getValue()); } String url = request.getRequestURL().toString(); String logout = UriComponentsBuilder.fromHttpUrl(url).replacePath(this.logoutEndpointName).build() .toUriString(); HttpEntity entity = new HttpEntity<>(null, headers); - this.rest.postForEntity(logout, entity, Object.class); - if (this.logger.isTraceEnabled()) { - this.logger.trace(String.format("Invalidated session [%s]", token.getSessionId())); + try { + this.rest.postForEntity(logout, entity, Object.class); + if (this.logger.isTraceEnabled()) { + this.logger.trace(String.format("Invalidated session", token.getSessionId())); + } + } + catch (RestClientException ex) { + this.logger.debug("Failed to invalidate session", ex); } } From d80b16960784cf0b4ab10dc6f2431cfcbaa7cc83 Mon Sep 17 00:00:00 2001 From: Josh Cummings Date: Thu, 6 Jul 2023 15:51:22 -0600 Subject: [PATCH 04/12] Responding to Feedback --- .../security/core/session/SessionInformation.java | 4 ---- 1 file changed, 4 deletions(-) diff --git a/core/src/main/java/org/springframework/security/core/session/SessionInformation.java b/core/src/main/java/org/springframework/security/core/session/SessionInformation.java index ce0babe88f2..54b05bbbb08 100644 --- a/core/src/main/java/org/springframework/security/core/session/SessionInformation.java +++ b/core/src/main/java/org/springframework/security/core/session/SessionInformation.java @@ -49,10 +49,6 @@ public class SessionInformation implements Serializable { private boolean expired = false; - public SessionInformation(Object principal, String sessionId) { - this(principal, sessionId, new Date()); - } - public SessionInformation(Object principal, String sessionId, Date lastRequest) { Assert.notNull(principal, "Principal required"); Assert.hasText(sessionId, "SessionId required"); From b59be570315f0fd20d193e8047b77b39a2513434 Mon Sep 17 00:00:00 2001 From: Josh Cummings Date: Tue, 11 Jul 2023 13:31:25 -0600 Subject: [PATCH 05/12] Responding to Feedback This tries to respond to the feedback that the session registry logic belongs in the authentication manager. This does have some nice benefits, for example it appears to remove a couple of classes in the PR. However, it has the potential downside of the authentication manager causing side-effects, which I'm not clear on whether our other authentication managers do. In effect, the sessions get deregistered from the session registry in order to be supplied to the BackchannelLogoutAuthentication instance. Does it make sense to tell a user who is customizing Logout Token authentication that they, as part of authenticating that token, need to supply the sessions attributed to that token? It also has the somewhat uncomfortable consequence of using the session registry in such a way as to make it tricky to expose it and in the DSL. That's because the session registry gets applied to multiple beans and so even if a person is supplying a custom, the coder still needs to know that any custom session registry set on the authentication manager still needs to be set on the DSL as well. I believe this could be a consequence of trying to overuse the authentication API. I'd advocate for not using AuthenticationManager to validate the logout token. Instead, we could introduce LogoutTokenValidator that validates and returns the token. The filter would take this token and invoke the session registry. Then, it would formulate the appropriate Authentication to perform backchannel logout. --- .../oauth2/client/OidcLogoutConfigurer.java | 70 ++++++++++--------- .../client/OidcLogoutConfigurerTests.java | 16 ++--- .../core/session/SessionInformation.java | 17 +++++ ...va => LogoutTokenAuthenticationToken.java} | 4 +- .../OidcBackChannelLogoutAuthentication.java | 56 --------------- ...ackChannelLogoutAuthenticationManager.java | 26 +++++-- .../session/InMemoryOidcSessionRegistry.java | 7 +- .../session/OidcSessionRegistration.java | 35 +++------- .../logout/OidcBackChannelLogoutFilter.java | 49 ++----------- .../session/TestOidcSessionRegistrations.java | 4 +- .../OidcBackChannelLogoutFilterTests.java | 18 ++--- .../BackchannelLogoutAuthentication.java | 33 +++++---- .../logout/BackchannelLogoutHandler.java | 14 +++- 13 files changed, 137 insertions(+), 212 deletions(-) rename oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/{OidcBackChannelLogoutAuthenticationToken.java => LogoutTokenAuthenticationToken.java} (87%) delete mode 100644 oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/OidcBackChannelLogoutAuthentication.java diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurer.java index c7e855e92bc..d49f22efd81 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurer.java @@ -48,7 +48,6 @@ import org.springframework.security.oauth2.client.oidc.web.authentication.logout.OidcBackChannelLogoutFilter; import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; import org.springframework.security.oauth2.core.oidc.user.OidcUser; -import org.springframework.security.web.authentication.logout.BackchannelLogoutAuthentication; import org.springframework.security.web.authentication.logout.BackchannelLogoutHandler; import org.springframework.security.web.authentication.logout.LogoutHandler; import org.springframework.security.web.authentication.session.SessionAuthenticationException; @@ -93,8 +92,7 @@ public final class OidcLogoutConfigurer> private BackChannelLogoutConfigurer backChannel; /** - * Sets the repository of client registrations. - * @param clientRegistrationRepository the repository of client registrations + * Configure OIDC Back-Channel Logout using the provided {@link Consumer} * @return the {@link OidcLogoutConfigurer} for further configuration */ public OidcLogoutConfigurer backChannel(Consumer backChannelLogoutConfigurer) { @@ -105,6 +103,7 @@ public OidcLogoutConfigurer backChannel(Consumer return this; } + @Deprecated(forRemoval = true, since = "6.2") public B and() { return getBuilder(); } @@ -142,15 +141,9 @@ public final class BackChannelLogoutConfigurer { private LogoutHandler logoutHandler = new BackchannelLogoutHandler(); - private AuthenticationManager authenticationManager = new OidcBackChannelLogoutAuthenticationManager(); + private AuthenticationManager authenticationManager; - private OidcSessionRegistry providerSessionRegistry = new InMemoryOidcSessionRegistry(); - - public BackChannelLogoutConfigurer clientLogoutHandler(LogoutHandler logoutHandler) { - Assert.notNull(logoutHandler, "logoutHandler cannot be null"); - this.logoutHandler = logoutHandler; - return this; - } + private OidcSessionRegistry sessionRegistry = new InMemoryOidcSessionRegistry(); public BackChannelLogoutConfigurer authenticationManager(AuthenticationManager authenticationManager) { Assert.notNull(authenticationManager, "authenticationManager cannot be null"); @@ -158,18 +151,29 @@ public BackChannelLogoutConfigurer authenticationManager(AuthenticationManager a return this; } - public BackChannelLogoutConfigurer oidcProviderSessionRegistry(OidcSessionRegistry providerSessionRegistry) { - Assert.notNull(providerSessionRegistry, "providerSessionRegistry cannot be null"); - this.providerSessionRegistry = providerSessionRegistry; + public BackChannelLogoutConfigurer sessionRegistry(OidcSessionRegistry sessionRegistry) { + Assert.notNull(sessionRegistry, "sessionRegistry cannot be null"); + this.sessionRegistry = sessionRegistry; + return this; + } + + public BackChannelLogoutConfigurer logoutHandler(LogoutHandler logoutHandler) { + Assert.notNull(logoutHandler, "logoutHandler cannot be null"); + this.logoutHandler = logoutHandler; return this; } private AuthenticationManager authenticationManager() { + if (this.authenticationManager == null) { + OidcBackChannelLogoutAuthenticationManager authenticationManager = new OidcBackChannelLogoutAuthenticationManager(); + authenticationManager.setSessionRegistry(sessionRegistry()); + this.authenticationManager = authenticationManager; + } return this.authenticationManager; } - private OidcSessionRegistry oidcProviderSessionRegistry() { - return this.providerSessionRegistry; + private OidcSessionRegistry sessionRegistry() { + return this.sessionRegistry; } private LogoutHandler logoutHandler() { @@ -178,7 +182,7 @@ private LogoutHandler logoutHandler() { private SessionAuthenticationStrategy sessionAuthenticationStrategy() { OidcProviderSessionAuthenticationStrategy strategy = new OidcProviderSessionAuthenticationStrategy(); - strategy.setProviderSessionRegistry(oidcProviderSessionRegistry()); + strategy.setSessionRegistry(sessionRegistry()); return strategy; } @@ -187,7 +191,6 @@ void configure(B http) { .getClientRegistrationRepository(http); OidcBackChannelLogoutFilter filter = new OidcBackChannelLogoutFilter(clientRegistrationRepository, authenticationManager()); - filter.setProviderSessionRegistry(oidcProviderSessionRegistry()); LogoutHandler expiredStrategy = logoutHandler(); filter.setLogoutHandler(expiredStrategy); http.addFilterBefore(filter, CsrfFilter.class); @@ -196,7 +199,7 @@ void configure(B http) { sessionConfigurer.addSessionAuthenticationStrategy(sessionAuthenticationStrategy()); } OidcClientSessionEventListener listener = new OidcClientSessionEventListener(); - listener.setProviderSessionRegistry(this.providerSessionRegistry); + listener.setSessionRegistry(this.sessionRegistry); registerDelegateApplicationListener(listener); } @@ -204,7 +207,7 @@ static final class OidcClientSessionEventListener implements ApplicationListener private final Log logger = LogFactory.getLog(OidcClientSessionEventListener.class); - private OidcSessionRegistry providerSessionRegistry = new InMemoryOidcSessionRegistry(); + private OidcSessionRegistry sessionRegistry = new InMemoryOidcSessionRegistry(); /** * {@inheritDoc} @@ -213,23 +216,23 @@ static final class OidcClientSessionEventListener implements ApplicationListener public void onApplicationEvent(AbstractSessionEvent event) { if (event instanceof SessionDestroyedEvent destroyed) { this.logger.debug("Received SessionDestroyedEvent"); - this.providerSessionRegistry.deregister(destroyed.getId()); + this.sessionRegistry.deregister(destroyed.getId()); return; } if (event instanceof SessionIdChangedEvent changed) { this.logger.debug("Received SessionIdChangedEvent"); - this.providerSessionRegistry.register(changed.getOldSessionId(), changed.getNewSessionId()); + this.sessionRegistry.register(changed.getOldSessionId(), changed.getNewSessionId()); } } /** * The registry where OIDC Provider sessions are linked to the Client session. * Defaults to in-memory storage. - * @param providerSessionRegistry the {@link OidcSessionRegistry} to use + * @param sessionRegistry the {@link OidcSessionRegistry} to use */ - void setProviderSessionRegistry(OidcSessionRegistry providerSessionRegistry) { - Assert.notNull(providerSessionRegistry, "providerSessionRegistry cannot be null"); - this.providerSessionRegistry = providerSessionRegistry; + void setSessionRegistry(OidcSessionRegistry sessionRegistry) { + Assert.notNull(sessionRegistry, "sessionRegistry cannot be null"); + this.sessionRegistry = sessionRegistry; } } @@ -238,7 +241,7 @@ static final class OidcProviderSessionAuthenticationStrategy implements SessionA private final Log logger = LogFactory.getLog(getClass()); - private OidcSessionRegistry providerSessionRegistry = new InMemoryOidcSessionRegistry(); + private OidcSessionRegistry sessionRegistry = new InMemoryOidcSessionRegistry(); /** * {@inheritDoc} @@ -258,22 +261,21 @@ public void onAuthentication(Authentication authentication, HttpServletRequest r String sessionId = session.getId(); CsrfToken csrfToken = (CsrfToken) request.getAttribute(CsrfToken.class.getName()); Map credentials = (csrfToken != null) ? Map.of(csrfToken.getHeaderName(), csrfToken.getToken()) : Collections.emptyMap(); - BackchannelLogoutAuthentication logoutAuthentication = new BackchannelLogoutAuthentication(sessionId, credentials); - OidcSessionRegistration registration = new OidcSessionRegistration(sessionId, user, logoutAuthentication); + OidcSessionRegistration registration = new OidcSessionRegistration(sessionId, credentials, user); if (this.logger.isTraceEnabled()) { this.logger.trace(String.format("Linking a provider [%s] session to this client's session", user.getIssuer())); } - this.providerSessionRegistry.register(registration); + this.sessionRegistry.register(registration); } /** * The registration for linking OIDC Provider Session information to the * Client's session. Defaults to in-memory. - * @param providerSessionRegistry the {@link OidcSessionRegistry} to use + * @param sessionRegistry the {@link OidcSessionRegistry} to use */ - void setProviderSessionRegistry(OidcSessionRegistry providerSessionRegistry) { - Assert.notNull(providerSessionRegistry, "providerSessionRegistry cannot be null"); - this.providerSessionRegistry = providerSessionRegistry; + void setSessionRegistry(OidcSessionRegistry sessionRegistry) { + Assert.notNull(sessionRegistry, "sessionRegistry cannot be null"); + this.sessionRegistry = sessionRegistry; } } diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurerTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurerTests.java index 39bab192ab1..1282bcb9323 100644 --- a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurerTests.java +++ b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurerTests.java @@ -62,7 +62,7 @@ import org.springframework.security.core.userdetails.User; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.oauth2.client.oidc.authentication.logout.LogoutTokenClaimNames; -import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcBackChannelLogoutAuthentication; +import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcBackChannelLogoutAuthenticationManager; import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcLogoutToken; import org.springframework.security.oauth2.client.oidc.authentication.logout.TestOidcLogoutTokens; import org.springframework.security.oauth2.client.oidc.authentication.session.OidcSessionRegistration; @@ -81,6 +81,7 @@ import org.springframework.security.oauth2.jwt.NimbusJwtEncoder; import org.springframework.security.provisioning.InMemoryUserDetailsManager; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.logout.BackchannelLogoutAuthentication; import org.springframework.security.web.authentication.logout.LogoutHandler; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MvcResult; @@ -168,15 +169,13 @@ void logoutWhenCustomComponentsThenUses() throws Exception { String registrationId = this.registration.getRegistrationId(); AuthenticationManager authenticationManager = this.spring.getContext().getBean(AuthenticationManager.class); OidcLogoutToken logoutToken = TestOidcLogoutTokens.withSessionId("issuer", "provider").build(); + Set details = Set.of(TestOidcSessionRegistrations.create()); given(authenticationManager.authenticate(any())) - .willReturn(new OidcBackChannelLogoutAuthentication(logoutToken, this.registration)); + .willReturn(new BackchannelLogoutAuthentication(logoutToken, logoutToken, details)); LogoutHandler logoutHandler = this.spring.getContext().getBean(LogoutHandler.class); - OidcSessionRegistry registry = this.spring.getContext().getBean(OidcSessionRegistry.class); - Set details = Set.of(TestOidcSessionRegistrations.create()); - given(registry.deregister(any(OidcLogoutToken.class))).willReturn(details); this.mvc.perform(post("/logout/connect/back-channel/" + registrationId).param("logout_token", "token")) .andExpect(status().isOk()); - verify(registry).deregister(any(OidcLogoutToken.class)); + // verify(registry).deregister(any(OidcLogoutToken.class)); verify(authenticationManager).authenticate(any()); verify(logoutHandler).logout(any(), any(), any()); } @@ -240,15 +239,16 @@ static class WithCustomComponentsConfig { @Bean @Order(1) SecurityFilterChain filters(HttpSecurity http) throws Exception { + OidcBackChannelLogoutAuthenticationManager authenticationManager = new OidcBackChannelLogoutAuthenticationManager(); + authenticationManager.setSessionRegistry(this.registry); // @formatter:off http .authorizeHttpRequests((authorize) -> authorize.anyRequest().authenticated()) .oauth2Login(Customizer.withDefaults()) .oidcLogout((oauth2) -> oauth2. backChannel((backchannel) -> backchannel - .clientLogoutHandler(this.logoutHandler) + .logoutHandler(this.logoutHandler) .authenticationManager(this.authenticationManager) - .oidcProviderSessionRegistry(this.registry) ) ); // @formatter:on diff --git a/core/src/main/java/org/springframework/security/core/session/SessionInformation.java b/core/src/main/java/org/springframework/security/core/session/SessionInformation.java index 54b05bbbb08..db53d4bfe1b 100644 --- a/core/src/main/java/org/springframework/security/core/session/SessionInformation.java +++ b/core/src/main/java/org/springframework/security/core/session/SessionInformation.java @@ -18,6 +18,8 @@ import java.io.Serializable; import java.util.Date; +import java.util.LinkedHashMap; +import java.util.Map; import org.springframework.security.core.SpringSecurityCoreVersion; import org.springframework.util.Assert; @@ -49,6 +51,8 @@ public class SessionInformation implements Serializable { private boolean expired = false; + private Map headers = new LinkedHashMap<>(); + public SessionInformation(Object principal, String sessionId, Date lastRequest) { Assert.notNull(principal, "Principal required"); Assert.hasText(sessionId, "SessionId required"); @@ -58,6 +62,15 @@ public SessionInformation(Object principal, String sessionId, Date lastRequest) this.lastRequest = lastRequest; } + public SessionInformation(Object principal, String sessionId, Map headers) { + Assert.notNull(principal, "Principal required"); + Assert.hasText(sessionId, "SessionId required"); + this.principal = principal; + this.sessionId = sessionId; + this.lastRequest = new Date(); + this.headers = headers; + } + public void expireNow() { this.expired = true; } @@ -74,6 +87,10 @@ public String getSessionId() { return this.sessionId; } + public Map getHeaders() { + return this.headers; + } + public boolean isExpired() { return this.expired; } diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/OidcBackChannelLogoutAuthenticationToken.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/LogoutTokenAuthenticationToken.java similarity index 87% rename from oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/OidcBackChannelLogoutAuthenticationToken.java rename to oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/LogoutTokenAuthenticationToken.java index 71ee33bd8fb..c0e2690d79a 100644 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/OidcBackChannelLogoutAuthenticationToken.java +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/LogoutTokenAuthenticationToken.java @@ -20,13 +20,13 @@ import org.springframework.security.core.authority.AuthorityUtils; import org.springframework.security.oauth2.client.registration.ClientRegistration; -public class OidcBackChannelLogoutAuthenticationToken extends AbstractAuthenticationToken { +public class LogoutTokenAuthenticationToken extends AbstractAuthenticationToken { private final String logoutToken; private final ClientRegistration clientRegistration; - public OidcBackChannelLogoutAuthenticationToken(String logoutToken, ClientRegistration clientRegistration) { + public LogoutTokenAuthenticationToken(String logoutToken, ClientRegistration clientRegistration) { super(AuthorityUtils.NO_AUTHORITIES); this.logoutToken = logoutToken; this.clientRegistration = clientRegistration; diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/OidcBackChannelLogoutAuthentication.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/OidcBackChannelLogoutAuthentication.java deleted file mode 100644 index 6106026b6cf..00000000000 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/OidcBackChannelLogoutAuthentication.java +++ /dev/null @@ -1,56 +0,0 @@ -/* - * Copyright 2002-2023 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.oidc.authentication.logout; - -import java.util.Collections; - -import org.springframework.security.authentication.AbstractAuthenticationToken; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.oauth2.client.registration.ClientRegistration; - -public class OidcBackChannelLogoutAuthentication extends AbstractAuthenticationToken { - - private final OidcLogoutToken logoutToken; - - private final ClientRegistration clientRegistration; - - public OidcBackChannelLogoutAuthentication(OidcLogoutToken logoutToken, ClientRegistration clientRegistration) { - super(Collections.singleton(new SimpleGrantedAuthority("BACKCHANNEL_LOGOUT"))); - this.logoutToken = logoutToken; - this.clientRegistration = clientRegistration; - setAuthenticated(true); - } - - @Override - public OidcLogoutToken getPrincipal() { - return this.logoutToken; - } - - @Override - public OidcLogoutToken getCredentials() { - return this.logoutToken; - } - - public OidcLogoutToken getLogoutToken() { - return this.logoutToken; - } - - public ClientRegistration getClientRegistration() { - return this.clientRegistration; - } - -} diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/OidcBackChannelLogoutAuthenticationManager.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/OidcBackChannelLogoutAuthenticationManager.java index 9c8945c6bde..dd05f34ef91 100644 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/OidcBackChannelLogoutAuthenticationManager.java +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/OidcBackChannelLogoutAuthenticationManager.java @@ -21,33 +21,34 @@ import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.session.SessionInformation; import org.springframework.security.oauth2.client.oidc.authentication.OidcIdTokenDecoderFactory; +import org.springframework.security.oauth2.client.oidc.authentication.session.InMemoryOidcSessionRegistry; +import org.springframework.security.oauth2.client.oidc.authentication.session.OidcSessionRegistry; import org.springframework.security.oauth2.client.registration.ClientRegistration; import org.springframework.security.oauth2.jwt.BadJwtException; import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.jwt.JwtDecoder; import org.springframework.security.oauth2.jwt.JwtDecoderFactory; import org.springframework.security.oauth2.jwt.JwtException; +import org.springframework.security.web.authentication.logout.BackchannelLogoutAuthentication; import org.springframework.util.Assert; public final class OidcBackChannelLogoutAuthenticationManager implements AuthenticationManager { private JwtDecoderFactory logoutTokenDecoderFactory; + private OidcSessionRegistry sessionRegistry = new InMemoryOidcSessionRegistry(); + public OidcBackChannelLogoutAuthenticationManager() { OidcIdTokenDecoderFactory logoutTokenDecoderFactory = new OidcIdTokenDecoderFactory(); logoutTokenDecoderFactory.setJwtValidatorFactory(new DefaultOidcLogoutTokenValidatorFactory()); this.logoutTokenDecoderFactory = logoutTokenDecoderFactory; } - public void setLogoutTokenDecoderFactory(JwtDecoderFactory logoutTokenDecoderFactory) { - Assert.notNull(logoutTokenDecoderFactory, "logoutTokenDecoderFactory cannot be null"); - this.logoutTokenDecoderFactory = logoutTokenDecoderFactory; - } - @Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { - if (!(authentication instanceof OidcBackChannelLogoutAuthenticationToken token)) { + if (!(authentication instanceof LogoutTokenAuthenticationToken token)) { return null; } String logoutToken = token.getLogoutToken(); @@ -55,7 +56,8 @@ public Authentication authenticate(Authentication authentication) throws Authent Jwt jwt = decode(registration, logoutToken); OidcLogoutToken oidcLogoutToken = OidcLogoutToken.withTokenValue(logoutToken) .claims((claims) -> claims.putAll(jwt.getClaims())).build(); - return new OidcBackChannelLogoutAuthentication(oidcLogoutToken, registration); + Iterable sessions = this.sessionRegistry.deregister(oidcLogoutToken); + return new BackchannelLogoutAuthentication(oidcLogoutToken, oidcLogoutToken, sessions); } private Jwt decode(ClientRegistration registration, String token) { @@ -71,4 +73,14 @@ private Jwt decode(ClientRegistration registration, String token) { } } + public void setLogoutTokenDecoderFactory(JwtDecoderFactory logoutTokenDecoderFactory) { + Assert.notNull(logoutTokenDecoderFactory, "logoutTokenDecoderFactory cannot be null"); + this.logoutTokenDecoderFactory = logoutTokenDecoderFactory; + } + + public void setSessionRegistry(OidcSessionRegistry sessionRegistry) { + Assert.notNull(sessionRegistry, "sessionRegistry cannot be null"); + this.sessionRegistry = sessionRegistry; + } + } diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/session/InMemoryOidcSessionRegistry.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/session/InMemoryOidcSessionRegistry.java index fb854f2541f..f252c966f6e 100644 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/session/InMemoryOidcSessionRegistry.java +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/session/InMemoryOidcSessionRegistry.java @@ -32,7 +32,7 @@ * An in-memory implementation of {@link OidcSessionRegistry} * * @author Josh Cummings - * @since 6.1 + * @since 6.2 */ public final class InMemoryOidcSessionRegistry implements OidcSessionRegistry { @@ -42,7 +42,7 @@ public final class InMemoryOidcSessionRegistry implements OidcSessionRegistry { @Override public void register(OidcSessionRegistration registration) { - this.sessions.put(registration.getClientSessionId(), registration); + this.sessions.put(registration.getSessionId(), registration); } @Override @@ -52,8 +52,7 @@ public void register(String oldClientSessionId, String newClientSessionId) { this.logger.debug("Failed to register new session id since old session id was not found in registry"); return; } - register(new OidcSessionRegistration(newClientSessionId, old.getPrincipal(), - old.getLogoutAuthenticationToken())); + register(new OidcSessionRegistration(newClientSessionId, old.getHeaders(), old.getPrincipal())); } @Override diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/session/OidcSessionRegistration.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/session/OidcSessionRegistration.java index ddbf482c057..8d6e5747418 100644 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/session/OidcSessionRegistration.java +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/session/OidcSessionRegistration.java @@ -16,7 +16,9 @@ package org.springframework.security.oauth2.client.oidc.authentication.session; -import org.springframework.security.core.Authentication; +import java.util.Map; + +import org.springframework.security.core.session.SessionInformation; import org.springframework.security.oauth2.core.oidc.user.OidcUser; /** @@ -26,37 +28,22 @@ * @author Josh Cummings * @since 6.2 */ -public class OidcSessionRegistration { - - private final String clientSessionId; - - private final OidcUser user; - - private final Authentication logoutAuthenticationToken; +public class OidcSessionRegistration extends SessionInformation { /** * Construct an {@link OidcSessionRegistration} - * @param clientSessionId the Client's session id - * @param logoutAuthenticationToken the Client's CSRF logoutAuthenticationToken for - * this session + * @param sessionId the Client's session id + * @param additionalHeaders any additional headers needed to authenticate session + * ownership * @param user the OIDC Provider's session and end user */ - public OidcSessionRegistration(String clientSessionId, OidcUser user, Authentication logoutAuthenticationToken) { - this.clientSessionId = clientSessionId; - this.user = user; - this.logoutAuthenticationToken = logoutAuthenticationToken; - } - - public String getClientSessionId() { - return this.clientSessionId; - } - - public Authentication getLogoutAuthenticationToken() { - return this.logoutAuthenticationToken; + public OidcSessionRegistration(String sessionId, Map additionalHeaders, OidcUser user) { + super(user, sessionId, additionalHeaders); } + @Override public OidcUser getPrincipal() { - return this.user; + return (OidcUser) super.getPrincipal(); } } diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/web/authentication/logout/OidcBackChannelLogoutFilter.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/web/authentication/logout/OidcBackChannelLogoutFilter.java index 95710205d0c..324e0229eff 100644 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/web/authentication/logout/OidcBackChannelLogoutFilter.java +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/web/authentication/logout/OidcBackChannelLogoutFilter.java @@ -28,12 +28,9 @@ import org.springframework.http.server.ServletServerHttpResponse; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.AuthenticationServiceException; +import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; -import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcBackChannelLogoutAuthenticationToken; -import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcLogoutToken; -import org.springframework.security.oauth2.client.oidc.authentication.session.InMemoryOidcSessionRegistry; -import org.springframework.security.oauth2.client.oidc.authentication.session.OidcSessionRegistration; -import org.springframework.security.oauth2.client.oidc.authentication.session.OidcSessionRegistry; +import org.springframework.security.oauth2.client.oidc.authentication.logout.LogoutTokenAuthenticationToken; import org.springframework.security.oauth2.client.registration.ClientRegistration; import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; import org.springframework.security.oauth2.core.OAuth2Error; @@ -50,7 +47,7 @@ * A filter for the Client-side OIDC Back-Channel Logout endpoint * * @author Josh Cummings - * @since 6.1 + * @since 6.2 * @see OIDC Back-Channel Logout * Spec @@ -69,8 +66,6 @@ public class OidcBackChannelLogoutFilter extends OncePerRequestFilter { private RequestMatcher requestMatcher = new AntPathRequestMatcher(DEFAULT_LOGOUT_URI, "POST"); - private OidcSessionRegistry providerSessionRegistry = new InMemoryOidcSessionRegistry(); - private LogoutHandler logoutHandler = new BackchannelLogoutHandler(); /** @@ -112,14 +107,14 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response.sendError(HttpServletResponse.SC_BAD_REQUEST); return; } - OidcLogoutToken token; + LogoutTokenAuthenticationToken token = new LogoutTokenAuthenticationToken(logoutToken, registration); try { - token = authenticate(logoutToken, registration); + Authentication authentication = this.authenticationManager.authenticate(token); + this.logoutHandler.logout(request, response, authentication); } catch (AuthenticationServiceException ex) { this.logger.debug("Failed to process OIDC Back-Channel Logout", ex); response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, ex.getMessage()); - return; } catch (AuthenticationException ex) { this.logger.debug("Failed to process OIDC Back-Channel Logout", ex); @@ -127,30 +122,9 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse "https://openid.net/specs/openid-connect-backchannel-1_0.html#Validation"); response.setStatus(HttpServletResponse.SC_BAD_REQUEST); this.errorHttpMessageConverter.write(error, null, new ServletServerHttpResponse(response)); - return; - } - int sessionCount = 0; - Iterable sessions = this.providerSessionRegistry.deregister(token); - for (OidcSessionRegistration session : sessions) { - if (this.logger.isTraceEnabled()) { - String message = "Logging out session #%d from result set for issuer [%s]"; - this.logger.trace(String.format(message, sessionCount, token.getIssuer())); - } - this.logoutHandler.logout(request, response, session.getLogoutAuthenticationToken()); - sessionCount++; - } - if (this.logger.isTraceEnabled()) { - this.logger.trace(String.format("Invalidated all %d linked sessions for issuer [%s]", sessionCount, - token.getIssuer())); } } - private OidcLogoutToken authenticate(String logoutToken, ClientRegistration registration) { - OidcBackChannelLogoutAuthenticationToken token = new OidcBackChannelLogoutAuthenticationToken(logoutToken, - registration); - return (OidcLogoutToken) this.authenticationManager.authenticate(token).getPrincipal(); - } - /** * The logout endpoint. Defaults to * {@code /logout/connect/back-channel/{registrationId}}. @@ -162,16 +136,7 @@ public void setRequestMatcher(RequestMatcher requestMatcher) { } /** - * The registry for linking Client sessions to OIDC Provider sessions and End Users - * @param providerSessionRegistry the {@link OidcSessionRegistry} to use - */ - public void setProviderSessionRegistry(OidcSessionRegistry providerSessionRegistry) { - Assert.notNull(providerSessionRegistry, "providerSessionRegistry cannot be null"); - this.providerSessionRegistry = providerSessionRegistry; - } - - /** - * The strategy for expiring each Client session indicated by the logout request. + * The strategy for expiring all Client sessions indicated by the logout request. * Defaults to {@link BackchannelLogoutHandler}. * @param logoutHandler the {@link LogoutHandler} to use */ diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/authentication/session/TestOidcSessionRegistrations.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/authentication/session/TestOidcSessionRegistrations.java index 9257a0cfed0..9d39d59b6ea 100644 --- a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/authentication/session/TestOidcSessionRegistrations.java +++ b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/authentication/session/TestOidcSessionRegistrations.java @@ -20,7 +20,6 @@ import org.springframework.security.oauth2.core.oidc.user.OidcUser; import org.springframework.security.oauth2.core.oidc.user.TestOidcUsers; -import org.springframework.security.web.authentication.logout.BackchannelLogoutAuthentication; public final class TestOidcSessionRegistrations { @@ -33,8 +32,7 @@ public static OidcSessionRegistration create(String sessionId) { } public static OidcSessionRegistration create(String sessionId, OidcUser user) { - return new OidcSessionRegistration(sessionId, user, - new BackchannelLogoutAuthentication(sessionId, Map.of("_csrf", "token"))); + return new OidcSessionRegistration(sessionId, Map.of("_csrf", "token"), user); } private TestOidcSessionRegistrations() { diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/web/authentication/logout/OidcBackChannelLogoutFilterTests.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/web/authentication/logout/OidcBackChannelLogoutFilterTests.java index f81f80385ae..0da06be9f2f 100644 --- a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/web/authentication/logout/OidcBackChannelLogoutFilterTests.java +++ b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/web/authentication/logout/OidcBackChannelLogoutFilterTests.java @@ -25,22 +25,20 @@ import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.BadCredentialsException; -import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcBackChannelLogoutAuthentication; import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcLogoutToken; import org.springframework.security.oauth2.client.oidc.authentication.logout.TestOidcLogoutTokens; import org.springframework.security.oauth2.client.oidc.authentication.session.OidcSessionRegistration; -import org.springframework.security.oauth2.client.oidc.authentication.session.OidcSessionRegistry; import org.springframework.security.oauth2.client.oidc.authentication.session.TestOidcSessionRegistrations; import org.springframework.security.oauth2.client.registration.ClientRegistration; import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; import org.springframework.security.oauth2.client.registration.TestClientRegistrations; +import org.springframework.security.web.authentication.logout.BackchannelLogoutAuthentication; import org.springframework.security.web.authentication.logout.LogoutHandler; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; @@ -98,14 +96,11 @@ public void doFilterWithSessionMatchingLogoutTokenThenInvalidates() throws Excep given(clients.findByRegistrationId(any())).willReturn(registration); AuthenticationManager factory = mock(AuthenticationManager.class); OidcLogoutToken token = TestOidcLogoutTokens.withSessionId("issuer", "provider").build(); - given(factory.authenticate(any())).willReturn(new OidcBackChannelLogoutAuthentication(token, registration)); - OidcSessionRegistry registry = mock(OidcSessionRegistry.class); Iterable infos = Set.of(TestOidcSessionRegistrations.create("clientOne"), TestOidcSessionRegistrations.create("clientTwo")); - given(registry.deregister(any(OidcLogoutToken.class))).willReturn(infos); + given(factory.authenticate(any())).willReturn(new BackchannelLogoutAuthentication(token, token, infos)); LogoutHandler logoutHandler = mock(LogoutHandler.class); OidcBackChannelLogoutFilter filter = new OidcBackChannelLogoutFilter(clients, factory); - filter.setProviderSessionRegistry(registry); filter.setLogoutHandler(logoutHandler); MockHttpServletRequest request = new MockHttpServletRequest("POST", "/oauth2/" + registration.getRegistrationId() + "/logout"); @@ -114,7 +109,7 @@ public void doFilterWithSessionMatchingLogoutTokenThenInvalidates() throws Excep MockHttpServletResponse response = new MockHttpServletResponse(); FilterChain chain = mock(FilterChain.class); filter.doFilter(request, response, chain); - verify(logoutHandler, times(2)).logout(any(), any(), any()); + verify(logoutHandler).logout(any(), any(), any()); verifyNoInteractions(chain); assertThat(response.getStatus()).isEqualTo(200); } @@ -126,13 +121,8 @@ public void doFilterWhenInvalidJwtThenBadRequest() throws Exception { given(clients.findByRegistrationId(any())).willReturn(registration); AuthenticationManager factory = mock(AuthenticationManager.class); given(factory.authenticate(any())).willThrow(new BadCredentialsException("bad")); - OidcSessionRegistry registry = mock(OidcSessionRegistry.class); - Iterable infos = Set.of(TestOidcSessionRegistrations.create("clientOne"), - TestOidcSessionRegistrations.create("clientTwo")); - given(registry.deregister(any(OidcLogoutToken.class))).willReturn(infos); LogoutHandler logoutHandler = mock(LogoutHandler.class); OidcBackChannelLogoutFilter filter = new OidcBackChannelLogoutFilter(clients, factory); - filter.setProviderSessionRegistry(registry); filter.setLogoutHandler(logoutHandler); MockHttpServletRequest request = new MockHttpServletRequest("POST", "/oauth2/" + registration.getRegistrationId() + "/logout"); @@ -141,7 +131,7 @@ public void doFilterWhenInvalidJwtThenBadRequest() throws Exception { MockHttpServletResponse response = new MockHttpServletResponse(); FilterChain chain = mock(FilterChain.class); filter.doFilter(request, response, chain); - verifyNoInteractions(registry, logoutHandler, chain); + verifyNoInteractions(logoutHandler, chain); assertThat(response.getStatus()).isEqualTo(400); assertThat(response.getContentAsString()).contains("bad"); } diff --git a/web/src/main/java/org/springframework/security/web/authentication/logout/BackchannelLogoutAuthentication.java b/web/src/main/java/org/springframework/security/web/authentication/logout/BackchannelLogoutAuthentication.java index 585c7ba7608..c61488bddb7 100644 --- a/web/src/main/java/org/springframework/security/web/authentication/logout/BackchannelLogoutAuthentication.java +++ b/web/src/main/java/org/springframework/security/web/authentication/logout/BackchannelLogoutAuthentication.java @@ -17,38 +17,41 @@ package org.springframework.security.web.authentication.logout; import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.Map; import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.session.SessionInformation; import org.springframework.util.Assert; public class BackchannelLogoutAuthentication extends AbstractAuthenticationToken { - private final String sessionId; + private final Object principal; - private final Map credentials; + private final Object credentials; - public BackchannelLogoutAuthentication(String sessionId, Map credentials) { + private final Iterable sessions; + + public BackchannelLogoutAuthentication(Object principal, Object credentials, + Iterable sessions) { super(Collections.emptyList()); - Assert.notNull(sessionId, "sessionId cannot be null"); - this.sessionId = sessionId; - this.credentials = new LinkedHashMap<>(credentials); + Assert.notNull(sessions, "sessions cannot be null"); + this.sessions = sessions; + this.principal = principal; + this.credentials = credentials; setAuthenticated(true); } @Override - public String getPrincipal() { - return this.sessionId; - } - - public String getSessionId() { - return this.sessionId; + public Object getPrincipal() { + return this.principal; } @Override - public Map getCredentials() { + public Object getCredentials() { return this.credentials; } + public Iterable getSessions() { + return this.sessions; + } + } diff --git a/web/src/main/java/org/springframework/security/web/authentication/logout/BackchannelLogoutHandler.java b/web/src/main/java/org/springframework/security/web/authentication/logout/BackchannelLogoutHandler.java index 640fe620784..2e16a339e67 100644 --- a/web/src/main/java/org/springframework/security/web/authentication/logout/BackchannelLogoutHandler.java +++ b/web/src/main/java/org/springframework/security/web/authentication/logout/BackchannelLogoutHandler.java @@ -26,6 +26,7 @@ import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.security.core.Authentication; +import org.springframework.security.core.session.SessionInformation; import org.springframework.util.Assert; import org.springframework.web.client.RestClientException; import org.springframework.web.client.RestOperations; @@ -51,9 +52,16 @@ public void logout(HttpServletRequest request, HttpServletResponse response, Aut } return; } + Iterable sessions = token.getSessions(); + for (SessionInformation session : sessions) { + eachLogout(request, session); + } + } + + private void eachLogout(HttpServletRequest request, SessionInformation session) { HttpHeaders headers = new HttpHeaders(); - headers.add(HttpHeaders.COOKIE, this.clientSessionCookieName + "=" + token.getSessionId()); - for (Map.Entry credential : token.getCredentials().entrySet()) { + headers.add(HttpHeaders.COOKIE, this.clientSessionCookieName + "=" + session.getSessionId()); + for (Map.Entry credential : session.getHeaders().entrySet()) { headers.add(credential.getKey(), credential.getValue()); } String url = request.getRequestURL().toString(); @@ -63,7 +71,7 @@ public void logout(HttpServletRequest request, HttpServletResponse response, Aut try { this.rest.postForEntity(logout, entity, Object.class); if (this.logger.isTraceEnabled()) { - this.logger.trace(String.format("Invalidated session", token.getSessionId())); + this.logger.trace("Invalidated session"); } } catch (RestClientException ex) { From 05f6407ad95dbb57eeef15c073a81f9ff10b29ae Mon Sep 17 00:00:00 2001 From: Josh Cummings Date: Wed, 12 Jul 2023 13:55:24 -0600 Subject: [PATCH 06/12] Only invalidate sessions tied to the audience in the logout token --- .../oauth2/client/OidcLogoutConfigurer.java | 28 ++++++++++++++----- .../session/InMemoryOidcSessionRegistry.java | 20 +++++++++---- .../session/OidcSessionRegistration.java | 10 ++++++- .../logout/TestOidcLogoutTokens.java | 8 +++--- .../session/TestOidcSessionRegistrations.java | 2 +- 5 files changed, 49 insertions(+), 19 deletions(-) diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurer.java index d49f22efd81..ed6d9dbfa83 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurer.java @@ -41,11 +41,13 @@ import org.springframework.security.core.session.AbstractSessionEvent; import org.springframework.security.core.session.SessionDestroyedEvent; import org.springframework.security.core.session.SessionIdChangedEvent; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcBackChannelLogoutAuthenticationManager; import org.springframework.security.oauth2.client.oidc.authentication.session.InMemoryOidcSessionRegistry; import org.springframework.security.oauth2.client.oidc.authentication.session.OidcSessionRegistration; import org.springframework.security.oauth2.client.oidc.authentication.session.OidcSessionRegistry; import org.springframework.security.oauth2.client.oidc.web.authentication.logout.OidcBackChannelLogoutFilter; +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.BackchannelLogoutHandler; @@ -180,8 +182,10 @@ private LogoutHandler logoutHandler() { return this.logoutHandler; } - private SessionAuthenticationStrategy sessionAuthenticationStrategy() { - OidcProviderSessionAuthenticationStrategy strategy = new OidcProviderSessionAuthenticationStrategy(); + private SessionAuthenticationStrategy sessionAuthenticationStrategy( + ClientRegistrationRepository clientRegistrationRepository) { + OidcSessionRegistryAuthenticationStrategy strategy = new OidcSessionRegistryAuthenticationStrategy( + clientRegistrationRepository); strategy.setSessionRegistry(sessionRegistry()); return strategy; } @@ -196,7 +200,8 @@ void configure(B http) { http.addFilterBefore(filter, CsrfFilter.class); SessionManagementConfigurer sessionConfigurer = http.getConfigurer(SessionManagementConfigurer.class); if (sessionConfigurer != null) { - sessionConfigurer.addSessionAuthenticationStrategy(sessionAuthenticationStrategy()); + sessionConfigurer + .addSessionAuthenticationStrategy(sessionAuthenticationStrategy(clientRegistrationRepository)); } OidcClientSessionEventListener listener = new OidcClientSessionEventListener(); listener.setSessionRegistry(this.sessionRegistry); @@ -237,12 +242,18 @@ void setSessionRegistry(OidcSessionRegistry sessionRegistry) { } - static final class OidcProviderSessionAuthenticationStrategy implements SessionAuthenticationStrategy { + static final class OidcSessionRegistryAuthenticationStrategy implements SessionAuthenticationStrategy { private final Log logger = LogFactory.getLog(getClass()); + private final ClientRegistrationRepository clientRegistrationRepository; + private OidcSessionRegistry sessionRegistry = new InMemoryOidcSessionRegistry(); + OidcSessionRegistryAuthenticationStrategy(ClientRegistrationRepository clientRegistrationRepository) { + this.clientRegistrationRepository = clientRegistrationRepository; + } + /** * {@inheritDoc} */ @@ -252,16 +263,19 @@ public void onAuthentication(Authentication authentication, HttpServletRequest r if (session == null) { return; } - if (authentication == null) { + if (!(authentication instanceof OAuth2AuthenticationToken token)) { return; } if (!(authentication.getPrincipal() instanceof OidcUser user)) { return; } + String registrationId = token.getAuthorizedClientRegistrationId(); + ClientRegistration clientRegistration = this.clientRegistrationRepository.findByRegistrationId(registrationId); + String clientId = clientRegistration.getClientId(); String sessionId = session.getId(); CsrfToken csrfToken = (CsrfToken) request.getAttribute(CsrfToken.class.getName()); - Map credentials = (csrfToken != null) ? Map.of(csrfToken.getHeaderName(), csrfToken.getToken()) : Collections.emptyMap(); - OidcSessionRegistration registration = new OidcSessionRegistration(sessionId, credentials, user); + Map headers = (csrfToken != null) ? Map.of(csrfToken.getHeaderName(), csrfToken.getToken()) : Collections.emptyMap(); + OidcSessionRegistration registration = new OidcSessionRegistration(clientId, sessionId, headers, user); if (this.logger.isTraceEnabled()) { this.logger.trace(String.format("Linking a provider [%s] session to this client's session", user.getIssuer())); } diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/session/InMemoryOidcSessionRegistry.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/session/InMemoryOidcSessionRegistry.java index f252c966f6e..1215bc5b709 100644 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/session/InMemoryOidcSessionRegistry.java +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/session/InMemoryOidcSessionRegistry.java @@ -17,6 +17,7 @@ package org.springframework.security.oauth2.client.oidc.authentication.session; import java.util.HashSet; +import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; @@ -52,7 +53,8 @@ public void register(String oldClientSessionId, String newClientSessionId) { this.logger.debug("Failed to register new session id since old session id was not found in registry"); return; } - register(new OidcSessionRegistration(newClientSessionId, old.getHeaders(), old.getPrincipal())); + register(new OidcSessionRegistration(old.getClientId(), newClientSessionId, old.getHeaders(), + old.getPrincipal())); } @Override @@ -66,11 +68,12 @@ public OidcSessionRegistration deregister(String clientSessionId) { @Override public Iterable deregister(OidcLogoutToken token) { + List audience = token.getAudience(); String issuer = token.getIssuer().toString(); String subject = token.getSubject(); String providerSessionId = token.getSessionId(); Predicate matcher = (providerSessionId != null) - ? sessionIdMatcher(issuer, providerSessionId) : subjectMatcher(issuer, subject); + ? sessionIdMatcher(audience, issuer, providerSessionId) : subjectMatcher(audience, issuer, subject); if (this.logger.isTraceEnabled()) { String message = "Looking up sessions by issuer [%s] and %s [%s]"; if (providerSessionId != null) { @@ -99,19 +102,24 @@ else if (this.logger.isTraceEnabled()) { return infos; } - private static Predicate sessionIdMatcher(String issuer, String sessionId) { + private static Predicate sessionIdMatcher(List audience, String issuer, + String sessionId) { return (session) -> { + String thatRegistrationId = session.getClientId(); String thatIssuer = session.getPrincipal().getIssuer().toString(); String thatSessionId = session.getPrincipal().getClaimAsString(LogoutTokenClaimNames.SID); - return issuer.equals(thatIssuer) && sessionId.equals(thatSessionId); + return audience.contains(thatRegistrationId) && issuer.equals(thatIssuer) + && sessionId.equals(thatSessionId); }; } - private static Predicate subjectMatcher(String issuer, String subject) { + private static Predicate subjectMatcher(List audience, String issuer, + String subject) { return (session) -> { + String thatRegistrationId = session.getClientId(); String thatIssuer = session.getPrincipal().getIssuer().toString(); String thatSubject = session.getPrincipal().getSubject(); - return issuer.equals(thatIssuer) && subject.equals(thatSubject); + return audience.contains(thatRegistrationId) && issuer.equals(thatIssuer) && subject.equals(thatSubject); }; } diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/session/OidcSessionRegistration.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/session/OidcSessionRegistration.java index 8d6e5747418..8113fe1935c 100644 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/session/OidcSessionRegistration.java +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/session/OidcSessionRegistration.java @@ -30,6 +30,8 @@ */ public class OidcSessionRegistration extends SessionInformation { + private String clientRegistrationId; + /** * Construct an {@link OidcSessionRegistration} * @param sessionId the Client's session id @@ -37,8 +39,14 @@ public class OidcSessionRegistration extends SessionInformation { * ownership * @param user the OIDC Provider's session and end user */ - public OidcSessionRegistration(String sessionId, Map additionalHeaders, OidcUser user) { + public OidcSessionRegistration(String clientId, String sessionId, Map additionalHeaders, + OidcUser user) { super(user, sessionId, additionalHeaders); + this.clientRegistrationId = clientId; + } + + public String getClientId() { + return this.clientRegistrationId; } @Override diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/authentication/logout/TestOidcLogoutTokens.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/authentication/logout/TestOidcLogoutTokens.java index 86942d69ee4..15788c830ea 100644 --- a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/authentication/logout/TestOidcLogoutTokens.java +++ b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/authentication/logout/TestOidcLogoutTokens.java @@ -25,8 +25,8 @@ public final class TestOidcLogoutTokens { public static OidcLogoutToken.Builder withUser(OidcUser user) { OidcLogoutToken.Builder builder = OidcLogoutToken.withTokenValue("token") - .audience(Collections.singleton("audience")).issuedAt(Instant.now()).issuer(user.getIssuer().toString()) - .jti("id").subject(user.getSubject()); + .audience(Collections.singleton("client-id")).issuedAt(Instant.now()) + .issuer(user.getIssuer().toString()).jti("id").subject(user.getSubject()); if (user.hasClaim(LogoutTokenClaimNames.SID)) { builder.sessionId(user.getClaimAsString(LogoutTokenClaimNames.SID)); } @@ -34,12 +34,12 @@ public static OidcLogoutToken.Builder withUser(OidcUser user) { } public static OidcLogoutToken.Builder withSessionId(String issuer, String sessionId) { - return OidcLogoutToken.withTokenValue("token").audience(Collections.singleton("audience")) + return OidcLogoutToken.withTokenValue("token").audience(Collections.singleton("client-id")) .issuedAt(Instant.now()).issuer(issuer).jti("id").sessionId(sessionId); } public static OidcLogoutToken.Builder withSubject(String issuer, String subject) { - return OidcLogoutToken.withTokenValue("token").audience(Collections.singleton("audience")) + return OidcLogoutToken.withTokenValue("token").audience(Collections.singleton("client-id")) .issuedAt(Instant.now()).issuer(issuer).jti("id").subject(subject); } diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/authentication/session/TestOidcSessionRegistrations.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/authentication/session/TestOidcSessionRegistrations.java index 9d39d59b6ea..3beb9b9bc5d 100644 --- a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/authentication/session/TestOidcSessionRegistrations.java +++ b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/authentication/session/TestOidcSessionRegistrations.java @@ -32,7 +32,7 @@ public static OidcSessionRegistration create(String sessionId) { } public static OidcSessionRegistration create(String sessionId, OidcUser user) { - return new OidcSessionRegistration(sessionId, Map.of("_csrf", "token"), user); + return new OidcSessionRegistration("client-id", sessionId, Map.of("_csrf", "token"), user); } private TestOidcSessionRegistrations() { From 556b2424fa6e285970b80b87682d341da99f5eaa Mon Sep 17 00:00:00 2001 From: Josh Cummings Date: Wed, 19 Jul 2023 15:21:48 -0600 Subject: [PATCH 07/12] Responding to Feedback - Moved session registry logic into OidcBackChannelLogoutHandler - Updating naming conventions - Moved packages - Adjusted exception handling - Added audience checks --- .../oauth2/client/OidcLogoutConfigurer.java | 109 +++++++------ .../client/OidcLogoutConfigurerTests.java | 79 +++++----- .../core/session/SessionInformation.java | 17 -- ...efaultOidcLogoutTokenValidatorFactory.java | 4 +- .../logout/LogoutTokenClaimAccessor.java | 10 +- .../logout/LogoutTokenClaimNames.java | 9 +- .../OidcBackChannelLogoutAuthentication.java | 65 ++++++++ ...kChannelLogoutAuthenticationProvider.java} | 68 +++++--- ... OidcBackChannelLogoutTokenValidator.java} | 11 +- ...ava => OidcLogoutAuthenticationToken.java} | 31 +++- .../logout/OidcLogoutToken.java | 36 +++-- .../session/OidcSessionRegistration.java | 57 ------- .../session/InMemoryOidcSessionRegistry.java | 53 +++---- .../oidc/session/OidcSessionInformation.java | 73 +++++++++ .../session/OidcSessionRegistry.java | 28 ++-- .../OidcBackChannelLogoutFilter.java | 22 ++- .../logout/OidcBackChannelLogoutHandler.java | 147 ++++++++++++++++++ ...cBackChannelLogoutTokenValidatorTests.java | 88 +++++++++++ .../logout/OidcLogoutTokenValidatorTests.java | 40 ----- .../InMemoryOidcSessionRegistryTests.java | 98 ------------ .../InMemoryOidcSessionRegistryTests.java | 102 ++++++++++++ .../TestOidcSessionInformations.java} | 17 +- .../OidcBackChannelLogoutFilterTests.java | 94 ++++++----- .../oauth2/core/oidc/TestOidcIdTokens.java | 2 + .../oauth2/core/oidc/user/TestOidcUsers.java | 2 +- .../BackchannelLogoutAuthentication.java | 57 ------- .../logout/BackchannelLogoutHandler.java | 97 ------------ 27 files changed, 803 insertions(+), 613 deletions(-) create mode 100644 oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/OidcBackChannelLogoutAuthentication.java rename oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/{OidcBackChannelLogoutAuthenticationManager.java => OidcBackChannelLogoutAuthenticationProvider.java} (60%) rename oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/{OidcLogoutTokenValidator.java => OidcBackChannelLogoutTokenValidator.java} (89%) rename oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/{LogoutTokenAuthenticationToken.java => OidcLogoutAuthenticationToken.java} (61%) delete mode 100644 oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/session/OidcSessionRegistration.java rename oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/{authentication => }/session/InMemoryOidcSessionRegistry.java (65%) create mode 100644 oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/session/OidcSessionInformation.java rename oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/{authentication => }/session/OidcSessionRegistry.java (65%) rename oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/web/{authentication/logout => }/OidcBackChannelLogoutFilter.java (86%) create mode 100644 oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/web/logout/OidcBackChannelLogoutHandler.java create mode 100644 oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/authentication/logout/OidcBackChannelLogoutTokenValidatorTests.java delete mode 100644 oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/authentication/logout/OidcLogoutTokenValidatorTests.java delete mode 100644 oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/authentication/session/InMemoryOidcSessionRegistryTests.java create mode 100644 oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/session/InMemoryOidcSessionRegistryTests.java rename oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/{authentication/session/TestOidcSessionRegistrations.java => session/TestOidcSessionInformations.java} (64%) rename oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/web/{authentication/logout => }/OidcBackChannelLogoutFilterTests.java (53%) delete mode 100644 web/src/main/java/org/springframework/security/web/authentication/logout/BackchannelLogoutAuthentication.java delete mode 100644 web/src/main/java/org/springframework/security/web/authentication/logout/BackchannelLogoutHandler.java diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurer.java index ed6d9dbfa83..39c50265f08 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurer.java @@ -32,6 +32,8 @@ import org.springframework.context.event.GenericApplicationListenerAdapter; import org.springframework.context.event.SmartApplicationListener; import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.ProviderManager; +import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.HttpSecurityBuilder; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; @@ -41,16 +43,14 @@ import org.springframework.security.core.session.AbstractSessionEvent; import org.springframework.security.core.session.SessionDestroyedEvent; import org.springframework.security.core.session.SessionIdChangedEvent; -import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; -import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcBackChannelLogoutAuthenticationManager; -import org.springframework.security.oauth2.client.oidc.authentication.session.InMemoryOidcSessionRegistry; -import org.springframework.security.oauth2.client.oidc.authentication.session.OidcSessionRegistration; -import org.springframework.security.oauth2.client.oidc.authentication.session.OidcSessionRegistry; -import org.springframework.security.oauth2.client.oidc.web.authentication.logout.OidcBackChannelLogoutFilter; -import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcBackChannelLogoutAuthenticationProvider; +import org.springframework.security.oauth2.client.oidc.session.InMemoryOidcSessionRegistry; +import org.springframework.security.oauth2.client.oidc.session.OidcSessionInformation; +import org.springframework.security.oauth2.client.oidc.session.OidcSessionRegistry; +import org.springframework.security.oauth2.client.oidc.web.OidcBackChannelLogoutFilter; +import org.springframework.security.oauth2.client.oidc.web.logout.OidcBackChannelLogoutHandler; import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; import org.springframework.security.oauth2.core.oidc.user.OidcUser; -import org.springframework.security.web.authentication.logout.BackchannelLogoutHandler; import org.springframework.security.web.authentication.logout.LogoutHandler; import org.springframework.security.web.authentication.session.SessionAuthenticationException; import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy; @@ -59,11 +59,11 @@ import org.springframework.util.Assert; /** - * An {@link AbstractHttpConfigurer} for OAuth 2.0 Logout flows + * An {@link AbstractHttpConfigurer} for OIDC Logout flows * *

- * OAuth 2.0 Logout provides an application with the capability to have users log out by - * using their existing account at an OAuth 2.0 or OpenID Connect 1.0 Provider. + * OIDC Logout provides an application with the capability to have users log out by using + * their existing account at an OAuth 2.0 or OpenID Connect 1.0 Provider. * * *

Security Filters

@@ -83,7 +83,7 @@ * * * @author Josh Cummings - * @since 6.1 + * @since 6.2 * @see HttpSecurity#oidcLogout() * @see OidcBackChannelLogoutFilter * @see ClientRegistrationRepository @@ -97,11 +97,11 @@ public final class OidcLogoutConfigurer> * Configure OIDC Back-Channel Logout using the provided {@link Consumer} * @return the {@link OidcLogoutConfigurer} for further configuration */ - public OidcLogoutConfigurer backChannel(Consumer backChannelLogoutConfigurer) { + public OidcLogoutConfigurer backChannel(Customizer backChannelLogoutConfigurer) { if (this.backChannel == null) { this.backChannel = new BackChannelLogoutConfigurer(); } - backChannelLogoutConfigurer.accept(this.backChannel); + backChannelLogoutConfigurer.customize(this.backChannel); return this; } @@ -139,26 +139,46 @@ private T getBeanOrNull(Class type) { } } + /** + * A configurer for configuring OIDC Back-Channel Logout + */ public final class BackChannelLogoutConfigurer { - private LogoutHandler logoutHandler = new BackchannelLogoutHandler(); - - private AuthenticationManager authenticationManager; + private AuthenticationManager authenticationManager = new ProviderManager( + new OidcBackChannelLogoutAuthenticationProvider()); private OidcSessionRegistry sessionRegistry = new InMemoryOidcSessionRegistry(); + private LogoutHandler logoutHandler; + + /** + * Use this {@link AuthenticationManager} to authenticate the OIDC Logout Token + * @param authenticationManager the {@link AuthenticationManager} to use + * @return the {@link BackChannelLogoutConfigurer} for further configuration + */ public BackChannelLogoutConfigurer authenticationManager(AuthenticationManager authenticationManager) { Assert.notNull(authenticationManager, "authenticationManager cannot be null"); this.authenticationManager = authenticationManager; return this; } - public BackChannelLogoutConfigurer sessionRegistry(OidcSessionRegistry sessionRegistry) { + /** + * Use this {@link OidcSessionRegistry} for managing the client-provider session + * link + * @param sessionRegistry the {@link OidcSessionRegistry} to use + * @return the {@link BackChannelLogoutConfigurer} for further configuration + */ + public BackChannelLogoutConfigurer oidcSessionRegistry(OidcSessionRegistry sessionRegistry) { Assert.notNull(sessionRegistry, "sessionRegistry cannot be null"); this.sessionRegistry = sessionRegistry; return this; } + /** + * Use this {@link LogoutHandler} for invalidating each session identified by the + * OIDC Back-Channel Logout Token + * @return the {@link BackChannelLogoutConfigurer} for further configuration + */ public BackChannelLogoutConfigurer logoutHandler(LogoutHandler logoutHandler) { Assert.notNull(logoutHandler, "logoutHandler cannot be null"); this.logoutHandler = logoutHandler; @@ -166,27 +186,25 @@ public BackChannelLogoutConfigurer logoutHandler(LogoutHandler logoutHandler) { } private AuthenticationManager authenticationManager() { - if (this.authenticationManager == null) { - OidcBackChannelLogoutAuthenticationManager authenticationManager = new OidcBackChannelLogoutAuthenticationManager(); - authenticationManager.setSessionRegistry(sessionRegistry()); - this.authenticationManager = authenticationManager; - } return this.authenticationManager; } - private OidcSessionRegistry sessionRegistry() { + private OidcSessionRegistry oidcSessionRegistry() { return this.sessionRegistry; } private LogoutHandler logoutHandler() { + if (this.logoutHandler == null) { + OidcBackChannelLogoutHandler logoutHandler = new OidcBackChannelLogoutHandler(); + logoutHandler.setSessionRegistry(this.sessionRegistry); + this.logoutHandler = logoutHandler; + } return this.logoutHandler; } - private SessionAuthenticationStrategy sessionAuthenticationStrategy( - ClientRegistrationRepository clientRegistrationRepository) { - OidcSessionRegistryAuthenticationStrategy strategy = new OidcSessionRegistryAuthenticationStrategy( - clientRegistrationRepository); - strategy.setSessionRegistry(sessionRegistry()); + private SessionAuthenticationStrategy sessionAuthenticationStrategy() { + OidcSessionRegistryAuthenticationStrategy strategy = new OidcSessionRegistryAuthenticationStrategy(); + strategy.setSessionRegistry(oidcSessionRegistry()); return strategy; } @@ -195,13 +213,11 @@ void configure(B http) { .getClientRegistrationRepository(http); OidcBackChannelLogoutFilter filter = new OidcBackChannelLogoutFilter(clientRegistrationRepository, authenticationManager()); - LogoutHandler expiredStrategy = logoutHandler(); - filter.setLogoutHandler(expiredStrategy); + filter.setLogoutHandler(logoutHandler()); http.addFilterBefore(filter, CsrfFilter.class); SessionManagementConfigurer sessionConfigurer = http.getConfigurer(SessionManagementConfigurer.class); if (sessionConfigurer != null) { - sessionConfigurer - .addSessionAuthenticationStrategy(sessionAuthenticationStrategy(clientRegistrationRepository)); + sessionConfigurer.addSessionAuthenticationStrategy(sessionAuthenticationStrategy()); } OidcClientSessionEventListener listener = new OidcClientSessionEventListener(); listener.setSessionRegistry(this.sessionRegistry); @@ -221,12 +237,17 @@ static final class OidcClientSessionEventListener implements ApplicationListener public void onApplicationEvent(AbstractSessionEvent event) { if (event instanceof SessionDestroyedEvent destroyed) { this.logger.debug("Received SessionDestroyedEvent"); - this.sessionRegistry.deregister(destroyed.getId()); + this.sessionRegistry.removeSessionInformation(destroyed.getId()); return; } if (event instanceof SessionIdChangedEvent changed) { this.logger.debug("Received SessionIdChangedEvent"); - this.sessionRegistry.register(changed.getOldSessionId(), changed.getNewSessionId()); + OidcSessionInformation information = this.sessionRegistry.removeSessionInformation(changed.getOldSessionId()); + if (information == null) { + this.logger.debug("Failed to register new session id since old session id was not found in registry"); + return; + } + this.sessionRegistry.saveSessionInformation(information.withSessionId(changed.getNewSessionId())); } } @@ -246,14 +267,8 @@ static final class OidcSessionRegistryAuthenticationStrategy implements SessionA private final Log logger = LogFactory.getLog(getClass()); - private final ClientRegistrationRepository clientRegistrationRepository; - private OidcSessionRegistry sessionRegistry = new InMemoryOidcSessionRegistry(); - OidcSessionRegistryAuthenticationStrategy(ClientRegistrationRepository clientRegistrationRepository) { - this.clientRegistrationRepository = clientRegistrationRepository; - } - /** * {@inheritDoc} */ @@ -263,28 +278,22 @@ public void onAuthentication(Authentication authentication, HttpServletRequest r if (session == null) { return; } - if (!(authentication instanceof OAuth2AuthenticationToken token)) { - return; - } if (!(authentication.getPrincipal() instanceof OidcUser user)) { return; } - String registrationId = token.getAuthorizedClientRegistrationId(); - ClientRegistration clientRegistration = this.clientRegistrationRepository.findByRegistrationId(registrationId); - String clientId = clientRegistration.getClientId(); String sessionId = session.getId(); CsrfToken csrfToken = (CsrfToken) request.getAttribute(CsrfToken.class.getName()); Map headers = (csrfToken != null) ? Map.of(csrfToken.getHeaderName(), csrfToken.getToken()) : Collections.emptyMap(); - OidcSessionRegistration registration = new OidcSessionRegistration(clientId, sessionId, headers, user); + OidcSessionInformation registration = new OidcSessionInformation(sessionId, headers, user); if (this.logger.isTraceEnabled()) { this.logger.trace(String.format("Linking a provider [%s] session to this client's session", user.getIssuer())); } - this.sessionRegistry.register(registration); + this.sessionRegistry.saveSessionInformation(registration); } /** * The registration for linking OIDC Provider Session information to the - * Client's session. Defaults to in-memory. + * Client's session. Defaults to in-memory storage. * @param sessionRegistry the {@link OidcSessionRegistry} to use */ void setSessionRegistry(OidcSessionRegistry sessionRegistry) { diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurerTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurerTests.java index 1282bcb9323..4b0bcfc08ab 100644 --- a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurerTests.java +++ b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurerTests.java @@ -62,12 +62,13 @@ import org.springframework.security.core.userdetails.User; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.oauth2.client.oidc.authentication.logout.LogoutTokenClaimNames; -import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcBackChannelLogoutAuthenticationManager; +import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcBackChannelLogoutAuthentication; import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcLogoutToken; import org.springframework.security.oauth2.client.oidc.authentication.logout.TestOidcLogoutTokens; -import org.springframework.security.oauth2.client.oidc.authentication.session.OidcSessionRegistration; -import org.springframework.security.oauth2.client.oidc.authentication.session.OidcSessionRegistry; -import org.springframework.security.oauth2.client.oidc.authentication.session.TestOidcSessionRegistrations; +import org.springframework.security.oauth2.client.oidc.session.OidcSessionInformation; +import org.springframework.security.oauth2.client.oidc.session.OidcSessionRegistry; +import org.springframework.security.oauth2.client.oidc.session.TestOidcSessionInformations; +import org.springframework.security.oauth2.client.oidc.web.logout.OidcBackChannelLogoutHandler; import org.springframework.security.oauth2.client.registration.ClientRegistration; import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository; @@ -81,7 +82,6 @@ import org.springframework.security.oauth2.jwt.NimbusJwtEncoder; import org.springframework.security.provisioning.InMemoryUserDetailsManager; import org.springframework.security.web.SecurityFilterChain; -import org.springframework.security.web.authentication.logout.BackchannelLogoutAuthentication; import org.springframework.security.web.authentication.logout.LogoutHandler; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MvcResult; @@ -96,6 +96,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; import static org.mockito.Mockito.verify; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic; @@ -103,6 +104,9 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +/** + * Tests for {@link OidcLogoutConfigurer} + */ @ExtendWith(SpringTestContextExtension.class) public class OidcLogoutConfigurerTests { @@ -113,7 +117,7 @@ public class OidcLogoutConfigurerTests { private MockWebServer web; @Autowired - private ClientRegistration registration; + private ClientRegistration clientRegistration; public final SpringTestContext spring = new SpringTestContext(this); @@ -122,14 +126,14 @@ void logoutWhenDefaultsThenRemotelyInvalidatesSessions() throws Exception { this.spring.register(WebServerConfig.class, OidcProviderConfig.class, DefaultConfig.class).autowire(); MockMvcDispatcher dispatcher = (MockMvcDispatcher) this.web.getDispatcher(); this.mvc.perform(get("/token/logout")).andExpect(status().isUnauthorized()); - String registrationId = this.registration.getRegistrationId(); + String registrationId = this.clientRegistration.getRegistrationId(); MvcResult result = this.mvc.perform(get("/oauth2/authorization/" + registrationId)) .andExpect(status().isFound()).andReturn(); MockHttpSession session = (MockHttpSession) result.getRequest().getSession(); String redirectUrl = UrlUtils.decode(result.getResponse().getRedirectedUrl()); String state = this.mvc - .perform(get(redirectUrl) - .with(httpBasic(this.registration.getClientId(), this.registration.getClientSecret()))) + .perform(get(redirectUrl).with( + httpBasic(this.clientRegistration.getClientId(), this.clientRegistration.getClientSecret()))) .andReturn().getResponse().getContentAsString(); result = this.mvc.perform(get("/login/oauth2/code/" + registrationId).param("code", "code") .param("state", state).session(session)).andExpect(status().isFound()).andReturn(); @@ -146,14 +150,14 @@ void logoutWhenDefaultsThenRemotelyInvalidatesSessions() throws Exception { void logoutWhenInvalidLogoutTokenThenBadRequest() throws Exception { this.spring.register(WebServerConfig.class, OidcProviderConfig.class, DefaultConfig.class).autowire(); this.mvc.perform(get("/token/logout")).andExpect(status().isUnauthorized()); - String registrationId = this.registration.getRegistrationId(); + String registrationId = this.clientRegistration.getRegistrationId(); MvcResult result = this.mvc.perform(get("/oauth2/authorization/" + registrationId)) .andExpect(status().isFound()).andReturn(); MockHttpSession session = (MockHttpSession) result.getRequest().getSession(); String redirectUrl = UrlUtils.decode(result.getResponse().getRedirectedUrl()); String state = this.mvc - .perform(get(redirectUrl) - .with(httpBasic(this.registration.getClientId(), this.registration.getClientSecret()))) + .perform(get(redirectUrl).with( + httpBasic(this.clientRegistration.getClientId(), this.clientRegistration.getClientSecret()))) .andReturn().getResponse().getContentAsString(); result = this.mvc.perform(get("/login/oauth2/code/" + registrationId).param("code", "code") .param("state", state).session(session)).andExpect(status().isFound()).andReturn(); @@ -166,18 +170,19 @@ void logoutWhenInvalidLogoutTokenThenBadRequest() throws Exception { @Test void logoutWhenCustomComponentsThenUses() throws Exception { this.spring.register(WithCustomComponentsConfig.class).autowire(); - String registrationId = this.registration.getRegistrationId(); + String registrationId = this.clientRegistration.getRegistrationId(); AuthenticationManager authenticationManager = this.spring.getContext().getBean(AuthenticationManager.class); OidcLogoutToken logoutToken = TestOidcLogoutTokens.withSessionId("issuer", "provider").build(); - Set details = Set.of(TestOidcSessionRegistrations.create()); given(authenticationManager.authenticate(any())) - .willReturn(new BackchannelLogoutAuthentication(logoutToken, logoutToken, details)); - LogoutHandler logoutHandler = this.spring.getContext().getBean(LogoutHandler.class); + .willReturn(new OidcBackChannelLogoutAuthentication(logoutToken)); + OidcSessionRegistry sessionRegistry = this.spring.getContext().getBean(OidcSessionRegistry.class); + Set details = Set.of(TestOidcSessionInformations.create()); + given(sessionRegistry.removeSessionInformation(logoutToken)).willReturn(details); this.mvc.perform(post("/logout/connect/back-channel/" + registrationId).param("logout_token", "token")) .andExpect(status().isOk()); - // verify(registry).deregister(any(OidcLogoutToken.class)); verify(authenticationManager).authenticate(any()); - verify(logoutHandler).logout(any(), any(), any()); + verify(this.spring.getContext().getBean(LogoutHandler.class)).logout(any(), any(), any()); + verify(sessionRegistry).removeSessionInformation(logoutToken); } @Configuration @@ -187,7 +192,7 @@ static class RegistrationConfig { MockWebServer web; @Bean - ClientRegistration registration() { + ClientRegistration clientRegistration() { if (this.web == null) { return TestClientRegistrations.clientRegistration().build(); } @@ -197,8 +202,8 @@ ClientRegistration registration() { } @Bean - ClientRegistrationRepository registrations(ClientRegistration registration) { - return new InMemoryClientRegistrationRepository(registration); + ClientRegistrationRepository clientRegistrationRepository(ClientRegistration clientRegistration) { + return new InMemoryClientRegistrationRepository(clientRegistration); } } @@ -215,9 +220,7 @@ SecurityFilterChain filters(HttpSecurity http) throws Exception { http .authorizeHttpRequests((authorize) -> authorize.anyRequest().authenticated()) .oauth2Login(Customizer.withDefaults()) - .oidcLogout((oauth2) -> oauth2. - backChannel((backchannel) -> { }) - ); + .oidcLogout((oidc) -> oidc.backChannel(Customizer.withDefaults())); // @formatter:on return http.build(); @@ -232,25 +235,23 @@ static class WithCustomComponentsConfig { AuthenticationManager authenticationManager = mock(AuthenticationManager.class); - LogoutHandler logoutHandler = mock(LogoutHandler.class); + OidcSessionRegistry sessionRegistry = mock(OidcSessionRegistry.class); - OidcSessionRegistry registry = mock(OidcSessionRegistry.class); + OidcBackChannelLogoutHandler logoutHandler = spy(new OidcBackChannelLogoutHandler()); @Bean @Order(1) SecurityFilterChain filters(HttpSecurity http) throws Exception { - OidcBackChannelLogoutAuthenticationManager authenticationManager = new OidcBackChannelLogoutAuthenticationManager(); - authenticationManager.setSessionRegistry(this.registry); + this.logoutHandler.setSessionRegistry(this.sessionRegistry); // @formatter:off http .authorizeHttpRequests((authorize) -> authorize.anyRequest().authenticated()) .oauth2Login(Customizer.withDefaults()) - .oidcLogout((oauth2) -> oauth2. - backChannel((backchannel) -> backchannel - .logoutHandler(this.logoutHandler) - .authenticationManager(this.authenticationManager) - ) - ); + .oidcLogout((oidc) -> oidc.backChannel((logout) -> logout + .authenticationManager(this.authenticationManager) + .oidcSessionRegistry(this.sessionRegistry) + .logoutHandler(this.logoutHandler) + )); // @formatter:on return http.build(); @@ -262,13 +263,13 @@ AuthenticationManager authenticationManager() { } @Bean - LogoutHandler logoutHandler() { - return this.logoutHandler; + OidcSessionRegistry sessionRegistry() { + return this.sessionRegistry; } @Bean - OidcSessionRegistry providerSessionRegistry() { - return this.registry; + LogoutHandler logoutHandler() { + return this.logoutHandler; } } @@ -325,7 +326,7 @@ SecurityFilterChain authorizationServer(HttpSecurity http, ClientRegistration re ) .httpBasic(Customizer.withDefaults()) .oauth2ResourceServer((oauth2) -> oauth2 - .jwt().jwkSetUri(registration.getProviderDetails().getJwkSetUri()) + .jwt((jwt) -> jwt.jwkSetUri(registration.getProviderDetails().getJwkSetUri())) ); // @formatter:off diff --git a/core/src/main/java/org/springframework/security/core/session/SessionInformation.java b/core/src/main/java/org/springframework/security/core/session/SessionInformation.java index db53d4bfe1b..54b05bbbb08 100644 --- a/core/src/main/java/org/springframework/security/core/session/SessionInformation.java +++ b/core/src/main/java/org/springframework/security/core/session/SessionInformation.java @@ -18,8 +18,6 @@ import java.io.Serializable; import java.util.Date; -import java.util.LinkedHashMap; -import java.util.Map; import org.springframework.security.core.SpringSecurityCoreVersion; import org.springframework.util.Assert; @@ -51,8 +49,6 @@ public class SessionInformation implements Serializable { private boolean expired = false; - private Map headers = new LinkedHashMap<>(); - public SessionInformation(Object principal, String sessionId, Date lastRequest) { Assert.notNull(principal, "Principal required"); Assert.hasText(sessionId, "SessionId required"); @@ -62,15 +58,6 @@ public SessionInformation(Object principal, String sessionId, Date lastRequest) this.lastRequest = lastRequest; } - public SessionInformation(Object principal, String sessionId, Map headers) { - Assert.notNull(principal, "Principal required"); - Assert.hasText(sessionId, "SessionId required"); - this.principal = principal; - this.sessionId = sessionId; - this.lastRequest = new Date(); - this.headers = headers; - } - public void expireNow() { this.expired = true; } @@ -87,10 +74,6 @@ public String getSessionId() { return this.sessionId; } - public Map getHeaders() { - return this.headers; - } - public boolean isExpired() { return this.expired; } diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/DefaultOidcLogoutTokenValidatorFactory.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/DefaultOidcLogoutTokenValidatorFactory.java index 847b67f827a..d9bc0c944e6 100644 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/DefaultOidcLogoutTokenValidatorFactory.java +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/DefaultOidcLogoutTokenValidatorFactory.java @@ -24,12 +24,12 @@ import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.jwt.JwtTimestampValidator; -class DefaultOidcLogoutTokenValidatorFactory implements Function> { +final class DefaultOidcLogoutTokenValidatorFactory implements Function> { @Override public OAuth2TokenValidator apply(ClientRegistration clientRegistration) { return new DelegatingOAuth2TokenValidator<>(new JwtTimestampValidator(), - new OidcLogoutTokenValidator(clientRegistration)); + new OidcBackChannelLogoutTokenValidator(clientRegistration)); } } diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/LogoutTokenClaimAccessor.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/LogoutTokenClaimAccessor.java index 1fe9c78c466..49aeff4c3cc 100644 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/LogoutTokenClaimAccessor.java +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/LogoutTokenClaimAccessor.java @@ -24,15 +24,15 @@ import org.springframework.security.oauth2.core.ClaimAccessor; /** - * A {@link ClaimAccessor} for the "claims" that can be returned in OIDC - * Backchannel Logout Tokens + * A {@link ClaimAccessor} for the "claims" that can be returned in OIDC Logout + * Tokens * * @author Josh Cummings - * @since 6.1 + * @since 6.2 * @see OidcLogoutToken * @see Logout - * Token + * "https://openid.net/specs/openid-connect-backchannel-1_0.html#LogoutToken">OIDC + * Back-Channel Logout Token */ public interface LogoutTokenClaimAccessor extends ClaimAccessor { diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/LogoutTokenClaimNames.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/LogoutTokenClaimNames.java index 5f00470ba37..9893aa350ab 100644 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/LogoutTokenClaimNames.java +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/LogoutTokenClaimNames.java @@ -17,17 +17,16 @@ package org.springframework.security.oauth2.client.oidc.authentication.logout; /** - * The names of the "claims" defined by the OpenID Backchannel Logout 1.0 + * The names of the "claims" defined by the OpenID Back-Channel Logout 1.0 * specification that can be returned in a Logout Token. * * @author Josh Cummings - * @since 6.1 + * @since 6.2 * @see OidcLogoutToken * @see Logout - * Token + * "https://openid.net/specs/openid-connect-backchannel-1_0.html#LogoutToken">OIDC + * Back-Channel Logout Token */ - public final class LogoutTokenClaimNames { /** diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/OidcBackChannelLogoutAuthentication.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/OidcBackChannelLogoutAuthentication.java new file mode 100644 index 00000000000..0f12ad3f06e --- /dev/null +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/OidcBackChannelLogoutAuthentication.java @@ -0,0 +1,65 @@ +/* + * Copyright 2002-2023 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.oidc.authentication.logout; + +import java.util.Collections; + +import org.springframework.security.authentication.AbstractAuthenticationToken; + +/** + * An {@link org.springframework.security.core.Authentication} implementation that + * represents the result of authenticating an OIDC Logout token for the purposes of + * performing Back-Channel Logout. + * + * @author Josh Cummings + * @since 6.2 + * @see OidcLogoutAuthenticationToken + * @see OIDC Back-Channel + * Logout + */ +public class OidcBackChannelLogoutAuthentication extends AbstractAuthenticationToken { + + private final OidcLogoutToken logoutToken; + + /** + * Construct an {@link OidcBackChannelLogoutAuthentication} + * @param logoutToken a deserialized, verified OIDC Logout Token + */ + public OidcBackChannelLogoutAuthentication(OidcLogoutToken logoutToken) { + super(Collections.emptyList()); + this.logoutToken = logoutToken; + setAuthenticated(true); + } + + /** + * {@inheritDoc} + */ + @Override + public OidcLogoutToken getPrincipal() { + return this.logoutToken; + } + + /** + * {@inheritDoc} + */ + @Override + public OidcLogoutToken getCredentials() { + return this.logoutToken; + } + +} diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/OidcBackChannelLogoutAuthenticationManager.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/OidcBackChannelLogoutAuthenticationProvider.java similarity index 60% rename from oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/OidcBackChannelLogoutAuthenticationManager.java rename to oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/OidcBackChannelLogoutAuthenticationProvider.java index dd05f34ef91..8c417cc1143 100644 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/OidcBackChannelLogoutAuthenticationManager.java +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/OidcBackChannelLogoutAuthenticationProvider.java @@ -16,39 +16,56 @@ package org.springframework.security.oauth2.client.oidc.authentication.logout; -import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.AuthenticationProvider; import org.springframework.security.authentication.AuthenticationServiceException; -import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; -import org.springframework.security.core.session.SessionInformation; import org.springframework.security.oauth2.client.oidc.authentication.OidcIdTokenDecoderFactory; -import org.springframework.security.oauth2.client.oidc.authentication.session.InMemoryOidcSessionRegistry; -import org.springframework.security.oauth2.client.oidc.authentication.session.OidcSessionRegistry; import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; import org.springframework.security.oauth2.jwt.BadJwtException; import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.jwt.JwtDecoder; import org.springframework.security.oauth2.jwt.JwtDecoderFactory; -import org.springframework.security.oauth2.jwt.JwtException; -import org.springframework.security.web.authentication.logout.BackchannelLogoutAuthentication; import org.springframework.util.Assert; -public final class OidcBackChannelLogoutAuthenticationManager implements AuthenticationManager { +/** + * An {@link AuthenticationProvider} that authenticates an OIDC Logout Token; namely + * deserializing it, verifying its signature, and validating its claims. + * + *

+ * Intended to be included in a + * {@link org.springframework.security.authentication.ProviderManager} + * + * @author Josh Cummings + * @since 6.2 + * @see OidcLogoutAuthenticationToken + * @see org.springframework.security.authentication.ProviderManager + * @see OIDC Back-Channel + * Logout + */ +public final class OidcBackChannelLogoutAuthenticationProvider implements AuthenticationProvider { private JwtDecoderFactory logoutTokenDecoderFactory; - private OidcSessionRegistry sessionRegistry = new InMemoryOidcSessionRegistry(); - - public OidcBackChannelLogoutAuthenticationManager() { + /** + * Construct an {@link OidcBackChannelLogoutAuthenticationProvider} + */ + public OidcBackChannelLogoutAuthenticationProvider() { OidcIdTokenDecoderFactory logoutTokenDecoderFactory = new OidcIdTokenDecoderFactory(); logoutTokenDecoderFactory.setJwtValidatorFactory(new DefaultOidcLogoutTokenValidatorFactory()); this.logoutTokenDecoderFactory = logoutTokenDecoderFactory; } + /** + * {@inheritDoc} + */ @Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { - if (!(authentication instanceof LogoutTokenAuthenticationToken token)) { + if (!(authentication instanceof OidcLogoutAuthenticationToken token)) { return null; } String logoutToken = token.getLogoutToken(); @@ -56,8 +73,15 @@ public Authentication authenticate(Authentication authentication) throws Authent Jwt jwt = decode(registration, logoutToken); OidcLogoutToken oidcLogoutToken = OidcLogoutToken.withTokenValue(logoutToken) .claims((claims) -> claims.putAll(jwt.getClaims())).build(); - Iterable sessions = this.sessionRegistry.deregister(oidcLogoutToken); - return new BackchannelLogoutAuthentication(oidcLogoutToken, oidcLogoutToken, sessions); + return new OidcBackChannelLogoutAuthentication(oidcLogoutToken); + } + + /** + * {@inheritDoc} + */ + @Override + public boolean supports(Class authentication) { + return OidcLogoutAuthenticationToken.class.isAssignableFrom(authentication); } private Jwt decode(ClientRegistration registration, String token) { @@ -66,21 +90,23 @@ private Jwt decode(ClientRegistration registration, String token) { return logoutTokenDecoder.decode(token); } catch (BadJwtException failed) { - throw new BadCredentialsException(failed.getMessage(), failed); + OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.INVALID_REQUEST, failed.getMessage(), + "https://openid.net/specs/openid-connect-backchannel-1_0.html#Validation"); + throw new OAuth2AuthenticationException(error, failed); } - catch (JwtException failed) { + catch (Exception failed) { throw new AuthenticationServiceException(failed.getMessage(), failed); } } + /** + * Use this {@link JwtDecoderFactory} to generate {@link JwtDecoder}s that correspond + * to the {@link ClientRegistration} associated with the OIDC logout token. + * @param logoutTokenDecoderFactory the {@link JwtDecoderFactory} to use + */ public void setLogoutTokenDecoderFactory(JwtDecoderFactory logoutTokenDecoderFactory) { Assert.notNull(logoutTokenDecoderFactory, "logoutTokenDecoderFactory cannot be null"); this.logoutTokenDecoderFactory = logoutTokenDecoderFactory; } - public void setSessionRegistry(OidcSessionRegistry sessionRegistry) { - Assert.notNull(sessionRegistry, "sessionRegistry cannot be null"); - this.sessionRegistry = sessionRegistry; - } - } diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/OidcLogoutTokenValidator.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/OidcBackChannelLogoutTokenValidator.java similarity index 89% rename from oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/OidcLogoutTokenValidator.java rename to oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/OidcBackChannelLogoutTokenValidator.java index 680820c134a..d0bd9408560 100644 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/OidcLogoutTokenValidator.java +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/OidcBackChannelLogoutTokenValidator.java @@ -28,10 +28,10 @@ import org.springframework.security.oauth2.core.OAuth2TokenValidator; import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult; import org.springframework.security.oauth2.jwt.Jwt; -import org.springframework.security.oauth2.jwt.JwtDecoderFactory; /** - * A {@link JwtDecoderFactory} that decodes and verifies OIDC Logout Tokens. + * A {@link OAuth2TokenValidator} that validates OIDC Logout Token claims in conformance + * with the OIDC Back-Channel Logout Spec. * * @author Josh Cummings * @since 6.2 @@ -39,8 +39,11 @@ * @see Logout * Token + * @see the OIDC + * Back-Channel Logout spec */ -public final class OidcLogoutTokenValidator implements OAuth2TokenValidator { +public final class OidcBackChannelLogoutTokenValidator implements OAuth2TokenValidator { private static final String LOGOUT_VALIDATION_URL = "https://openid.net/specs/openid-connect-backchannel-1_0.html#Validation"; @@ -50,7 +53,7 @@ public final class OidcLogoutTokenValidator implements OAuth2TokenValidator private final String issuer; - OidcLogoutTokenValidator(ClientRegistration clientRegistration) { + public OidcBackChannelLogoutTokenValidator(ClientRegistration clientRegistration) { this.audience = clientRegistration.getClientId(); this.issuer = clientRegistration.getProviderDetails().getIssuerUri(); } diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/LogoutTokenAuthenticationToken.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/OidcLogoutAuthenticationToken.java similarity index 61% rename from oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/LogoutTokenAuthenticationToken.java rename to oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/OidcLogoutAuthenticationToken.java index c0e2690d79a..8912dc52df1 100644 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/LogoutTokenAuthenticationToken.java +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/OidcLogoutAuthenticationToken.java @@ -20,32 +20,59 @@ import org.springframework.security.core.authority.AuthorityUtils; import org.springframework.security.oauth2.client.registration.ClientRegistration; -public class LogoutTokenAuthenticationToken extends AbstractAuthenticationToken { +/** + * An {@link org.springframework.security.core.Authentication} instance that represents a + * request to authenticate an OIDC Logout Token. + * + * @author Josh Cummings + * @since 6.2 + */ +public class OidcLogoutAuthenticationToken extends AbstractAuthenticationToken { private final String logoutToken; private final ClientRegistration clientRegistration; - public LogoutTokenAuthenticationToken(String logoutToken, ClientRegistration clientRegistration) { + /** + * Construct an {@link OidcLogoutAuthenticationToken} + * @param logoutToken a signed, serialized OIDC Logout token + * @param clientRegistration the {@link ClientRegistration client} associated with + * this token; this is usually derived from material in the logout HTTP request + */ + public OidcLogoutAuthenticationToken(String logoutToken, ClientRegistration clientRegistration) { super(AuthorityUtils.NO_AUTHORITIES); this.logoutToken = logoutToken; this.clientRegistration = clientRegistration; } + /** + * {@inheritDoc} + */ @Override public String getCredentials() { return this.logoutToken; } + /** + * {@inheritDoc} + */ @Override public String getPrincipal() { return this.logoutToken; } + /** + * Get the signed, serialized OIDC Logout token + * @return the logout token + */ public String getLogoutToken() { return this.logoutToken; } + /** + * Get the {@link ClientRegistration} associated with this logout token + * @return the {@link ClientRegistration} + */ public ClientRegistration getClientRegistration() { return this.clientRegistration; } diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/OidcLogoutToken.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/OidcLogoutToken.java index 3407e0da945..41b425bf408 100644 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/OidcLogoutToken.java +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/OidcLogoutToken.java @@ -25,7 +25,6 @@ import org.springframework.security.oauth2.core.AbstractOAuth2Token; import org.springframework.security.oauth2.core.oidc.IdTokenClaimNames; -import org.springframework.security.oauth2.core.oidc.OidcIdToken; import org.springframework.util.Assert; /** @@ -37,7 +36,7 @@ * terminating sessions for a given OIDC Provider session id or End User. * * @author Josh Cummings - * @since 6.1 + * @since 6.2 * @see AbstractOAuth2Token * @see LogoutTokenClaimAccessor * @see claims; /** - * Constructs a {@code OidcLogoutToken} using the provided parameters. + * Constructs a {@link OidcLogoutToken} using the provided parameters. * @param tokenValue the Logout Token value * @param issuedAt the time at which the Logout Token was issued {@code (iat)} * @param claims the claims about the logout statement @@ -90,11 +89,11 @@ public static final class Builder { private Builder(String tokenValue) { this.tokenValue = tokenValue; this.claims.put(LogoutTokenClaimNames.EVENTS, - Collections.singletonMap(LOGOUT_TOKEN_EVENT_NAME, Collections.emptyMap())); + Collections.singletonMap(BACKCHANNEL_LOGOUT_TOKEN_EVENT_NAME, Collections.emptyMap())); } /** - * Use this token value in the resulting {@link OidcIdToken} + * Use this token value in the resulting {@link OidcLogoutToken} * @param tokenValue The token value to use * @return the {@link Builder} for further configurations */ @@ -104,7 +103,7 @@ public Builder tokenValue(String tokenValue) { } /** - * Use this claim in the resulting {@link OidcIdToken} + * Use this claim in the resulting {@link OidcLogoutToken} * @param name The claim name * @param value The claim value * @return the {@link Builder} for further configurations @@ -126,7 +125,7 @@ public Builder claims(Consumer> claimsConsumer) { } /** - * Use this audience in the resulting {@link OidcIdToken} + * Use this audience in the resulting {@link OidcLogoutToken} * @param audience The audience(s) to use * @return the {@link Builder} for further configurations */ @@ -135,7 +134,7 @@ public Builder audience(Collection audience) { } /** - * Use this issued-at timestamp in the resulting {@link OidcIdToken} + * Use this issued-at timestamp in the resulting {@link OidcLogoutToken} * @param issuedAt The issued-at timestamp to use * @return the {@link Builder} for further configurations */ @@ -144,7 +143,7 @@ public Builder issuedAt(Instant issuedAt) { } /** - * Use this issuer in the resulting {@link OidcIdToken} + * Use this issuer in the resulting {@link OidcLogoutToken} * @param issuer The issuer to use * @return the {@link Builder} for further configurations */ @@ -152,12 +151,17 @@ public Builder issuer(String issuer) { return claim(LogoutTokenClaimNames.ISS, issuer); } - public Builder jti(String id) { - return claim(LogoutTokenClaimNames.JTI, id); + /** + * Use this id to identify the resulting {@link OidcLogoutToken} + * @param jti The unique identifier to use + * @return the {@link Builder} for further configurations + */ + public Builder jti(String jti) { + return claim(LogoutTokenClaimNames.JTI, jti); } /** - * Use this subject in the resulting {@link OidcIdToken} + * Use this subject in the resulting {@link OidcLogoutToken} * @param subject The subject to use * @return the {@link Builder} for further configurations */ @@ -190,8 +194,8 @@ public OidcLogoutToken build() { Assert.notEmpty((Collection) this.claims.get(LogoutTokenClaimNames.AUD), "audience must not be empty"); Assert.notNull(this.claims.get(LogoutTokenClaimNames.JTI), "jti must not be null"); Assert.isTrue(hasLogoutTokenIdentifyingMember(), - "logout token must contain an events claim that contains a member called " - + "'http://schemas.openid.net/event/backchannel-logout' whose value is an empty Map"); + "logout token must contain an events claim that contains a member called " + "'" + + BACKCHANNEL_LOGOUT_TOKEN_EVENT_NAME + "' whose value is an empty Map"); Assert.isNull(this.claims.get("nonce"), "logout token must not contain a nonce claim"); Instant iat = toInstant(this.claims.get(IdTokenClaimNames.IAT)); return new OidcLogoutToken(this.tokenValue, iat, this.claims); @@ -201,7 +205,7 @@ private boolean hasLogoutTokenIdentifyingMember() { if (!(this.claims.get(LogoutTokenClaimNames.EVENTS) instanceof Map events)) { return false; } - if (!(events.get("http://schemas.openid.net/event/backchannel-logout") instanceof Map object)) { + if (!(events.get(BACKCHANNEL_LOGOUT_TOKEN_EVENT_NAME) instanceof Map object)) { return false; } return object.isEmpty(); diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/session/OidcSessionRegistration.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/session/OidcSessionRegistration.java deleted file mode 100644 index 8113fe1935c..00000000000 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/session/OidcSessionRegistration.java +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Copyright 2002-2023 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.oidc.authentication.session; - -import java.util.Map; - -import org.springframework.security.core.session.SessionInformation; -import org.springframework.security.oauth2.core.oidc.user.OidcUser; - -/** - * The default implementation for {@link OidcSessionRegistration}. Handy for in-memory - * registries. - * - * @author Josh Cummings - * @since 6.2 - */ -public class OidcSessionRegistration extends SessionInformation { - - private String clientRegistrationId; - - /** - * Construct an {@link OidcSessionRegistration} - * @param sessionId the Client's session id - * @param additionalHeaders any additional headers needed to authenticate session - * ownership - * @param user the OIDC Provider's session and end user - */ - public OidcSessionRegistration(String clientId, String sessionId, Map additionalHeaders, - OidcUser user) { - super(user, sessionId, additionalHeaders); - this.clientRegistrationId = clientId; - } - - public String getClientId() { - return this.clientRegistrationId; - } - - @Override - public OidcUser getPrincipal() { - return (OidcUser) super.getPrincipal(); - } - -} diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/session/InMemoryOidcSessionRegistry.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/session/InMemoryOidcSessionRegistry.java similarity index 65% rename from oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/session/InMemoryOidcSessionRegistry.java rename to oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/session/InMemoryOidcSessionRegistry.java index 1215bc5b709..f5bb6235df3 100644 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/session/InMemoryOidcSessionRegistry.java +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/session/InMemoryOidcSessionRegistry.java @@ -14,8 +14,9 @@ * limitations under the License. */ -package org.springframework.security.oauth2.client.oidc.authentication.session; +package org.springframework.security.oauth2.client.oidc.session; +import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Map; @@ -39,40 +40,29 @@ public final class InMemoryOidcSessionRegistry implements OidcSessionRegistry { private final Log logger = LogFactory.getLog(InMemoryOidcSessionRegistry.class); - private final Map sessions = new ConcurrentHashMap<>(); + private final Map sessions = new ConcurrentHashMap<>(); @Override - public void register(OidcSessionRegistration registration) { - this.sessions.put(registration.getSessionId(), registration); + public void saveSessionInformation(OidcSessionInformation info) { + this.sessions.put(info.getSessionId(), info); } @Override - public void register(String oldClientSessionId, String newClientSessionId) { - OidcSessionRegistration old = this.sessions.remove(oldClientSessionId); - if (old == null) { - this.logger.debug("Failed to register new session id since old session id was not found in registry"); - return; - } - register(new OidcSessionRegistration(old.getClientId(), newClientSessionId, old.getHeaders(), - old.getPrincipal())); - } - - @Override - public OidcSessionRegistration deregister(String clientSessionId) { - OidcSessionRegistration details = this.sessions.remove(clientSessionId); - if (details != null) { + public OidcSessionInformation removeSessionInformation(String clientSessionId) { + OidcSessionInformation information = this.sessions.remove(clientSessionId); + if (information != null) { this.logger.trace("Removed client session"); } - return details; + return information; } @Override - public Iterable deregister(OidcLogoutToken token) { + public Iterable removeSessionInformation(OidcLogoutToken token) { List audience = token.getAudience(); String issuer = token.getIssuer().toString(); String subject = token.getSubject(); String providerSessionId = token.getSessionId(); - Predicate matcher = (providerSessionId != null) + Predicate matcher = (providerSessionId != null) ? sessionIdMatcher(audience, issuer, providerSessionId) : subjectMatcher(audience, issuer, subject); if (this.logger.isTraceEnabled()) { String message = "Looking up sessions by issuer [%s] and %s [%s]"; @@ -84,7 +74,7 @@ public Iterable deregister(OidcLogoutToken token) { } } int size = this.sessions.size(); - Set infos = new HashSet<>(); + Set infos = new HashSet<>(); this.sessions.values().removeIf((info) -> { boolean result = matcher.test(info); if (result) { @@ -102,24 +92,31 @@ else if (this.logger.isTraceEnabled()) { return infos; } - private static Predicate sessionIdMatcher(List audience, String issuer, + private static Predicate sessionIdMatcher(List audience, String issuer, String sessionId) { return (session) -> { - String thatRegistrationId = session.getClientId(); + List thatAudience = session.getPrincipal().getAudience(); String thatIssuer = session.getPrincipal().getIssuer().toString(); String thatSessionId = session.getPrincipal().getClaimAsString(LogoutTokenClaimNames.SID); - return audience.contains(thatRegistrationId) && issuer.equals(thatIssuer) + if (thatAudience == null) { + return false; + } + return !Collections.disjoint(audience, thatAudience) && issuer.equals(thatIssuer) && sessionId.equals(thatSessionId); }; } - private static Predicate subjectMatcher(List audience, String issuer, + private static Predicate subjectMatcher(List audience, String issuer, String subject) { return (session) -> { - String thatRegistrationId = session.getClientId(); + List thatAudience = session.getPrincipal().getAudience(); String thatIssuer = session.getPrincipal().getIssuer().toString(); String thatSubject = session.getPrincipal().getSubject(); - return audience.contains(thatRegistrationId) && issuer.equals(thatIssuer) && subject.equals(thatSubject); + if (thatAudience == null) { + return false; + } + return !Collections.disjoint(audience, thatAudience) && issuer.equals(thatIssuer) + && subject.equals(thatSubject); }; } diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/session/OidcSessionInformation.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/session/OidcSessionInformation.java new file mode 100644 index 00000000000..e51ae90b2dc --- /dev/null +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/session/OidcSessionInformation.java @@ -0,0 +1,73 @@ +/* + * Copyright 2002-2023 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.oidc.session; + +import java.util.Collections; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.Map; + +import org.springframework.security.core.session.SessionInformation; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; + +/** + * A {@link SessionInformation} extension that enforces the principal be of type + * {@link OidcUser}. + * + * @author Josh Cummings + * @since 6.2 + */ +public class OidcSessionInformation extends SessionInformation { + + private final Map authorities; + + /** + * Construct an {@link OidcSessionInformation} + * @param sessionId the Client's session id + * @param user the OIDC Provider's session and end user + */ + public OidcSessionInformation(String sessionId, Map authorities, OidcUser user) { + super(user, sessionId, new Date()); + this.authorities = (authorities != null) ? new LinkedHashMap<>(authorities) : Collections.emptyMap(); + } + + /** + * Any headers needed to authorize operations on this session + * @return the {@link Map} of headers + */ + public Map getAuthorities() { + return this.authorities; + } + + /** + * {@inheritDoc} + */ + @Override + public OidcUser getPrincipal() { + return (OidcUser) super.getPrincipal(); + } + + /** + * Copy this {@link OidcSessionInformation}, using a new session identifier + * @param sessionId the new session identifier to use + * @return a new {@link OidcSessionInformation} instance + */ + public OidcSessionInformation withSessionId(String sessionId) { + return new OidcSessionInformation(sessionId, this.authorities, getPrincipal()); + } + +} diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/session/OidcSessionRegistry.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/session/OidcSessionRegistry.java similarity index 65% rename from oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/session/OidcSessionRegistry.java rename to oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/session/OidcSessionRegistry.java index a8c0a793204..26bae499db3 100644 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/session/OidcSessionRegistry.java +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/session/OidcSessionRegistry.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.security.oauth2.client.oidc.authentication.session; +package org.springframework.security.oauth2.client.oidc.session; import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcLogoutToken; @@ -24,7 +24,7 @@ * session or the End User. * * @author Josh Cummings - * @since 6.1 + * @since 6.2 * @see Logout * Token @@ -34,32 +34,26 @@ public interface OidcSessionRegistry { /** * Register a OIDC Provider session with the provided client session. Generally * speaking, the client session should be the session tied to the current login. - * @param details the {@link OidcSessionRegistration} to use + * @param info the {@link OidcSessionInformation} to use */ - void register(OidcSessionRegistration details); - - /** - * Update the entry for a Client when their session id changes. This is handy, for - * example, when the id changes for session fixation protection. - * @param oldClientSessionId the Client's old session id - * @param newClientSessionId the Client's new session id - */ - void register(String oldClientSessionId, String newClientSessionId); + void saveSessionInformation(OidcSessionInformation info); /** * Deregister the OIDC Provider session tied to the provided client session. Generally * speaking, the client session should be the session tied to the current logout. * @param clientSessionId the client session - * @return any found {@link OidcSessionRegistration}, could be {@code null} + * @return any found {@link OidcSessionInformation}, could be {@code null} */ - OidcSessionRegistration deregister(String clientSessionId); + OidcSessionInformation removeSessionInformation(String clientSessionId); /** * Deregister the OIDC Provider sessions referenced by the provided OIDC Logout Token - * by its session id or its subject. + * by its session id or its subject. Note that the issuer and audience should also + * match the corresponding values found in each {@link OidcSessionInformation} + * returned. * @param logoutToken the {@link OidcLogoutToken} - * @return any found {@link OidcSessionRegistration}s, could be empty + * @return any found {@link OidcSessionInformation}s, could be empty */ - Iterable deregister(OidcLogoutToken logoutToken); + Iterable removeSessionInformation(OidcLogoutToken logoutToken); } diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/web/authentication/logout/OidcBackChannelLogoutFilter.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/web/OidcBackChannelLogoutFilter.java similarity index 86% rename from oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/web/authentication/logout/OidcBackChannelLogoutFilter.java rename to oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/web/OidcBackChannelLogoutFilter.java index 324e0229eff..3c49535bf14 100644 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/web/authentication/logout/OidcBackChannelLogoutFilter.java +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/web/OidcBackChannelLogoutFilter.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.security.oauth2.client.oidc.web.authentication.logout; +package org.springframework.security.oauth2.client.oidc.web; import java.io.IOException; @@ -30,13 +30,14 @@ import org.springframework.security.authentication.AuthenticationServiceException; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; -import org.springframework.security.oauth2.client.oidc.authentication.logout.LogoutTokenAuthenticationToken; +import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcLogoutAuthenticationToken; +import org.springframework.security.oauth2.client.oidc.web.logout.OidcBackChannelLogoutHandler; import org.springframework.security.oauth2.client.registration.ClientRegistration; import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; import org.springframework.security.oauth2.core.OAuth2Error; import org.springframework.security.oauth2.core.OAuth2ErrorCodes; import org.springframework.security.oauth2.core.http.converter.OAuth2ErrorHttpMessageConverter; -import org.springframework.security.web.authentication.logout.BackchannelLogoutHandler; import org.springframework.security.web.authentication.logout.LogoutHandler; import org.springframework.security.web.util.matcher.AntPathRequestMatcher; import org.springframework.security.web.util.matcher.RequestMatcher; @@ -66,7 +67,7 @@ public class OidcBackChannelLogoutFilter extends OncePerRequestFilter { private RequestMatcher requestMatcher = new AntPathRequestMatcher(DEFAULT_LOGOUT_URI, "POST"); - private LogoutHandler logoutHandler = new BackchannelLogoutHandler(); + private LogoutHandler logoutHandler = new OidcBackChannelLogoutHandler(); /** * Construct an {@link OidcBackChannelLogoutFilter} @@ -95,8 +96,8 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse return; } String registrationId = result.getVariables().get("registrationId"); - ClientRegistration registration = this.clientRegistrationRepository.findByRegistrationId(registrationId); - if (registration == null) { + ClientRegistration clientRegistration = this.clientRegistrationRepository.findByRegistrationId(registrationId); + if (clientRegistration == null) { this.logger.debug("Did not process OIDC Back-Channel Logout since no ClientRegistration was found"); response.sendError(HttpServletResponse.SC_BAD_REQUEST); return; @@ -107,11 +108,16 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response.sendError(HttpServletResponse.SC_BAD_REQUEST); return; } - LogoutTokenAuthenticationToken token = new LogoutTokenAuthenticationToken(logoutToken, registration); + OidcLogoutAuthenticationToken token = new OidcLogoutAuthenticationToken(logoutToken, clientRegistration); try { Authentication authentication = this.authenticationManager.authenticate(token); this.logoutHandler.logout(request, response, authentication); } + catch (OAuth2AuthenticationException ex) { + this.logger.debug("Failed to process OIDC Back-Channel Logout", ex); + response.setStatus(HttpServletResponse.SC_BAD_REQUEST); + this.errorHttpMessageConverter.write(ex.getError(), null, new ServletServerHttpResponse(response)); + } catch (AuthenticationServiceException ex) { this.logger.debug("Failed to process OIDC Back-Channel Logout", ex); response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, ex.getMessage()); @@ -137,7 +143,7 @@ public void setRequestMatcher(RequestMatcher requestMatcher) { /** * The strategy for expiring all Client sessions indicated by the logout request. - * Defaults to {@link BackchannelLogoutHandler}. + * Defaults to {@link OidcBackChannelLogoutHandler}. * @param logoutHandler the {@link LogoutHandler} to use */ public void setLogoutHandler(LogoutHandler logoutHandler) { diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/web/logout/OidcBackChannelLogoutHandler.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/web/logout/OidcBackChannelLogoutHandler.java new file mode 100644 index 00000000000..0263ae649f9 --- /dev/null +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/web/logout/OidcBackChannelLogoutHandler.java @@ -0,0 +1,147 @@ +/* + * Copyright 2002-2023 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.oidc.web.logout; + +import java.util.Map; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcBackChannelLogoutAuthentication; +import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcLogoutToken; +import org.springframework.security.oauth2.client.oidc.session.InMemoryOidcSessionRegistry; +import org.springframework.security.oauth2.client.oidc.session.OidcSessionInformation; +import org.springframework.security.oauth2.client.oidc.session.OidcSessionRegistry; +import org.springframework.security.web.authentication.logout.LogoutHandler; +import org.springframework.util.Assert; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestOperations; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +/** + * A {@link LogoutHandler} that locates the sessions associated with a given OIDC + * Back-Channel Logout Token and invalidates each one. + * + * @author Josh Cummings + * @since 6.2 + * @see OIDC Back-Channel Logout + * Spec + */ +public final class OidcBackChannelLogoutHandler implements LogoutHandler { + + private final Log logger = LogFactory.getLog(getClass()); + + private OidcSessionRegistry sessionRegistry = new InMemoryOidcSessionRegistry(); + + private RestOperations restOperations = new RestTemplate(); + + private String logoutEndpointName = "/logout"; + + private String sessionCookieName = "JSESSIONID"; + + @Override + public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) { + if (!(authentication instanceof OidcBackChannelLogoutAuthentication token)) { + if (this.logger.isDebugEnabled()) { + String message = "Did not perform OIDC Back-Channel Logout since authentication [%s] was of the wrong type"; + this.logger.debug(String.format(message, authentication.getClass().getSimpleName())); + } + return; + } + Iterable sessions = this.sessionRegistry.removeSessionInformation(token.getPrincipal()); + int totalCount = 0; + int invalidatedCount = 0; + for (OidcSessionInformation session : sessions) { + totalCount++; + try { + eachLogout(request, session); + invalidatedCount++; + } + catch (RestClientException ex) { + this.logger.debug("Failed to invalidate session", ex); + } + } + if (this.logger.isTraceEnabled()) { + this.logger.trace(String.format("Invalidated %d out of %d sessions", invalidatedCount, totalCount)); + } + } + + private void eachLogout(HttpServletRequest request, OidcSessionInformation session) { + HttpHeaders headers = new HttpHeaders(); + headers.add(HttpHeaders.COOKIE, this.sessionCookieName + "=" + session.getSessionId()); + for (Map.Entry credential : session.getAuthorities().entrySet()) { + headers.add(credential.getKey(), credential.getValue()); + } + String url = request.getRequestURL().toString(); + String logout = UriComponentsBuilder.fromHttpUrl(url).replacePath(this.logoutEndpointName).build() + .toUriString(); + HttpEntity entity = new HttpEntity<>(null, headers); + this.restOperations.postForEntity(logout, entity, Object.class); + } + + /** + * Use this {@link OidcSessionRegistry} to identify sessions to invalidate. Note that + * this class uses + * {@link OidcSessionRegistry#removeSessionInformation(OidcLogoutToken)} to identify + * sessions. + * @param sessionRegistry the {@link OidcSessionRegistry} to use + */ + public void setSessionRegistry(OidcSessionRegistry sessionRegistry) { + Assert.notNull(sessionRegistry, "sessionRegistry cannot be null"); + this.sessionRegistry = sessionRegistry; + } + + /** + * Use this {@link RestOperations} to perform the per-session back-channel logout + * @param restOperations the {@link RestOperations} to use + */ + public void setRestOperations(RestOperations restOperations) { + Assert.notNull(restOperations, "restOperations cannot be null"); + this.restOperations = restOperations; + } + + /** + * Use this logout URI for performing per-session logout. Defaults to {@code /logout} + * since that is the default URI for + * {@link org.springframework.security.web.authentication.logout.LogoutFilter}. + * @param logoutUri the URI to use + */ + public void setLogoutUri(String logoutUri) { + Assert.hasText(logoutUri, "logoutUri cannot be empty"); + this.logoutEndpointName = logoutUri; + } + + /** + * Use this cookie name for the session identifier. Defaults to {@code JSESSIONID}. + * + *

+ * Note that if you are using Spring Session, this likely needs to change to SESSION. + * @param sessionCookieName the cookie name to use + */ + public void setSessionCookieName(String sessionCookieName) { + Assert.hasText(sessionCookieName, "clientSessionCookieName cannot be empty"); + this.sessionCookieName = sessionCookieName; + } + +} diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/authentication/logout/OidcBackChannelLogoutTokenValidatorTests.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/authentication/logout/OidcBackChannelLogoutTokenValidatorTests.java new file mode 100644 index 00000000000..c4747837ad4 --- /dev/null +++ b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/authentication/logout/OidcBackChannelLogoutTokenValidatorTests.java @@ -0,0 +1,88 @@ +/* + * Copyright 2002-2023 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.oidc.authentication.logout; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.registration.TestClientRegistrations; +import org.springframework.security.oauth2.jwt.Jwt; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link OidcBackChannelLogoutTokenValidator} + */ +public class OidcBackChannelLogoutTokenValidatorTests { + + // @formatter:off + private final ClientRegistration clientRegistration = TestClientRegistrations + .clientRegistration() + .issuerUri("https://issuer") + .scope("openid").build(); + // @formatter:on + + private final OidcBackChannelLogoutTokenValidator logoutTokenValidator = new OidcBackChannelLogoutTokenValidator( + this.clientRegistration); + + @Test + public void createDecoderWhenTokenValidThenNoErrors() { + Jwt valid = valid(this.clientRegistration).build(); + assertThat(this.logoutTokenValidator.validate(valid).hasErrors()).isFalse(); + } + + @Test + public void createDecoderWhenInvalidAudienceThenErrors() { + Jwt valid = valid(this.clientRegistration).audience(List.of("wrong")).build(); + assertThat(this.logoutTokenValidator.validate(valid).hasErrors()).isTrue(); + } + + @Test + public void createDecoderWhenMissingEventsThenErrors() { + Jwt valid = valid(this.clientRegistration).claims((claims) -> claims.remove(LogoutTokenClaimNames.EVENTS)) + .build(); + assertThat(this.logoutTokenValidator.validate(valid).hasErrors()).isTrue(); + } + + @Test + public void createDecoderWhenInvalidIssuerThenErrors() { + Jwt valid = valid(this.clientRegistration).issuer("https://wrong").build(); + assertThat(this.logoutTokenValidator.validate(valid).hasErrors()).isTrue(); + } + + @Test + public void createDecoderWhenMissingSubjectThenErrors() { + Jwt valid = valid(this.clientRegistration).claims((claims) -> claims.remove(LogoutTokenClaimNames.SUB)).build(); + assertThat(this.logoutTokenValidator.validate(valid).hasErrors()).isTrue(); + } + + @Test + public void createDecoderWhenMissingAudienceThenErrors() { + Jwt valid = valid(this.clientRegistration).claims((claims) -> claims.remove(LogoutTokenClaimNames.AUD)).build(); + assertThat(this.logoutTokenValidator.validate(valid).hasErrors()).isTrue(); + } + + private Jwt.Builder valid(ClientRegistration clientRegistration) { + String issuerUri = clientRegistration.getProviderDetails().getIssuerUri(); + OidcLogoutToken logoutToken = TestOidcLogoutTokens.withSubject(issuerUri, "subject").build(); + return Jwt.withTokenValue(logoutToken.getTokenValue()).header("header", "value") + .claims((claims) -> claims.putAll(logoutToken.getClaims())); + } + +} diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/authentication/logout/OidcLogoutTokenValidatorTests.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/authentication/logout/OidcLogoutTokenValidatorTests.java deleted file mode 100644 index dcff8a917f2..00000000000 --- a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/authentication/logout/OidcLogoutTokenValidatorTests.java +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright 2002-2023 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.oidc.authentication.logout; - -import org.junit.jupiter.api.Test; - -import org.springframework.security.oauth2.client.registration.ClientRegistration; -import org.springframework.security.oauth2.client.registration.TestClientRegistrations; - -import static org.assertj.core.api.Assertions.assertThat; - -public class OidcLogoutTokenValidatorTests { - - // @formatter:off - private ClientRegistration.Builder registration = TestClientRegistrations - .clientRegistration() - .scope("openid"); - // @formatter:on - - @Test - public void createDecoderWhenClientRegistrationValidThenReturnDecoder() { - OidcLogoutTokenValidator validator = new OidcLogoutTokenValidator(this.registration.build()); - assertThat(validator).isNotNull(); - } - -} diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/authentication/session/InMemoryOidcSessionRegistryTests.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/authentication/session/InMemoryOidcSessionRegistryTests.java deleted file mode 100644 index ca495d4181d..00000000000 --- a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/authentication/session/InMemoryOidcSessionRegistryTests.java +++ /dev/null @@ -1,98 +0,0 @@ -/* - * Copyright 2002-2023 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.oidc.authentication.session; - -import org.junit.jupiter.api.Test; - -import org.springframework.security.core.authority.AuthorityUtils; -import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcLogoutToken; -import org.springframework.security.oauth2.client.oidc.authentication.logout.TestOidcLogoutTokens; -import org.springframework.security.oauth2.core.oidc.OidcIdToken; -import org.springframework.security.oauth2.core.oidc.TestOidcIdTokens; -import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser; -import org.springframework.security.oauth2.core.oidc.user.OidcUser; - -import static org.assertj.core.api.Assertions.assertThat; - -public class InMemoryOidcSessionRegistryTests { - - @Test - public void registerWhenDefaultsThenStoresSessionInformation() { - InMemoryOidcSessionRegistry registry = new InMemoryOidcSessionRegistry(); - String sessionId = "client"; - OidcSessionRegistration info = TestOidcSessionRegistrations.create(sessionId); - registry.register(info); - OidcLogoutToken token = TestOidcLogoutTokens.withUser(info.getPrincipal()).build(); - Iterable infos = registry.deregister(token); - assertThat(infos).containsExactly(info); - } - - @Test - public void registerWhenIdTokenHasSessionIdThenStoresSessionInformation() { - InMemoryOidcSessionRegistry registry = new InMemoryOidcSessionRegistry(); - OidcIdToken token = TestOidcIdTokens.idToken().claim("sid", "provider").build(); - OidcUser user = new DefaultOidcUser(AuthorityUtils.NO_AUTHORITIES, token); - OidcSessionRegistration info = TestOidcSessionRegistrations.create("client", user); - registry.register(info); - OidcLogoutToken logoutToken = TestOidcLogoutTokens.withSessionId(token.getIssuer().toString(), "provider") - .build(); - Iterable infos = registry.deregister(logoutToken); - assertThat(infos).containsExactly(info); - } - - @Test - public void unregisterWhenMultipleSessionsThenRemovesAllMatching() { - InMemoryOidcSessionRegistry registry = new InMemoryOidcSessionRegistry(); - OidcIdToken token = TestOidcIdTokens.idToken().claim("sid", "providerOne").subject("otheruser").build(); - OidcUser user = new DefaultOidcUser(AuthorityUtils.NO_AUTHORITIES, token); - OidcSessionRegistration one = TestOidcSessionRegistrations.create("clientOne", user); - registry.register(one); - token = TestOidcIdTokens.idToken().claim("sid", "providerTwo").build(); - user = new DefaultOidcUser(AuthorityUtils.NO_AUTHORITIES, token); - OidcSessionRegistration two = TestOidcSessionRegistrations.create("clientTwo", user); - registry.register(two); - token = TestOidcIdTokens.idToken().claim("sid", "providerThree").build(); - user = new DefaultOidcUser(AuthorityUtils.NO_AUTHORITIES, token); - OidcSessionRegistration three = TestOidcSessionRegistrations.create("clientThree", user); - registry.register(three); - OidcLogoutToken logoutToken = TestOidcLogoutTokens.withSubject(token.getIssuer().toString(), token.getSubject()) - .build(); - Iterable infos = registry.deregister(logoutToken); - assertThat(infos).containsExactlyInAnyOrder(two, three); - logoutToken = TestOidcLogoutTokens.withSubject(token.getIssuer().toString(), "otheruser").build(); - infos = registry.deregister(logoutToken); - assertThat(infos).containsExactly(one); - } - - @Test - public void unregisterWhenNoSessionsThenEmptyList() { - InMemoryOidcSessionRegistry registry = new InMemoryOidcSessionRegistry(); - OidcIdToken token = TestOidcIdTokens.idToken().claim("sid", "provider").build(); - OidcUser user = new DefaultOidcUser(AuthorityUtils.NO_AUTHORITIES, token); - OidcSessionRegistration registration = TestOidcSessionRegistrations.create("client", user); - registry.register(registration); - OidcLogoutToken logoutToken = TestOidcLogoutTokens.withSessionId(token.getIssuer().toString(), "wrong").build(); - Iterable infos = registry.deregister(logoutToken); - assertThat(infos).isNotNull(); - assertThat(infos).isEmpty(); - logoutToken = TestOidcLogoutTokens.withSessionId("https://wrong", "provider").build(); - infos = registry.deregister(logoutToken); - assertThat(infos).isNotNull(); - assertThat(infos).isEmpty(); - } - -} diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/session/InMemoryOidcSessionRegistryTests.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/session/InMemoryOidcSessionRegistryTests.java new file mode 100644 index 00000000000..861eccce7ea --- /dev/null +++ b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/session/InMemoryOidcSessionRegistryTests.java @@ -0,0 +1,102 @@ +/* + * Copyright 2002-2023 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.oidc.session; + +import org.junit.jupiter.api.Test; + +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcLogoutToken; +import org.springframework.security.oauth2.client.oidc.authentication.logout.TestOidcLogoutTokens; +import org.springframework.security.oauth2.core.oidc.OidcIdToken; +import org.springframework.security.oauth2.core.oidc.TestOidcIdTokens; +import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link InMemoryOidcSessionRegistry} + */ +public class InMemoryOidcSessionRegistryTests { + + @Test + public void registerWhenDefaultsThenStoresSessionInformation() { + InMemoryOidcSessionRegistry sessionRegistry = new InMemoryOidcSessionRegistry(); + String sessionId = "client"; + OidcSessionInformation info = TestOidcSessionInformations.create(sessionId); + sessionRegistry.saveSessionInformation(info); + OidcLogoutToken logoutToken = TestOidcLogoutTokens.withUser(info.getPrincipal()).build(); + Iterable infos = sessionRegistry.removeSessionInformation(logoutToken); + assertThat(infos).containsExactly(info); + } + + @Test + public void registerWhenIdTokenHasSessionIdThenStoresSessionInformation() { + InMemoryOidcSessionRegistry sessionRegistry = new InMemoryOidcSessionRegistry(); + OidcIdToken idToken = TestOidcIdTokens.idToken().claim("sid", "provider").build(); + OidcUser user = new DefaultOidcUser(AuthorityUtils.NO_AUTHORITIES, idToken); + OidcSessionInformation info = TestOidcSessionInformations.create("client", user); + sessionRegistry.saveSessionInformation(info); + OidcLogoutToken logoutToken = TestOidcLogoutTokens.withSessionId(idToken.getIssuer().toString(), "provider") + .build(); + Iterable infos = sessionRegistry.removeSessionInformation(logoutToken); + assertThat(infos).containsExactly(info); + } + + @Test + public void unregisterWhenMultipleSessionsThenRemovesAllMatching() { + InMemoryOidcSessionRegistry sessionRegistry = new InMemoryOidcSessionRegistry(); + OidcIdToken idToken = TestOidcIdTokens.idToken().claim("sid", "providerOne").subject("otheruser").build(); + OidcUser user = new DefaultOidcUser(AuthorityUtils.NO_AUTHORITIES, idToken); + OidcSessionInformation oneSession = TestOidcSessionInformations.create("clientOne", user); + sessionRegistry.saveSessionInformation(oneSession); + idToken = TestOidcIdTokens.idToken().claim("sid", "providerTwo").build(); + user = new DefaultOidcUser(AuthorityUtils.NO_AUTHORITIES, idToken); + OidcSessionInformation twoSession = TestOidcSessionInformations.create("clientTwo", user); + sessionRegistry.saveSessionInformation(twoSession); + idToken = TestOidcIdTokens.idToken().claim("sid", "providerThree").build(); + user = new DefaultOidcUser(AuthorityUtils.NO_AUTHORITIES, idToken); + OidcSessionInformation threeSession = TestOidcSessionInformations.create("clientThree", user); + sessionRegistry.saveSessionInformation(threeSession); + OidcLogoutToken logoutToken = TestOidcLogoutTokens + .withSubject(idToken.getIssuer().toString(), idToken.getSubject()).build(); + Iterable infos = sessionRegistry.removeSessionInformation(logoutToken); + assertThat(infos).containsExactlyInAnyOrder(twoSession, threeSession); + logoutToken = TestOidcLogoutTokens.withSubject(idToken.getIssuer().toString(), "otheruser").build(); + infos = sessionRegistry.removeSessionInformation(logoutToken); + assertThat(infos).containsExactly(oneSession); + } + + @Test + public void unregisterWhenNoSessionsThenEmptyList() { + InMemoryOidcSessionRegistry sessionRegistry = new InMemoryOidcSessionRegistry(); + OidcIdToken idToken = TestOidcIdTokens.idToken().claim("sid", "provider").build(); + OidcUser user = new DefaultOidcUser(AuthorityUtils.NO_AUTHORITIES, idToken); + OidcSessionInformation info = TestOidcSessionInformations.create("client", user); + sessionRegistry.saveSessionInformation(info); + OidcLogoutToken logoutToken = TestOidcLogoutTokens.withSessionId(idToken.getIssuer().toString(), "wrong") + .build(); + Iterable infos = sessionRegistry.removeSessionInformation(logoutToken); + assertThat(infos).isNotNull(); + assertThat(infos).isEmpty(); + logoutToken = TestOidcLogoutTokens.withSessionId("https://wrong", "provider").build(); + infos = sessionRegistry.removeSessionInformation(logoutToken); + assertThat(infos).isNotNull(); + assertThat(infos).isEmpty(); + } + +} diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/authentication/session/TestOidcSessionRegistrations.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/session/TestOidcSessionInformations.java similarity index 64% rename from oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/authentication/session/TestOidcSessionRegistrations.java rename to oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/session/TestOidcSessionInformations.java index 3beb9b9bc5d..47f64868de1 100644 --- a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/authentication/session/TestOidcSessionRegistrations.java +++ b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/session/TestOidcSessionInformations.java @@ -14,28 +14,31 @@ * limitations under the License. */ -package org.springframework.security.oauth2.client.oidc.authentication.session; +package org.springframework.security.oauth2.client.oidc.session; import java.util.Map; import org.springframework.security.oauth2.core.oidc.user.OidcUser; import org.springframework.security.oauth2.core.oidc.user.TestOidcUsers; -public final class TestOidcSessionRegistrations { +/** + * Sample {@link OidcSessionInformation} instances + */ +public final class TestOidcSessionInformations { - public static OidcSessionRegistration create() { + public static OidcSessionInformation create() { return create("sessionId"); } - public static OidcSessionRegistration create(String sessionId) { + public static OidcSessionInformation create(String sessionId) { return create(sessionId, TestOidcUsers.create()); } - public static OidcSessionRegistration create(String sessionId, OidcUser user) { - return new OidcSessionRegistration("client-id", sessionId, Map.of("_csrf", "token"), user); + public static OidcSessionInformation create(String sessionId, OidcUser user) { + return new OidcSessionInformation(sessionId, Map.of("_csrf", "token"), user); } - private TestOidcSessionRegistrations() { + private TestOidcSessionInformations() { } diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/web/authentication/logout/OidcBackChannelLogoutFilterTests.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/web/OidcBackChannelLogoutFilterTests.java similarity index 53% rename from oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/web/authentication/logout/OidcBackChannelLogoutFilterTests.java rename to oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/web/OidcBackChannelLogoutFilterTests.java index 0da06be9f2f..5d87f8baeb4 100644 --- a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/web/authentication/logout/OidcBackChannelLogoutFilterTests.java +++ b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/web/OidcBackChannelLogoutFilterTests.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.security.oauth2.client.oidc.web.authentication.logout; +package org.springframework.security.oauth2.client.oidc.web; import java.util.Set; @@ -25,14 +25,16 @@ import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcBackChannelLogoutAuthentication; import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcLogoutToken; import org.springframework.security.oauth2.client.oidc.authentication.logout.TestOidcLogoutTokens; -import org.springframework.security.oauth2.client.oidc.authentication.session.OidcSessionRegistration; -import org.springframework.security.oauth2.client.oidc.authentication.session.TestOidcSessionRegistrations; +import org.springframework.security.oauth2.client.oidc.session.OidcSessionInformation; +import org.springframework.security.oauth2.client.oidc.session.OidcSessionRegistry; +import org.springframework.security.oauth2.client.oidc.session.TestOidcSessionInformations; +import org.springframework.security.oauth2.client.oidc.web.logout.OidcBackChannelLogoutHandler; import org.springframework.security.oauth2.client.registration.ClientRegistration; import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; import org.springframework.security.oauth2.client.registration.TestClientRegistrations; -import org.springframework.security.web.authentication.logout.BackchannelLogoutAuthentication; import org.springframework.security.web.authentication.logout.LogoutHandler; import static org.assertj.core.api.Assertions.assertThat; @@ -46,91 +48,99 @@ public class OidcBackChannelLogoutFilterTests { @Test public void doFilterRequestDoesNotMatchThenDoesNotRun() throws Exception { - ClientRegistrationRepository clients = mock(ClientRegistrationRepository.class); - AuthenticationManager factory = mock(AuthenticationManager.class); - OidcBackChannelLogoutFilter filter = new OidcBackChannelLogoutFilter(clients, factory); + ClientRegistrationRepository clientRegistrationRepository = mock(ClientRegistrationRepository.class); + AuthenticationManager authenticationManager = mock(AuthenticationManager.class); + OidcBackChannelLogoutFilter backChannelLogoutFilter = new OidcBackChannelLogoutFilter( + clientRegistrationRepository, authenticationManager); MockHttpServletRequest request = new MockHttpServletRequest(); MockHttpServletResponse response = new MockHttpServletResponse(); FilterChain chain = mock(FilterChain.class); - filter.doFilter(request, response, chain); - verifyNoInteractions(clients, factory); + backChannelLogoutFilter.doFilter(request, response, chain); + verifyNoInteractions(clientRegistrationRepository, authenticationManager); verify(chain).doFilter(request, response); } @Test public void doFilterRequestDoesNotMatchContainLogoutTokenThenBadRequest() throws Exception { - ClientRegistration registration = TestClientRegistrations.clientRegistration().build(); - ClientRegistrationRepository clients = mock(ClientRegistrationRepository.class); - given(clients.findByRegistrationId(any())).willReturn(registration); - AuthenticationManager factory = mock(AuthenticationManager.class); - OidcBackChannelLogoutFilter filter = new OidcBackChannelLogoutFilter(clients, factory); + ClientRegistration clientRegistration = TestClientRegistrations.clientRegistration().build(); + ClientRegistrationRepository clientRegistrationRepository = mock(ClientRegistrationRepository.class); + given(clientRegistrationRepository.findByRegistrationId(any())).willReturn(clientRegistration); + AuthenticationManager authenticationManager = mock(AuthenticationManager.class); + OidcBackChannelLogoutFilter filter = new OidcBackChannelLogoutFilter(clientRegistrationRepository, + authenticationManager); MockHttpServletRequest request = new MockHttpServletRequest("POST", "/logout/connect/back-channel/id"); request.setServletPath("/logout/connect/back-channel/id"); MockHttpServletResponse response = new MockHttpServletResponse(); FilterChain chain = mock(FilterChain.class); filter.doFilter(request, response, chain); - verifyNoInteractions(factory, chain); + verifyNoInteractions(authenticationManager, chain); assertThat(response.getStatus()).isEqualTo(400); } @Test public void doFilterWithNoMatchingClientThenBadRequest() throws Exception { - ClientRegistrationRepository clients = mock(ClientRegistrationRepository.class); - AuthenticationManager factory = mock(AuthenticationManager.class); - OidcBackChannelLogoutFilter filter = new OidcBackChannelLogoutFilter(clients, factory); + ClientRegistrationRepository clientRegistrationRepository = mock(ClientRegistrationRepository.class); + AuthenticationManager authenticationManager = mock(AuthenticationManager.class); + OidcBackChannelLogoutFilter backChannelLogoutFilter = new OidcBackChannelLogoutFilter( + clientRegistrationRepository, authenticationManager); MockHttpServletRequest request = new MockHttpServletRequest("POST", "/logout/connect/back-channel/id"); request.setServletPath("/logout/connect/back-channel/id"); request.setParameter("logout_token", "logout_token"); MockHttpServletResponse response = new MockHttpServletResponse(); FilterChain chain = mock(FilterChain.class); - filter.doFilter(request, response, chain); - verify(clients).findByRegistrationId("id"); - verifyNoInteractions(factory, chain); + backChannelLogoutFilter.doFilter(request, response, chain); + verify(clientRegistrationRepository).findByRegistrationId("id"); + verifyNoInteractions(authenticationManager, chain); assertThat(response.getStatus()).isEqualTo(400); } @Test public void doFilterWithSessionMatchingLogoutTokenThenInvalidates() throws Exception { - ClientRegistration registration = TestClientRegistrations.clientRegistration().build(); - ClientRegistrationRepository clients = mock(ClientRegistrationRepository.class); - given(clients.findByRegistrationId(any())).willReturn(registration); - AuthenticationManager factory = mock(AuthenticationManager.class); + ClientRegistration clientRegistration = TestClientRegistrations.clientRegistration().build(); + ClientRegistrationRepository clientRegistrationRepository = mock(ClientRegistrationRepository.class); + given(clientRegistrationRepository.findByRegistrationId(any())).willReturn(clientRegistration); + AuthenticationManager authenticationManager = mock(AuthenticationManager.class); OidcLogoutToken token = TestOidcLogoutTokens.withSessionId("issuer", "provider").build(); - Iterable infos = Set.of(TestOidcSessionRegistrations.create("clientOne"), - TestOidcSessionRegistrations.create("clientTwo")); - given(factory.authenticate(any())).willReturn(new BackchannelLogoutAuthentication(token, token, infos)); - LogoutHandler logoutHandler = mock(LogoutHandler.class); - OidcBackChannelLogoutFilter filter = new OidcBackChannelLogoutFilter(clients, factory); - filter.setLogoutHandler(logoutHandler); + Iterable infos = Set.of(TestOidcSessionInformations.create("clientOne"), + TestOidcSessionInformations.create("clientTwo")); + given(authenticationManager.authenticate(any())).willReturn(new OidcBackChannelLogoutAuthentication(token)); + OidcBackChannelLogoutHandler backChannelLogoutHandler = new OidcBackChannelLogoutHandler(); + OidcSessionRegistry sessionRegistry = mock(OidcSessionRegistry.class); + given(sessionRegistry.removeSessionInformation(any(OidcLogoutToken.class))).willReturn(infos); + backChannelLogoutHandler.setSessionRegistry(sessionRegistry); + OidcBackChannelLogoutFilter filter = new OidcBackChannelLogoutFilter(clientRegistrationRepository, + authenticationManager); + filter.setLogoutHandler(backChannelLogoutHandler); MockHttpServletRequest request = new MockHttpServletRequest("POST", - "/oauth2/" + registration.getRegistrationId() + "/logout"); + "/oauth2/" + clientRegistration.getRegistrationId() + "/logout"); request.setServletPath("/logout/connect/back-channel/id"); request.setParameter("logout_token", "logout_token"); MockHttpServletResponse response = new MockHttpServletResponse(); FilterChain chain = mock(FilterChain.class); filter.doFilter(request, response, chain); - verify(logoutHandler).logout(any(), any(), any()); + verify(sessionRegistry).removeSessionInformation(token); verifyNoInteractions(chain); assertThat(response.getStatus()).isEqualTo(200); } @Test public void doFilterWhenInvalidJwtThenBadRequest() throws Exception { - ClientRegistration registration = TestClientRegistrations.clientRegistration().build(); - ClientRegistrationRepository clients = mock(ClientRegistrationRepository.class); - given(clients.findByRegistrationId(any())).willReturn(registration); - AuthenticationManager factory = mock(AuthenticationManager.class); - given(factory.authenticate(any())).willThrow(new BadCredentialsException("bad")); + ClientRegistration clientRegistration = TestClientRegistrations.clientRegistration().build(); + ClientRegistrationRepository clientRegistrationRepository = mock(ClientRegistrationRepository.class); + given(clientRegistrationRepository.findByRegistrationId(any())).willReturn(clientRegistration); + AuthenticationManager authenticationManager = mock(AuthenticationManager.class); + given(authenticationManager.authenticate(any())).willThrow(new BadCredentialsException("bad")); LogoutHandler logoutHandler = mock(LogoutHandler.class); - OidcBackChannelLogoutFilter filter = new OidcBackChannelLogoutFilter(clients, factory); - filter.setLogoutHandler(logoutHandler); + OidcBackChannelLogoutFilter backChannelLogoutFilter = new OidcBackChannelLogoutFilter( + clientRegistrationRepository, authenticationManager); + backChannelLogoutFilter.setLogoutHandler(logoutHandler); MockHttpServletRequest request = new MockHttpServletRequest("POST", - "/oauth2/" + registration.getRegistrationId() + "/logout"); + "/oauth2/" + clientRegistration.getRegistrationId() + "/logout"); request.setServletPath("/logout/connect/back-channel/id"); request.setParameter("logout_token", "logout_token"); MockHttpServletResponse response = new MockHttpServletResponse(); FilterChain chain = mock(FilterChain.class); - filter.doFilter(request, response, chain); + backChannelLogoutFilter.doFilter(request, response, chain); verifyNoInteractions(logoutHandler, chain); assertThat(response.getStatus()).isEqualTo(400); assertThat(response.getContentAsString()).contains("bad"); diff --git a/oauth2/oauth2-core/src/test/java/org/springframework/security/oauth2/core/oidc/TestOidcIdTokens.java b/oauth2/oauth2-core/src/test/java/org/springframework/security/oauth2/core/oidc/TestOidcIdTokens.java index ca859473d1e..2271a52e00f 100644 --- a/oauth2/oauth2-core/src/test/java/org/springframework/security/oauth2/core/oidc/TestOidcIdTokens.java +++ b/oauth2/oauth2-core/src/test/java/org/springframework/security/oauth2/core/oidc/TestOidcIdTokens.java @@ -17,6 +17,7 @@ package org.springframework.security.oauth2.core.oidc; import java.time.Instant; +import java.util.List; /** * Test {@link OidcIdToken}s @@ -32,6 +33,7 @@ public static OidcIdToken.Builder idToken() { // @formatter:off return OidcIdToken.withTokenValue("id-token") .issuer("https://example.com") + .audience(List.of("client-id")) .subject("subject") .issuedAt(Instant.now()) .expiresAt(Instant.now() diff --git a/oauth2/oauth2-core/src/test/java/org/springframework/security/oauth2/core/oidc/user/TestOidcUsers.java b/oauth2/oauth2-core/src/test/java/org/springframework/security/oauth2/core/oidc/user/TestOidcUsers.java index 3bda7ec32d7..ca2c37abf78 100644 --- a/oauth2/oauth2-core/src/test/java/org/springframework/security/oauth2/core/oidc/user/TestOidcUsers.java +++ b/oauth2/oauth2-core/src/test/java/org/springframework/security/oauth2/core/oidc/user/TestOidcUsers.java @@ -50,7 +50,7 @@ private static OidcIdToken idToken() { .expiresAt(expiresAt) .subject("subject") .issuer("http://localhost/issuer") - .audience(Collections.unmodifiableSet(new LinkedHashSet<>(Collections.singletonList("client")))) + .audience(Collections.unmodifiableSet(new LinkedHashSet<>(Collections.singletonList("client-id")))) .authorizedParty("client") .build(); // @formatter:on diff --git a/web/src/main/java/org/springframework/security/web/authentication/logout/BackchannelLogoutAuthentication.java b/web/src/main/java/org/springframework/security/web/authentication/logout/BackchannelLogoutAuthentication.java deleted file mode 100644 index c61488bddb7..00000000000 --- a/web/src/main/java/org/springframework/security/web/authentication/logout/BackchannelLogoutAuthentication.java +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Copyright 2002-2023 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.web.authentication.logout; - -import java.util.Collections; - -import org.springframework.security.authentication.AbstractAuthenticationToken; -import org.springframework.security.core.session.SessionInformation; -import org.springframework.util.Assert; - -public class BackchannelLogoutAuthentication extends AbstractAuthenticationToken { - - private final Object principal; - - private final Object credentials; - - private final Iterable sessions; - - public BackchannelLogoutAuthentication(Object principal, Object credentials, - Iterable sessions) { - super(Collections.emptyList()); - Assert.notNull(sessions, "sessions cannot be null"); - this.sessions = sessions; - this.principal = principal; - this.credentials = credentials; - setAuthenticated(true); - } - - @Override - public Object getPrincipal() { - return this.principal; - } - - @Override - public Object getCredentials() { - return this.credentials; - } - - public Iterable getSessions() { - return this.sessions; - } - -} diff --git a/web/src/main/java/org/springframework/security/web/authentication/logout/BackchannelLogoutHandler.java b/web/src/main/java/org/springframework/security/web/authentication/logout/BackchannelLogoutHandler.java deleted file mode 100644 index 2e16a339e67..00000000000 --- a/web/src/main/java/org/springframework/security/web/authentication/logout/BackchannelLogoutHandler.java +++ /dev/null @@ -1,97 +0,0 @@ -/* - * Copyright 2002-2023 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.web.authentication.logout; - -import java.util.Map; - -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import org.apache.commons.logging.Log; -import org.apache.commons.logging.LogFactory; - -import org.springframework.http.HttpEntity; -import org.springframework.http.HttpHeaders; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.session.SessionInformation; -import org.springframework.util.Assert; -import org.springframework.web.client.RestClientException; -import org.springframework.web.client.RestOperations; -import org.springframework.web.client.RestTemplate; -import org.springframework.web.util.UriComponentsBuilder; - -public final class BackchannelLogoutHandler implements LogoutHandler { - - private final Log logger = LogFactory.getLog(getClass()); - - private RestOperations rest = new RestTemplate(); - - private String logoutEndpointName = "/logout"; - - private String clientSessionCookieName = "JSESSIONID"; - - @Override - public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) { - if (!(authentication instanceof BackchannelLogoutAuthentication token)) { - if (this.logger.isDebugEnabled()) { - String message = "Did not perform Backchannel Logout since authentication [%s] was of the wrong type"; - this.logger.debug(String.format(message, authentication.getClass().getSimpleName())); - } - return; - } - Iterable sessions = token.getSessions(); - for (SessionInformation session : sessions) { - eachLogout(request, session); - } - } - - private void eachLogout(HttpServletRequest request, SessionInformation session) { - HttpHeaders headers = new HttpHeaders(); - headers.add(HttpHeaders.COOKIE, this.clientSessionCookieName + "=" + session.getSessionId()); - for (Map.Entry credential : session.getHeaders().entrySet()) { - headers.add(credential.getKey(), credential.getValue()); - } - String url = request.getRequestURL().toString(); - String logout = UriComponentsBuilder.fromHttpUrl(url).replacePath(this.logoutEndpointName).build() - .toUriString(); - HttpEntity entity = new HttpEntity<>(null, headers); - try { - this.rest.postForEntity(logout, entity, Object.class); - if (this.logger.isTraceEnabled()) { - this.logger.trace("Invalidated session"); - } - } - catch (RestClientException ex) { - this.logger.debug("Failed to invalidate session", ex); - } - } - - public void setRestOperations(RestOperations rest) { - Assert.notNull(rest, "rest cannot be null"); - this.rest = rest; - } - - public void setLogoutEndpointName(String logoutEndpointName) { - Assert.hasText(logoutEndpointName, "logoutEndpointName cannot be empty"); - this.logoutEndpointName = logoutEndpointName; - } - - public void setClientSessionCookieName(String clientSessionCookieName) { - Assert.hasText(clientSessionCookieName, "clientSessionCookieName cannot be empty"); - this.clientSessionCookieName = clientSessionCookieName; - } - -} From 3e140228087edfbdaf85861d1068fe6611331b2c Mon Sep 17 00:00:00 2001 From: Josh Cummings Date: Tue, 25 Jul 2023 17:44:48 -0600 Subject: [PATCH 08/12] Move OidcSessionRegistry Login Configuration to oauth2Login --- .../client/OAuth2ClientConfigurerUtils.java | 11 ++ .../oauth2/client/OAuth2LoginConfigurer.java | 135 ++++++++++++++ .../oauth2/client/OidcLogoutConfigurer.java | 164 +----------------- .../client/OidcLogoutConfigurerTests.java | 3 +- 4 files changed, 150 insertions(+), 163 deletions(-) diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OAuth2ClientConfigurerUtils.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OAuth2ClientConfigurerUtils.java index 0f1dc7ab8f0..7b4df033357 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OAuth2ClientConfigurerUtils.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OAuth2ClientConfigurerUtils.java @@ -25,6 +25,8 @@ import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.oauth2.client.InMemoryOAuth2AuthorizedClientService; import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService; +import org.springframework.security.oauth2.client.oidc.session.InMemoryOidcSessionRegistry; +import org.springframework.security.oauth2.client.oidc.session.OidcSessionRegistry; import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; import org.springframework.security.oauth2.client.web.AuthenticatedPrincipalOAuth2AuthorizedClientRepository; import org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepository; @@ -112,4 +114,13 @@ private static > OAuth2AuthorizedClientService return (!authorizedClientServiceMap.isEmpty() ? authorizedClientServiceMap.values().iterator().next() : null); } + static > OidcSessionRegistry getOidcSessionRegistry(B builder) { + OidcSessionRegistry sessionRegistry = builder.getSharedObject(OidcSessionRegistry.class); + if (sessionRegistry == null) { + sessionRegistry = new InMemoryOidcSessionRegistry(); + builder.setSharedObject(OidcSessionRegistry.class, sessionRegistry); + } + return sessionRegistry; + } + } diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OAuth2LoginConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OAuth2LoginConfigurer.java index b7a2ccc61fa..c50813f03b2 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OAuth2LoginConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OAuth2LoginConfigurer.java @@ -22,9 +22,18 @@ import java.util.LinkedHashMap; import java.util.Map; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + import org.springframework.beans.factory.BeanFactoryUtils; import org.springframework.beans.factory.NoUniqueBeanDefinitionException; import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationListener; +import org.springframework.context.event.GenericApplicationListenerAdapter; +import org.springframework.context.event.SmartApplicationListener; import org.springframework.core.ResolvableType; import org.springframework.security.authentication.AuthenticationProvider; import org.springframework.security.config.Customizer; @@ -32,9 +41,14 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractAuthenticationFilterConfigurer; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.annotation.web.configurers.SessionManagementConfigurer; +import org.springframework.security.context.DelegatingApplicationListener; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper; +import org.springframework.security.core.session.AbstractSessionEvent; +import org.springframework.security.core.session.SessionDestroyedEvent; +import org.springframework.security.core.session.SessionIdChangedEvent; import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService; import org.springframework.security.oauth2.client.authentication.OAuth2LoginAuthenticationProvider; import org.springframework.security.oauth2.client.authentication.OAuth2LoginAuthenticationToken; @@ -42,6 +56,9 @@ import org.springframework.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient; import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest; import org.springframework.security.oauth2.client.oidc.authentication.OidcAuthorizationCodeAuthenticationProvider; +import org.springframework.security.oauth2.client.oidc.session.InMemoryOidcSessionRegistry; +import org.springframework.security.oauth2.client.oidc.session.OidcSessionInformation; +import org.springframework.security.oauth2.client.oidc.session.OidcSessionRegistry; import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest; import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserService; import org.springframework.security.oauth2.client.registration.ClientRegistration; @@ -67,7 +84,10 @@ import org.springframework.security.web.RedirectStrategy; import org.springframework.security.web.authentication.DelegatingAuthenticationEntryPoint; import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint; +import org.springframework.security.web.authentication.session.SessionAuthenticationException; +import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy; import org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter; +import org.springframework.security.web.csrf.CsrfToken; import org.springframework.security.web.savedrequest.RequestCache; import org.springframework.security.web.util.matcher.AndRequestMatcher; import org.springframework.security.web.util.matcher.AntPathRequestMatcher; @@ -124,6 +144,7 @@ *

  • {@link DefaultLoginPageGeneratingFilter} - if {@link #loginPage(String)} is not * configured and {@code DefaultLoginPageGeneratingFilter} is available, then a default * login page will be made available
  • + *
  • {@link OidcSessionRegistry}
  • * * * @author Joe Grandja @@ -202,6 +223,17 @@ public OAuth2LoginConfigurer loginProcessingUrl(String loginProcessingUrl) { return this; } + /** + * Sets the registry for managing the OIDC client-provider session link + * @param sessionRegistry the {@link OidcSessionRegistry} to use + * @return the {@link OAuth2LoginConfigurer} for further configuration + */ + public OAuth2LoginConfigurer oidcSessionRegistry(OidcSessionRegistry sessionRegistry) { + Assert.notNull(sessionRegistry, "sessionRegistry cannot be null"); + this.getBuilder().setSharedObject(OidcSessionRegistry.class, sessionRegistry); + return this; + } + /** * Returns the {@link AuthorizationEndpointConfig} for configuring the Authorization * Server's Authorization Endpoint. @@ -400,6 +432,7 @@ public void configure(B http) throws Exception { authenticationFilter .setAuthorizationRequestRepository(this.authorizationEndpointConfig.authorizationRequestRepository); } + configureOidcSessionRegistry(http); super.configure(http); } @@ -539,6 +572,29 @@ private RequestMatcher getFormLoginNotEnabledRequestMatcher(B http) { return AnyRequestMatcher.INSTANCE; } + private void configureOidcSessionRegistry(B http) { + OidcSessionRegistry sessionRegistry = OAuth2ClientConfigurerUtils.getOidcSessionRegistry(http); + SessionManagementConfigurer sessionConfigurer = http.getConfigurer(SessionManagementConfigurer.class); + if (sessionConfigurer != null) { + OidcSessionRegistryAuthenticationStrategy sessionAuthenticationStrategy = new OidcSessionRegistryAuthenticationStrategy(); + sessionAuthenticationStrategy.setSessionRegistry(sessionRegistry); + sessionConfigurer.addSessionAuthenticationStrategy(sessionAuthenticationStrategy); + } + OidcClientSessionEventListener listener = new OidcClientSessionEventListener(); + listener.setSessionRegistry(sessionRegistry); + registerDelegateApplicationListener(listener); + } + + private void registerDelegateApplicationListener(ApplicationListener delegate) { + DelegatingApplicationListener delegating = getBeanOrNull( + ResolvableType.forType(DelegatingApplicationListener.class)); + if (delegating == null) { + return; + } + SmartApplicationListener smartListener = new GenericApplicationListenerAdapter(delegate); + delegating.addListener(smartListener); + } + /** * Configuration options for the Authorization Server's Authorization Endpoint. */ @@ -786,4 +842,83 @@ public boolean supports(Class authentication) { } + private static final class OidcClientSessionEventListener implements ApplicationListener { + + private final Log logger = LogFactory.getLog(OidcClientSessionEventListener.class); + + private OidcSessionRegistry sessionRegistry = new InMemoryOidcSessionRegistry(); + + /** + * {@inheritDoc} + */ + @Override + public void onApplicationEvent(AbstractSessionEvent event) { + if (event instanceof SessionDestroyedEvent destroyed) { + this.logger.debug("Received SessionDestroyedEvent"); + this.sessionRegistry.removeSessionInformation(destroyed.getId()); + return; + } + if (event instanceof SessionIdChangedEvent changed) { + this.logger.debug("Received SessionIdChangedEvent"); + OidcSessionInformation information = this.sessionRegistry.removeSessionInformation(changed.getOldSessionId()); + if (information == null) { + this.logger.debug("Failed to register new session id since old session id was not found in registry"); + return; + } + this.sessionRegistry.saveSessionInformation(information.withSessionId(changed.getNewSessionId())); + } + } + + /** + * The registry where OIDC Provider sessions are linked to the Client session. + * Defaults to in-memory storage. + * @param sessionRegistry the {@link OidcSessionRegistry} to use + */ + void setSessionRegistry(OidcSessionRegistry sessionRegistry) { + Assert.notNull(sessionRegistry, "sessionRegistry cannot be null"); + this.sessionRegistry = sessionRegistry; + } + + } + + private static final class OidcSessionRegistryAuthenticationStrategy implements SessionAuthenticationStrategy { + + private final Log logger = LogFactory.getLog(getClass()); + + private OidcSessionRegistry sessionRegistry = new InMemoryOidcSessionRegistry(); + + /** + * {@inheritDoc} + */ + @Override + public void onAuthentication(Authentication authentication, HttpServletRequest request, HttpServletResponse response) throws SessionAuthenticationException { + HttpSession session = request.getSession(false); + if (session == null) { + return; + } + if (!(authentication.getPrincipal() instanceof OidcUser user)) { + return; + } + String sessionId = session.getId(); + CsrfToken csrfToken = (CsrfToken) request.getAttribute(CsrfToken.class.getName()); + Map headers = (csrfToken != null) ? Map.of(csrfToken.getHeaderName(), csrfToken.getToken()) : Collections.emptyMap(); + OidcSessionInformation registration = new OidcSessionInformation(sessionId, headers, user); + if (this.logger.isTraceEnabled()) { + this.logger.trace(String.format("Linking a provider [%s] session to this client's session", user.getIssuer())); + } + this.sessionRegistry.saveSessionInformation(registration); + } + + /** + * The registration for linking OIDC Provider Session information to the Client's + * session. Defaults to in-memory storage. + * @param sessionRegistry the {@link OidcSessionRegistry} to use + */ + void setSessionRegistry(OidcSessionRegistry sessionRegistry) { + Assert.notNull(sessionRegistry, "sessionRegistry cannot be null"); + this.sessionRegistry = sessionRegistry; + } + + } + } diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurer.java index 39c50265f08..b8f0c486bbd 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurer.java @@ -16,46 +16,20 @@ package org.springframework.security.config.annotation.web.configurers.oauth2.client; -import java.util.Collections; -import java.util.Map; import java.util.function.Consumer; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import jakarta.servlet.http.HttpSession; -import org.apache.commons.logging.Log; -import org.apache.commons.logging.LogFactory; - -import org.springframework.beans.factory.NoSuchBeanDefinitionException; -import org.springframework.context.ApplicationContext; -import org.springframework.context.ApplicationListener; -import org.springframework.context.event.GenericApplicationListenerAdapter; -import org.springframework.context.event.SmartApplicationListener; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.ProviderManager; import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.HttpSecurityBuilder; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; -import org.springframework.security.config.annotation.web.configurers.SessionManagementConfigurer; -import org.springframework.security.context.DelegatingApplicationListener; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.session.AbstractSessionEvent; -import org.springframework.security.core.session.SessionDestroyedEvent; -import org.springframework.security.core.session.SessionIdChangedEvent; import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcBackChannelLogoutAuthenticationProvider; -import org.springframework.security.oauth2.client.oidc.session.InMemoryOidcSessionRegistry; -import org.springframework.security.oauth2.client.oidc.session.OidcSessionInformation; -import org.springframework.security.oauth2.client.oidc.session.OidcSessionRegistry; import org.springframework.security.oauth2.client.oidc.web.OidcBackChannelLogoutFilter; import org.springframework.security.oauth2.client.oidc.web.logout.OidcBackChannelLogoutHandler; import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; -import org.springframework.security.oauth2.core.oidc.user.OidcUser; import org.springframework.security.web.authentication.logout.LogoutHandler; -import org.springframework.security.web.authentication.session.SessionAuthenticationException; -import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy; import org.springframework.security.web.csrf.CsrfFilter; -import org.springframework.security.web.csrf.CsrfToken; import org.springframework.util.Assert; /** @@ -117,28 +91,6 @@ public void configure(B builder) throws Exception { } } - private void registerDelegateApplicationListener(ApplicationListener delegate) { - DelegatingApplicationListener delegating = getBeanOrNull(DelegatingApplicationListener.class); - if (delegating == null) { - return; - } - SmartApplicationListener smartListener = new GenericApplicationListenerAdapter(delegate); - delegating.addListener(smartListener); - } - - private T getBeanOrNull(Class type) { - ApplicationContext context = getBuilder().getSharedObject(ApplicationContext.class); - if (context == null) { - return null; - } - try { - return context.getBean(type); - } - catch (NoSuchBeanDefinitionException ex) { - return null; - } - } - /** * A configurer for configuring OIDC Back-Channel Logout */ @@ -147,8 +99,6 @@ public final class BackChannelLogoutConfigurer { private AuthenticationManager authenticationManager = new ProviderManager( new OidcBackChannelLogoutAuthenticationProvider()); - private OidcSessionRegistry sessionRegistry = new InMemoryOidcSessionRegistry(); - private LogoutHandler logoutHandler; /** @@ -162,18 +112,6 @@ public BackChannelLogoutConfigurer authenticationManager(AuthenticationManager a return this; } - /** - * Use this {@link OidcSessionRegistry} for managing the client-provider session - * link - * @param sessionRegistry the {@link OidcSessionRegistry} to use - * @return the {@link BackChannelLogoutConfigurer} for further configuration - */ - public BackChannelLogoutConfigurer oidcSessionRegistry(OidcSessionRegistry sessionRegistry) { - Assert.notNull(sessionRegistry, "sessionRegistry cannot be null"); - this.sessionRegistry = sessionRegistry; - return this; - } - /** * Use this {@link LogoutHandler} for invalidating each session identified by the * OIDC Back-Channel Logout Token @@ -189,118 +127,22 @@ private AuthenticationManager authenticationManager() { return this.authenticationManager; } - private OidcSessionRegistry oidcSessionRegistry() { - return this.sessionRegistry; - } - - private LogoutHandler logoutHandler() { + private LogoutHandler logoutHandler(B http) { if (this.logoutHandler == null) { OidcBackChannelLogoutHandler logoutHandler = new OidcBackChannelLogoutHandler(); - logoutHandler.setSessionRegistry(this.sessionRegistry); + logoutHandler.setSessionRegistry(OAuth2ClientConfigurerUtils.getOidcSessionRegistry(http)); this.logoutHandler = logoutHandler; } return this.logoutHandler; } - private SessionAuthenticationStrategy sessionAuthenticationStrategy() { - OidcSessionRegistryAuthenticationStrategy strategy = new OidcSessionRegistryAuthenticationStrategy(); - strategy.setSessionRegistry(oidcSessionRegistry()); - return strategy; - } - void configure(B http) { ClientRegistrationRepository clientRegistrationRepository = OAuth2ClientConfigurerUtils .getClientRegistrationRepository(http); OidcBackChannelLogoutFilter filter = new OidcBackChannelLogoutFilter(clientRegistrationRepository, authenticationManager()); - filter.setLogoutHandler(logoutHandler()); + filter.setLogoutHandler(logoutHandler(http)); http.addFilterBefore(filter, CsrfFilter.class); - SessionManagementConfigurer sessionConfigurer = http.getConfigurer(SessionManagementConfigurer.class); - if (sessionConfigurer != null) { - sessionConfigurer.addSessionAuthenticationStrategy(sessionAuthenticationStrategy()); - } - OidcClientSessionEventListener listener = new OidcClientSessionEventListener(); - listener.setSessionRegistry(this.sessionRegistry); - registerDelegateApplicationListener(listener); - } - - static final class OidcClientSessionEventListener implements ApplicationListener { - - private final Log logger = LogFactory.getLog(OidcClientSessionEventListener.class); - - private OidcSessionRegistry sessionRegistry = new InMemoryOidcSessionRegistry(); - - /** - * {@inheritDoc} - */ - @Override - public void onApplicationEvent(AbstractSessionEvent event) { - if (event instanceof SessionDestroyedEvent destroyed) { - this.logger.debug("Received SessionDestroyedEvent"); - this.sessionRegistry.removeSessionInformation(destroyed.getId()); - return; - } - if (event instanceof SessionIdChangedEvent changed) { - this.logger.debug("Received SessionIdChangedEvent"); - OidcSessionInformation information = this.sessionRegistry.removeSessionInformation(changed.getOldSessionId()); - if (information == null) { - this.logger.debug("Failed to register new session id since old session id was not found in registry"); - return; - } - this.sessionRegistry.saveSessionInformation(information.withSessionId(changed.getNewSessionId())); - } - } - - /** - * The registry where OIDC Provider sessions are linked to the Client session. - * Defaults to in-memory storage. - * @param sessionRegistry the {@link OidcSessionRegistry} to use - */ - void setSessionRegistry(OidcSessionRegistry sessionRegistry) { - Assert.notNull(sessionRegistry, "sessionRegistry cannot be null"); - this.sessionRegistry = sessionRegistry; - } - - } - - static final class OidcSessionRegistryAuthenticationStrategy implements SessionAuthenticationStrategy { - - private final Log logger = LogFactory.getLog(getClass()); - - private OidcSessionRegistry sessionRegistry = new InMemoryOidcSessionRegistry(); - - /** - * {@inheritDoc} - */ - @Override - public void onAuthentication(Authentication authentication, HttpServletRequest request, HttpServletResponse response) throws SessionAuthenticationException { - HttpSession session = request.getSession(false); - if (session == null) { - return; - } - if (!(authentication.getPrincipal() instanceof OidcUser user)) { - return; - } - String sessionId = session.getId(); - CsrfToken csrfToken = (CsrfToken) request.getAttribute(CsrfToken.class.getName()); - Map headers = (csrfToken != null) ? Map.of(csrfToken.getHeaderName(), csrfToken.getToken()) : Collections.emptyMap(); - OidcSessionInformation registration = new OidcSessionInformation(sessionId, headers, user); - if (this.logger.isTraceEnabled()) { - this.logger.trace(String.format("Linking a provider [%s] session to this client's session", user.getIssuer())); - } - this.sessionRegistry.saveSessionInformation(registration); - } - - /** - * The registration for linking OIDC Provider Session information to the - * Client's session. Defaults to in-memory storage. - * @param sessionRegistry the {@link OidcSessionRegistry} to use - */ - void setSessionRegistry(OidcSessionRegistry sessionRegistry) { - Assert.notNull(sessionRegistry, "sessionRegistry cannot be null"); - this.sessionRegistry = sessionRegistry; - } - } } diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurerTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurerTests.java index 4b0bcfc08ab..e7d062190a0 100644 --- a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurerTests.java +++ b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurerTests.java @@ -246,10 +246,9 @@ SecurityFilterChain filters(HttpSecurity http) throws Exception { // @formatter:off http .authorizeHttpRequests((authorize) -> authorize.anyRequest().authenticated()) - .oauth2Login(Customizer.withDefaults()) + .oauth2Login((oauth2) -> oauth2.oidcSessionRegistry(this.sessionRegistry)) .oidcLogout((oidc) -> oidc.backChannel((logout) -> logout .authenticationManager(this.authenticationManager) - .oidcSessionRegistry(this.sessionRegistry) .logoutHandler(this.logoutHandler) )); // @formatter:on From 5d60876edc1e92d33cad77450a8c70fbc8c5a2fb Mon Sep 17 00:00:00 2001 From: Josh Cummings Date: Wed, 26 Jul 2023 14:16:25 -0600 Subject: [PATCH 09/12] Add OidcLogoutTokenAuthenticationConverter - Note that the reason OidcLogoutAuthenticationToken has a ClientRegistration instance (instead of a reference) is because OAuth2LoginAuthenticationToken does --- .../oauth2/client/OidcLogoutConfigurer.java | 29 ++++++- .../client/OidcLogoutConfigurerTests.java | 14 +++ .../oidc/web/OidcBackChannelLogoutFilter.java | 87 ++++++++----------- .../OidcLogoutAuthenticationConverter.java | 86 ++++++++++++++++++ .../web/OidcBackChannelLogoutFilterTests.java | 14 +-- 5 files changed, 170 insertions(+), 60 deletions(-) create mode 100644 oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/web/OidcLogoutAuthenticationConverter.java diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurer.java index b8f0c486bbd..1cd3c48d5c1 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurer.java @@ -26,8 +26,10 @@ import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcBackChannelLogoutAuthenticationProvider; import org.springframework.security.oauth2.client.oidc.web.OidcBackChannelLogoutFilter; +import org.springframework.security.oauth2.client.oidc.web.OidcLogoutAuthenticationConverter; import org.springframework.security.oauth2.client.oidc.web.logout.OidcBackChannelLogoutHandler; import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.web.authentication.AuthenticationConverter; import org.springframework.security.web.authentication.logout.LogoutHandler; import org.springframework.security.web.csrf.CsrfFilter; import org.springframework.util.Assert; @@ -96,11 +98,25 @@ public void configure(B builder) throws Exception { */ public final class BackChannelLogoutConfigurer { + private AuthenticationConverter authenticationConverter; + private AuthenticationManager authenticationManager = new ProviderManager( new OidcBackChannelLogoutAuthenticationProvider()); private LogoutHandler logoutHandler; + /** + * Use this {@link AuthenticationConverter} to extract the Logout Token from the + * request + * @param authenticationConverter the {@link AuthenticationConverter} to use + * @return the {@link BackChannelLogoutConfigurer} for further configuration + */ + public BackChannelLogoutConfigurer authenticationConverter(AuthenticationConverter authenticationConverter) { + Assert.notNull(authenticationConverter, "authenticationConverter cannot be null"); + this.authenticationConverter = authenticationConverter; + return this; + } + /** * Use this {@link AuthenticationManager} to authenticate the OIDC Logout Token * @param authenticationManager the {@link AuthenticationManager} to use @@ -123,6 +139,15 @@ public BackChannelLogoutConfigurer logoutHandler(LogoutHandler logoutHandler) { return this; } + private AuthenticationConverter authenticationConverter(B http) { + if (this.authenticationConverter == null) { + ClientRegistrationRepository clientRegistrationRepository = OAuth2ClientConfigurerUtils + .getClientRegistrationRepository(http); + this.authenticationConverter = new OidcLogoutAuthenticationConverter(clientRegistrationRepository); + } + return this.authenticationConverter; + } + private AuthenticationManager authenticationManager() { return this.authenticationManager; } @@ -137,9 +162,7 @@ private LogoutHandler logoutHandler(B http) { } void configure(B http) { - ClientRegistrationRepository clientRegistrationRepository = OAuth2ClientConfigurerUtils - .getClientRegistrationRepository(http); - OidcBackChannelLogoutFilter filter = new OidcBackChannelLogoutFilter(clientRegistrationRepository, + OidcBackChannelLogoutFilter filter = new OidcBackChannelLogoutFilter(authenticationConverter(http), authenticationManager()); filter.setLogoutHandler(logoutHandler(http)); http.addFilterBefore(filter, CsrfFilter.class); diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurerTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurerTests.java index e7d062190a0..3fd3aed8ad6 100644 --- a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurerTests.java +++ b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurerTests.java @@ -63,6 +63,7 @@ import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.oauth2.client.oidc.authentication.logout.LogoutTokenClaimNames; import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcBackChannelLogoutAuthentication; +import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcLogoutAuthenticationToken; import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcLogoutToken; import org.springframework.security.oauth2.client.oidc.authentication.logout.TestOidcLogoutTokens; import org.springframework.security.oauth2.client.oidc.session.OidcSessionInformation; @@ -82,6 +83,7 @@ import org.springframework.security.oauth2.jwt.NimbusJwtEncoder; import org.springframework.security.provisioning.InMemoryUserDetailsManager; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.AuthenticationConverter; import org.springframework.security.web.authentication.logout.LogoutHandler; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MvcResult; @@ -171,6 +173,10 @@ void logoutWhenInvalidLogoutTokenThenBadRequest() throws Exception { void logoutWhenCustomComponentsThenUses() throws Exception { this.spring.register(WithCustomComponentsConfig.class).autowire(); String registrationId = this.clientRegistration.getRegistrationId(); + AuthenticationConverter authenticationConverter = this.spring.getContext() + .getBean(AuthenticationConverter.class); + given(authenticationConverter.convert(any())) + .willReturn(new OidcLogoutAuthenticationToken("token", this.clientRegistration)); AuthenticationManager authenticationManager = this.spring.getContext().getBean(AuthenticationManager.class); OidcLogoutToken logoutToken = TestOidcLogoutTokens.withSessionId("issuer", "provider").build(); given(authenticationManager.authenticate(any())) @@ -233,6 +239,8 @@ SecurityFilterChain filters(HttpSecurity http) throws Exception { @Import(RegistrationConfig.class) static class WithCustomComponentsConfig { + AuthenticationConverter authenticationConverter = mock(AuthenticationConverter.class); + AuthenticationManager authenticationManager = mock(AuthenticationManager.class); OidcSessionRegistry sessionRegistry = mock(OidcSessionRegistry.class); @@ -248,6 +256,7 @@ SecurityFilterChain filters(HttpSecurity http) throws Exception { .authorizeHttpRequests((authorize) -> authorize.anyRequest().authenticated()) .oauth2Login((oauth2) -> oauth2.oidcSessionRegistry(this.sessionRegistry)) .oidcLogout((oidc) -> oidc.backChannel((logout) -> logout + .authenticationConverter(this.authenticationConverter) .authenticationManager(this.authenticationManager) .logoutHandler(this.logoutHandler) )); @@ -256,6 +265,11 @@ SecurityFilterChain filters(HttpSecurity http) throws Exception { return http.build(); } + @Bean + AuthenticationConverter authenticationConverter() { + return this.authenticationConverter; + } + @Bean AuthenticationManager authenticationManager() { return this.authenticationManager; diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/web/OidcBackChannelLogoutFilter.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/web/OidcBackChannelLogoutFilter.java index 3c49535bf14..9179329f79e 100644 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/web/OidcBackChannelLogoutFilter.java +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/web/OidcBackChannelLogoutFilter.java @@ -30,17 +30,13 @@ import org.springframework.security.authentication.AuthenticationServiceException; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; -import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcLogoutAuthenticationToken; import org.springframework.security.oauth2.client.oidc.web.logout.OidcBackChannelLogoutHandler; -import org.springframework.security.oauth2.client.registration.ClientRegistration; -import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; import org.springframework.security.oauth2.core.OAuth2AuthenticationException; import org.springframework.security.oauth2.core.OAuth2Error; import org.springframework.security.oauth2.core.OAuth2ErrorCodes; import org.springframework.security.oauth2.core.http.converter.OAuth2ErrorHttpMessageConverter; +import org.springframework.security.web.authentication.AuthenticationConverter; import org.springframework.security.web.authentication.logout.LogoutHandler; -import org.springframework.security.web.util.matcher.AntPathRequestMatcher; -import org.springframework.security.web.util.matcher.RequestMatcher; import org.springframework.util.Assert; import org.springframework.web.filter.OncePerRequestFilter; @@ -55,32 +51,28 @@ */ public class OidcBackChannelLogoutFilter extends OncePerRequestFilter { - private static final String DEFAULT_LOGOUT_URI = "/logout/connect/back-channel/{registrationId}"; - private final Log logger = LogFactory.getLog(getClass()); - private final ClientRegistrationRepository clientRegistrationRepository; + private final AuthenticationConverter authenticationConverter; private final AuthenticationManager authenticationManager; private final OAuth2ErrorHttpMessageConverter errorHttpMessageConverter = new OAuth2ErrorHttpMessageConverter(); - private RequestMatcher requestMatcher = new AntPathRequestMatcher(DEFAULT_LOGOUT_URI, "POST"); - private LogoutHandler logoutHandler = new OidcBackChannelLogoutHandler(); /** * Construct an {@link OidcBackChannelLogoutFilter} - * @param clientRegistrationRepository the {@link ClientRegistrationRepository} for - * deriving Logout Token authentication + * @param authenticationConverter the {@link AuthenticationConverter} for deriving + * Logout Token authentication * @param authenticationManager the {@link AuthenticationManager} for authenticating * Logout Tokens */ - public OidcBackChannelLogoutFilter(ClientRegistrationRepository clientRegistrationRepository, + public OidcBackChannelLogoutFilter(AuthenticationConverter authenticationConverter, AuthenticationManager authenticationManager) { - Assert.notNull(clientRegistrationRepository, "clientRegistrationRepository cannot be null"); + Assert.notNull(authenticationConverter, "authenticationConverter cannot be null"); Assert.notNull(authenticationManager, "authenticationManager cannot be null"); - this.clientRegistrationRepository = clientRegistrationRepository; + this.authenticationConverter = authenticationConverter; this.authenticationManager = authenticationManager; } @@ -90,55 +82,50 @@ public OidcBackChannelLogoutFilter(ClientRegistrationRepository clientRegistrati @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { - RequestMatcher.MatchResult result = this.requestMatcher.matcher(request); - if (!result.isMatch()) { - chain.doFilter(request, response); - return; + Authentication token; + try { + token = this.authenticationConverter.convert(request); } - String registrationId = result.getVariables().get("registrationId"); - ClientRegistration clientRegistration = this.clientRegistrationRepository.findByRegistrationId(registrationId); - if (clientRegistration == null) { - this.logger.debug("Did not process OIDC Back-Channel Logout since no ClientRegistration was found"); - response.sendError(HttpServletResponse.SC_BAD_REQUEST); + catch (AuthenticationServiceException ex) { + this.logger.debug("Failed to process OIDC Back-Channel Logout", ex); + throw ex; + } + catch (AuthenticationException ex) { + handleAuthenticationFailure(response, ex); return; } - String logoutToken = request.getParameter("logout_token"); - if (logoutToken == null) { - this.logger.debug("Failed to process OIDC Back-Channel Logout since no logout token was found"); - response.sendError(HttpServletResponse.SC_BAD_REQUEST); + if (token == null) { + chain.doFilter(request, response); return; } - OidcLogoutAuthenticationToken token = new OidcLogoutAuthenticationToken(logoutToken, clientRegistration); + Authentication authentication; try { - Authentication authentication = this.authenticationManager.authenticate(token); - this.logoutHandler.logout(request, response, authentication); - } - catch (OAuth2AuthenticationException ex) { - this.logger.debug("Failed to process OIDC Back-Channel Logout", ex); - response.setStatus(HttpServletResponse.SC_BAD_REQUEST); - this.errorHttpMessageConverter.write(ex.getError(), null, new ServletServerHttpResponse(response)); + authentication = this.authenticationManager.authenticate(token); } catch (AuthenticationServiceException ex) { this.logger.debug("Failed to process OIDC Back-Channel Logout", ex); - response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, ex.getMessage()); + throw ex; } catch (AuthenticationException ex) { - this.logger.debug("Failed to process OIDC Back-Channel Logout", ex); - OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.INVALID_REQUEST, ex.getMessage(), - "https://openid.net/specs/openid-connect-backchannel-1_0.html#Validation"); - response.setStatus(HttpServletResponse.SC_BAD_REQUEST); - this.errorHttpMessageConverter.write(error, null, new ServletServerHttpResponse(response)); + handleAuthenticationFailure(response, ex); + return; } + this.logoutHandler.logout(request, response, authentication); } - /** - * The logout endpoint. Defaults to - * {@code /logout/connect/back-channel/{registrationId}}. - * @param requestMatcher the {@link RequestMatcher} to use - */ - public void setRequestMatcher(RequestMatcher requestMatcher) { - Assert.notNull(requestMatcher, "requestMatcher cannot be null"); - this.requestMatcher = requestMatcher; + private void handleAuthenticationFailure(HttpServletResponse response, AuthenticationException ex) + throws IOException { + this.logger.debug("Failed to process OIDC Back-Channel Logout", ex); + response.setStatus(HttpServletResponse.SC_BAD_REQUEST); + this.errorHttpMessageConverter.write(oauth2Error(ex), null, new ServletServerHttpResponse(response)); + } + + private OAuth2Error oauth2Error(AuthenticationException ex) { + if (ex instanceof OAuth2AuthenticationException oauth2) { + return oauth2.getError(); + } + return new OAuth2Error(OAuth2ErrorCodes.INVALID_REQUEST, ex.getMessage(), + "https://openid.net/specs/openid-connect-backchannel-1_0.html#Validation"); } /** diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/web/OidcLogoutAuthenticationConverter.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/web/OidcLogoutAuthenticationConverter.java new file mode 100644 index 00000000000..36c79a7b947 --- /dev/null +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/web/OidcLogoutAuthenticationConverter.java @@ -0,0 +1,86 @@ +/* + * Copyright 2002-2023 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.oidc.web; + +import jakarta.servlet.http.HttpServletRequest; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcLogoutAuthenticationToken; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; +import org.springframework.security.web.authentication.AuthenticationConverter; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatcher; +import org.springframework.util.Assert; + +/** + * An {@link AuthenticationConverter} that extracts the OIDC Logout Token authentication + * request + * + * @author Josh Cummings + * @since 6.2 + */ +public final class OidcLogoutAuthenticationConverter implements AuthenticationConverter { + + private static final String DEFAULT_LOGOUT_URI = "/logout/connect/back-channel/{registrationId}"; + + private final Log logger = LogFactory.getLog(getClass()); + + private final ClientRegistrationRepository clientRegistrationRepository; + + private RequestMatcher requestMatcher = new AntPathRequestMatcher(DEFAULT_LOGOUT_URI, "POST"); + + public OidcLogoutAuthenticationConverter(ClientRegistrationRepository clientRegistrationRepository) { + Assert.notNull(clientRegistrationRepository, "clientRegistrationRepository cannot be null"); + this.clientRegistrationRepository = clientRegistrationRepository; + } + + @Override + public Authentication convert(HttpServletRequest request) { + RequestMatcher.MatchResult result = this.requestMatcher.matcher(request); + if (!result.isMatch()) { + return null; + } + String registrationId = result.getVariables().get("registrationId"); + ClientRegistration clientRegistration = this.clientRegistrationRepository.findByRegistrationId(registrationId); + if (clientRegistration == null) { + this.logger.debug("Did not process OIDC Back-Channel Logout since no ClientRegistration was found"); + throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_REQUEST); + } + String logoutToken = request.getParameter("logout_token"); + if (logoutToken == null) { + this.logger.debug("Failed to process OIDC Back-Channel Logout since no logout token was found"); + throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_REQUEST); + } + return new OidcLogoutAuthenticationToken(logoutToken, clientRegistration); + } + + /** + * The logout endpoint. Defaults to + * {@code /logout/connect/back-channel/{registrationId}}. + * @param requestMatcher the {@link RequestMatcher} to use + */ + public void setRequestMatcher(RequestMatcher requestMatcher) { + Assert.notNull(requestMatcher, "requestMatcher cannot be null"); + this.requestMatcher = requestMatcher; + } + +} diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/web/OidcBackChannelLogoutFilterTests.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/web/OidcBackChannelLogoutFilterTests.java index 5d87f8baeb4..a1ad2a24661 100644 --- a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/web/OidcBackChannelLogoutFilterTests.java +++ b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/web/OidcBackChannelLogoutFilterTests.java @@ -51,7 +51,7 @@ public void doFilterRequestDoesNotMatchThenDoesNotRun() throws Exception { ClientRegistrationRepository clientRegistrationRepository = mock(ClientRegistrationRepository.class); AuthenticationManager authenticationManager = mock(AuthenticationManager.class); OidcBackChannelLogoutFilter backChannelLogoutFilter = new OidcBackChannelLogoutFilter( - clientRegistrationRepository, authenticationManager); + new OidcLogoutAuthenticationConverter(clientRegistrationRepository), authenticationManager); MockHttpServletRequest request = new MockHttpServletRequest(); MockHttpServletResponse response = new MockHttpServletResponse(); FilterChain chain = mock(FilterChain.class); @@ -66,8 +66,8 @@ public void doFilterRequestDoesNotMatchContainLogoutTokenThenBadRequest() throws ClientRegistrationRepository clientRegistrationRepository = mock(ClientRegistrationRepository.class); given(clientRegistrationRepository.findByRegistrationId(any())).willReturn(clientRegistration); AuthenticationManager authenticationManager = mock(AuthenticationManager.class); - OidcBackChannelLogoutFilter filter = new OidcBackChannelLogoutFilter(clientRegistrationRepository, - authenticationManager); + OidcBackChannelLogoutFilter filter = new OidcBackChannelLogoutFilter( + new OidcLogoutAuthenticationConverter(clientRegistrationRepository), authenticationManager); MockHttpServletRequest request = new MockHttpServletRequest("POST", "/logout/connect/back-channel/id"); request.setServletPath("/logout/connect/back-channel/id"); MockHttpServletResponse response = new MockHttpServletResponse(); @@ -82,7 +82,7 @@ public void doFilterWithNoMatchingClientThenBadRequest() throws Exception { ClientRegistrationRepository clientRegistrationRepository = mock(ClientRegistrationRepository.class); AuthenticationManager authenticationManager = mock(AuthenticationManager.class); OidcBackChannelLogoutFilter backChannelLogoutFilter = new OidcBackChannelLogoutFilter( - clientRegistrationRepository, authenticationManager); + new OidcLogoutAuthenticationConverter(clientRegistrationRepository), authenticationManager); MockHttpServletRequest request = new MockHttpServletRequest("POST", "/logout/connect/back-channel/id"); request.setServletPath("/logout/connect/back-channel/id"); request.setParameter("logout_token", "logout_token"); @@ -108,8 +108,8 @@ public void doFilterWithSessionMatchingLogoutTokenThenInvalidates() throws Excep OidcSessionRegistry sessionRegistry = mock(OidcSessionRegistry.class); given(sessionRegistry.removeSessionInformation(any(OidcLogoutToken.class))).willReturn(infos); backChannelLogoutHandler.setSessionRegistry(sessionRegistry); - OidcBackChannelLogoutFilter filter = new OidcBackChannelLogoutFilter(clientRegistrationRepository, - authenticationManager); + OidcBackChannelLogoutFilter filter = new OidcBackChannelLogoutFilter( + new OidcLogoutAuthenticationConverter(clientRegistrationRepository), authenticationManager); filter.setLogoutHandler(backChannelLogoutHandler); MockHttpServletRequest request = new MockHttpServletRequest("POST", "/oauth2/" + clientRegistration.getRegistrationId() + "/logout"); @@ -132,7 +132,7 @@ public void doFilterWhenInvalidJwtThenBadRequest() throws Exception { given(authenticationManager.authenticate(any())).willThrow(new BadCredentialsException("bad")); LogoutHandler logoutHandler = mock(LogoutHandler.class); OidcBackChannelLogoutFilter backChannelLogoutFilter = new OidcBackChannelLogoutFilter( - clientRegistrationRepository, authenticationManager); + new OidcLogoutAuthenticationConverter(clientRegistrationRepository), authenticationManager); backChannelLogoutFilter.setLogoutHandler(logoutHandler); MockHttpServletRequest request = new MockHttpServletRequest("POST", "/oauth2/" + clientRegistration.getRegistrationId() + "/logout"); From f236c8d495b039454770d8f44856f5904178c55e Mon Sep 17 00:00:00 2001 From: Josh Cummings Date: Wed, 26 Jul 2023 14:23:44 -0600 Subject: [PATCH 10/12] Move OidcLogoutTokenAuthenticationConverter --- .../web/configurers/oauth2/client/OidcLogoutConfigurer.java | 2 +- .../web/{ => logout}/OidcLogoutAuthenticationConverter.java | 2 +- .../client/oidc/web/OidcBackChannelLogoutFilterTests.java | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) rename oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/web/{ => logout}/OidcLogoutAuthenticationConverter.java (98%) diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurer.java index 1cd3c48d5c1..72ecf45b14d 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurer.java @@ -26,7 +26,7 @@ import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcBackChannelLogoutAuthenticationProvider; import org.springframework.security.oauth2.client.oidc.web.OidcBackChannelLogoutFilter; -import org.springframework.security.oauth2.client.oidc.web.OidcLogoutAuthenticationConverter; +import org.springframework.security.oauth2.client.oidc.web.logout.OidcLogoutAuthenticationConverter; import org.springframework.security.oauth2.client.oidc.web.logout.OidcBackChannelLogoutHandler; import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; import org.springframework.security.web.authentication.AuthenticationConverter; diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/web/OidcLogoutAuthenticationConverter.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/web/logout/OidcLogoutAuthenticationConverter.java similarity index 98% rename from oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/web/OidcLogoutAuthenticationConverter.java rename to oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/web/logout/OidcLogoutAuthenticationConverter.java index 36c79a7b947..bf21f075e47 100644 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/web/OidcLogoutAuthenticationConverter.java +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/web/logout/OidcLogoutAuthenticationConverter.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.security.oauth2.client.oidc.web; +package org.springframework.security.oauth2.client.oidc.web.logout; import jakarta.servlet.http.HttpServletRequest; import org.apache.commons.logging.Log; diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/web/OidcBackChannelLogoutFilterTests.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/web/OidcBackChannelLogoutFilterTests.java index a1ad2a24661..698d92c7480 100644 --- a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/web/OidcBackChannelLogoutFilterTests.java +++ b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/web/OidcBackChannelLogoutFilterTests.java @@ -32,6 +32,7 @@ import org.springframework.security.oauth2.client.oidc.session.OidcSessionRegistry; import org.springframework.security.oauth2.client.oidc.session.TestOidcSessionInformations; import org.springframework.security.oauth2.client.oidc.web.logout.OidcBackChannelLogoutHandler; +import org.springframework.security.oauth2.client.oidc.web.logout.OidcLogoutAuthenticationConverter; import org.springframework.security.oauth2.client.registration.ClientRegistration; import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; import org.springframework.security.oauth2.client.registration.TestClientRegistrations; From 95960e87c39a225dad8b9175d2c66532078f7bdc Mon Sep 17 00:00:00 2001 From: Josh Cummings Date: Mon, 7 Aug 2023 16:32:49 -0600 Subject: [PATCH 11/12] Fix Import Order --- .../web/configurers/oauth2/client/OidcLogoutConfigurer.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurer.java index 72ecf45b14d..570821b82ba 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurer.java @@ -26,8 +26,8 @@ import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcBackChannelLogoutAuthenticationProvider; import org.springframework.security.oauth2.client.oidc.web.OidcBackChannelLogoutFilter; -import org.springframework.security.oauth2.client.oidc.web.logout.OidcLogoutAuthenticationConverter; import org.springframework.security.oauth2.client.oidc.web.logout.OidcBackChannelLogoutHandler; +import org.springframework.security.oauth2.client.oidc.web.logout.OidcLogoutAuthenticationConverter; import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; import org.springframework.security.web.authentication.AuthenticationConverter; import org.springframework.security.web.authentication.logout.LogoutHandler; From e2530e548bd115b5e6c7ddf14e19859f4a8a4fd3 Mon Sep 17 00:00:00 2001 From: Josh Cummings Date: Tue, 5 Sep 2023 16:53:12 -0600 Subject: [PATCH 12/12] Polish - JavaDoc - Improved Logout Error Handling --- .../oidc/session/OidcSessionInformation.java | 7 +++-- .../oidc/web/OidcBackChannelLogoutFilter.java | 4 +-- .../logout/OidcBackChannelLogoutHandler.java | 28 +++++++++++++++++++ 3 files changed, 34 insertions(+), 5 deletions(-) diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/session/OidcSessionInformation.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/session/OidcSessionInformation.java index e51ae90b2dc..d7463151782 100644 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/session/OidcSessionInformation.java +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/session/OidcSessionInformation.java @@ -38,6 +38,7 @@ public class OidcSessionInformation extends SessionInformation { /** * Construct an {@link OidcSessionInformation} * @param sessionId the Client's session id + * @param authorities any material that authorizes operating on the session * @param user the OIDC Provider's session and end user */ public OidcSessionInformation(String sessionId, Map authorities, OidcUser user) { @@ -46,8 +47,8 @@ public OidcSessionInformation(String sessionId, Map authorities, } /** - * Any headers needed to authorize operations on this session - * @return the {@link Map} of headers + * Any material needed to authorize operations on this session + * @return the {@link Map} of credentials */ public Map getAuthorities() { return this.authorities; @@ -67,7 +68,7 @@ public OidcUser getPrincipal() { * @return a new {@link OidcSessionInformation} instance */ public OidcSessionInformation withSessionId(String sessionId) { - return new OidcSessionInformation(sessionId, this.authorities, getPrincipal()); + return new OidcSessionInformation(sessionId, getAuthorities(), getPrincipal()); } } diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/web/OidcBackChannelLogoutFilter.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/web/OidcBackChannelLogoutFilter.java index 9179329f79e..6a04b43fad7 100644 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/web/OidcBackChannelLogoutFilter.java +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/web/OidcBackChannelLogoutFilter.java @@ -113,14 +113,14 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse this.logoutHandler.logout(request, response, authentication); } - private void handleAuthenticationFailure(HttpServletResponse response, AuthenticationException ex) + private void handleAuthenticationFailure(HttpServletResponse response, Exception ex) throws IOException { this.logger.debug("Failed to process OIDC Back-Channel Logout", ex); response.setStatus(HttpServletResponse.SC_BAD_REQUEST); this.errorHttpMessageConverter.write(oauth2Error(ex), null, new ServletServerHttpResponse(response)); } - private OAuth2Error oauth2Error(AuthenticationException ex) { + private OAuth2Error oauth2Error(Exception ex) { if (ex instanceof OAuth2AuthenticationException oauth2) { return oauth2.getError(); } diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/web/logout/OidcBackChannelLogoutHandler.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/web/logout/OidcBackChannelLogoutHandler.java index 0263ae649f9..1bf46a2d45a 100644 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/web/logout/OidcBackChannelLogoutHandler.java +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/web/logout/OidcBackChannelLogoutHandler.java @@ -16,6 +16,9 @@ package org.springframework.security.oauth2.client.oidc.web.logout; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collection; import java.util.Map; import jakarta.servlet.http.HttpServletRequest; @@ -25,12 +28,15 @@ import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; +import org.springframework.http.server.ServletServerHttpResponse; import org.springframework.security.core.Authentication; import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcBackChannelLogoutAuthentication; import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcLogoutToken; import org.springframework.security.oauth2.client.oidc.session.InMemoryOidcSessionRegistry; import org.springframework.security.oauth2.client.oidc.session.OidcSessionInformation; import org.springframework.security.oauth2.client.oidc.session.OidcSessionRegistry; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.http.converter.OAuth2ErrorHttpMessageConverter; import org.springframework.security.web.authentication.logout.LogoutHandler; import org.springframework.util.Assert; import org.springframework.web.client.RestClientException; @@ -60,6 +66,8 @@ public final class OidcBackChannelLogoutHandler implements LogoutHandler { private String sessionCookieName = "JSESSIONID"; + private final OAuth2ErrorHttpMessageConverter errorHttpMessageConverter = new OAuth2ErrorHttpMessageConverter(); + @Override public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) { if (!(authentication instanceof OidcBackChannelLogoutAuthentication token)) { @@ -70,6 +78,7 @@ public void logout(HttpServletRequest request, HttpServletResponse response, Aut return; } Iterable sessions = this.sessionRegistry.removeSessionInformation(token.getPrincipal()); + Collection errors = new ArrayList<>(); int totalCount = 0; int invalidatedCount = 0; for (OidcSessionInformation session : sessions) { @@ -80,11 +89,16 @@ public void logout(HttpServletRequest request, HttpServletResponse response, Aut } catch (RestClientException ex) { this.logger.debug("Failed to invalidate session", ex); + errors.add(ex.getMessage()); + this.sessionRegistry.saveSessionInformation(session); } } if (this.logger.isTraceEnabled()) { this.logger.trace(String.format("Invalidated %d out of %d sessions", invalidatedCount, totalCount)); } + if (!errors.isEmpty()) { + handleLogoutFailure(response, oauth2Error(errors)); + } } private void eachLogout(HttpServletRequest request, OidcSessionInformation session) { @@ -100,6 +114,20 @@ private void eachLogout(HttpServletRequest request, OidcSessionInformation sessi this.restOperations.postForEntity(logout, entity, Object.class); } + private OAuth2Error oauth2Error(Collection errors) { + return new OAuth2Error("partial_logout", "not all sessions were terminated: " + errors, + "https://openid.net/specs/openid-connect-backchannel-1_0.html#Validation"); + } + + private void handleLogoutFailure(HttpServletResponse response, OAuth2Error error) { + response.setStatus(HttpServletResponse.SC_BAD_REQUEST); + try { + this.errorHttpMessageConverter.write(error, null, new ServletServerHttpResponse(response)); + } catch (IOException ex) { + throw new IllegalStateException(ex); + } + } + /** * Use this {@link OidcSessionRegistry} to identify sessions to invalidate. Note that * this class uses