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..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 @@ -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 oidcLogout() throws Exception { + return getOrApply(new OidcLogoutConfigurer<>()); + } + + public HttpSecurity oidcLogout(Customizer> oidcLogoutCustomizer) + throws Exception { + oidcLogoutCustomizer.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/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 new file mode 100644 index 00000000000..570821b82ba --- /dev/null +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurer.java @@ -0,0 +1,173 @@ +/* + * 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 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.oauth2.client.oidc.authentication.logout.OidcBackChannelLogoutAuthenticationProvider; +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.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; +import org.springframework.security.web.csrf.CsrfFilter; +import org.springframework.util.Assert; + +/** + * An {@link AbstractHttpConfigurer} for OIDC Logout flows + * + *

    + * 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

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

    Shared Objects Used

    + * + * The following shared objects are used: + * + *
      + *
    • {@link ClientRegistrationRepository}
    • + *
    + * + * @author Josh Cummings + * @since 6.2 + * @see HttpSecurity#oidcLogout() + * @see OidcBackChannelLogoutFilter + * @see ClientRegistrationRepository + */ +public final class OidcLogoutConfigurer> + extends AbstractHttpConfigurer, B> { + + private BackChannelLogoutConfigurer backChannel; + + /** + * Configure OIDC Back-Channel Logout using the provided {@link Consumer} + * @return the {@link OidcLogoutConfigurer} for further configuration + */ + public OidcLogoutConfigurer backChannel(Customizer backChannelLogoutConfigurer) { + if (this.backChannel == null) { + this.backChannel = new BackChannelLogoutConfigurer(); + } + backChannelLogoutConfigurer.customize(this.backChannel); + return this; + } + + @Deprecated(forRemoval = true, since = "6.2") + public B and() { + return getBuilder(); + } + + @Override + public void configure(B builder) throws Exception { + if (this.backChannel != null) { + this.backChannel.configure(builder); + } + } + + /** + * A configurer for configuring OIDC Back-Channel Logout + */ + 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 + * @return the {@link BackChannelLogoutConfigurer} for further configuration + */ + public BackChannelLogoutConfigurer authenticationManager(AuthenticationManager authenticationManager) { + Assert.notNull(authenticationManager, "authenticationManager cannot be null"); + this.authenticationManager = authenticationManager; + 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; + 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; + } + + private LogoutHandler logoutHandler(B http) { + if (this.logoutHandler == null) { + OidcBackChannelLogoutHandler logoutHandler = new OidcBackChannelLogoutHandler(); + logoutHandler.setSessionRegistry(OAuth2ClientConfigurerUtils.getOidcSessionRegistry(http)); + this.logoutHandler = logoutHandler; + } + return this.logoutHandler; + } + + void configure(B http) { + 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 new file mode 100644 index 00000000000..3fd3aed8ad6 --- /dev/null +++ b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurerTests.java @@ -0,0 +1,509 @@ +/* + * 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.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; +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; +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.AuthenticationConverter; +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.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; +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; + +/** + * Tests for {@link OidcLogoutConfigurer} + */ +@ExtendWith(SpringTestContextExtension.class) +public class OidcLogoutConfigurerTests { + + @Autowired + private MockMvc mvc; + + @Autowired(required = false) + private MockWebServer web; + + @Autowired + private ClientRegistration clientRegistration; + + 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.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.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(); + 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.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.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(); + 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.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())) + .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(authenticationManager).authenticate(any()); + verify(this.spring.getContext().getBean(LogoutHandler.class)).logout(any(), any(), any()); + verify(sessionRegistry).removeSessionInformation(logoutToken); + } + + @Configuration + static class RegistrationConfig { + + @Autowired(required = false) + MockWebServer web; + + @Bean + ClientRegistration clientRegistration() { + 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 clientRegistrationRepository(ClientRegistration clientRegistration) { + return new InMemoryClientRegistrationRepository(clientRegistration); + } + + } + + @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()) + .oidcLogout((oidc) -> oidc.backChannel(Customizer.withDefaults())); + // @formatter:on + + return http.build(); + } + + } + + @Configuration + @EnableWebSecurity + @Import(RegistrationConfig.class) + static class WithCustomComponentsConfig { + + AuthenticationConverter authenticationConverter = mock(AuthenticationConverter.class); + + AuthenticationManager authenticationManager = mock(AuthenticationManager.class); + + OidcSessionRegistry sessionRegistry = mock(OidcSessionRegistry.class); + + OidcBackChannelLogoutHandler logoutHandler = spy(new OidcBackChannelLogoutHandler()); + + @Bean + @Order(1) + SecurityFilterChain filters(HttpSecurity http) throws Exception { + this.logoutHandler.setSessionRegistry(this.sessionRegistry); + // @formatter:off + http + .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) + )); + // @formatter:on + + return http.build(); + } + + @Bean + AuthenticationConverter authenticationConverter() { + return this.authenticationConverter; + } + + @Bean + AuthenticationManager authenticationManager() { + return this.authenticationManager; + } + + @Bean + OidcSessionRegistry sessionRegistry() { + return this.sessionRegistry; + } + + @Bean + LogoutHandler logoutHandler() { + return this.logoutHandler; + } + + } + + @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((jwt) -> 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/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/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..d9bc0c944e6 --- /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; + +final class DefaultOidcLogoutTokenValidatorFactory implements Function> { + + @Override + public OAuth2TokenValidator apply(ClientRegistration clientRegistration) { + return new DelegatingOAuth2TokenValidator<>(new JwtTimestampValidator(), + 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 new file mode 100644 index 00000000000..49aeff4c3cc --- /dev/null +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/LogoutTokenClaimAccessor.java @@ -0,0 +1,96 @@ +/* + * 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; + +/** + * A {@link ClaimAccessor} for the "claims" that can be returned in OIDC Logout + * Tokens + * + * @author Josh Cummings + * @since 6.2 + * @see OidcLogoutToken + * @see OIDC + * Back-Channel Logout Token + */ +public interface LogoutTokenClaimAccessor extends ClaimAccessor { + + /** + * 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 + */ + 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..9893aa350ab --- /dev/null +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/LogoutTokenClaimNames.java @@ -0,0 +1,70 @@ +/* + * 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 Back-Channel Logout 1.0 + * specification that can be returned in a Logout Token. + * + * @author Josh Cummings + * @since 6.2 + * @see OidcLogoutToken + * @see OIDC + * Back-Channel 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..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/OidcBackChannelLogoutAuthenticationProvider.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/OidcBackChannelLogoutAuthenticationProvider.java new file mode 100644 index 00000000000..8c417cc1143 --- /dev/null +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/OidcBackChannelLogoutAuthenticationProvider.java @@ -0,0 +1,112 @@ +/* + * 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.AuthenticationProvider; +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.OidcIdTokenDecoderFactory; +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.util.Assert; + +/** + * 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; + + /** + * 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 OidcLogoutAuthenticationToken token)) { + return null; + } + 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); + } + + /** + * {@inheritDoc} + */ + @Override + public boolean supports(Class authentication) { + return OidcLogoutAuthenticationToken.class.isAssignableFrom(authentication); + } + + private Jwt decode(ClientRegistration registration, String token) { + JwtDecoder logoutTokenDecoder = this.logoutTokenDecoderFactory.createDecoder(registration); + try { + return logoutTokenDecoder.decode(token); + } + catch (BadJwtException 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 (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; + } + +} diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/OidcBackChannelLogoutTokenValidator.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/OidcBackChannelLogoutTokenValidator.java new file mode 100644 index 00000000000..d0bd9408560 --- /dev/null +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/OidcBackChannelLogoutTokenValidator.java @@ -0,0 +1,116 @@ +/* + * 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; + +/** + * A {@link OAuth2TokenValidator} that validates OIDC Logout Token claims in conformance + * with the OIDC Back-Channel Logout Spec. + * + * @author Josh Cummings + * @since 6.2 + * @see OidcLogoutToken + * @see Logout + * Token + * @see the OIDC + * Back-Channel Logout spec + */ +public final class OidcBackChannelLogoutTokenValidator 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; + + public OidcBackChannelLogoutTokenValidator(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/authentication/logout/OidcLogoutAuthenticationToken.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/OidcLogoutAuthenticationToken.java new file mode 100644 index 00000000000..8912dc52df1 --- /dev/null +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/OidcLogoutAuthenticationToken.java @@ -0,0 +1,80 @@ +/* + * 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; + +/** + * 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; + + /** + * 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 new file mode 100644 index 00000000000..41b425bf408 --- /dev/null +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/OidcLogoutToken.java @@ -0,0 +1,223 @@ +/* + * 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.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.2 + * @see AbstractOAuth2Token + * @see LogoutTokenClaimAccessor + * @see Logout + * Token + */ +public class OidcLogoutToken extends AbstractOAuth2Token implements LogoutTokenClaimAccessor { + + private static final String BACKCHANNEL_LOGOUT_TOKEN_EVENT_NAME = "http://schemas.openid.net/event/backchannel-logout"; + + private final Map claims; + + /** + * 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 + */ + 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(BACKCHANNEL_LOGOUT_TOKEN_EVENT_NAME, Collections.emptyMap())); + } + + /** + * Use this token value in the resulting {@link OidcLogoutToken} + * @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 OidcLogoutToken} + * @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 OidcLogoutToken} + * @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 OidcLogoutToken} + * @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 OidcLogoutToken} + * @param issuer The issuer to use + * @return the {@link Builder} for further configurations + */ + public Builder issuer(String issuer) { + return claim(LogoutTokenClaimNames.ISS, issuer); + } + + /** + * 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 OidcLogoutToken} + * @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 " + "'" + + 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); + } + + private boolean hasLogoutTokenIdentifyingMember() { + if (!(this.claims.get(LogoutTokenClaimNames.EVENTS) instanceof Map events)) { + return false; + } + if (!(events.get(BACKCHANNEL_LOGOUT_TOKEN_EVENT_NAME) 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/session/InMemoryOidcSessionRegistry.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/session/InMemoryOidcSessionRegistry.java new file mode 100644 index 00000000000..f5bb6235df3 --- /dev/null +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/session/InMemoryOidcSessionRegistry.java @@ -0,0 +1,123 @@ +/* + * 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.HashSet; +import java.util.List; +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 OidcSessionRegistry} + * + * @author Josh Cummings + * @since 6.2 + */ +public final class InMemoryOidcSessionRegistry implements OidcSessionRegistry { + + private final Log logger = LogFactory.getLog(InMemoryOidcSessionRegistry.class); + + private final Map sessions = new ConcurrentHashMap<>(); + + @Override + public void saveSessionInformation(OidcSessionInformation info) { + this.sessions.put(info.getSessionId(), info); + } + + @Override + public OidcSessionInformation removeSessionInformation(String clientSessionId) { + OidcSessionInformation information = this.sessions.remove(clientSessionId); + if (information != null) { + this.logger.trace("Removed client session"); + } + return information; + } + + @Override + 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) + ? 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) { + 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(List audience, String issuer, + String sessionId) { + return (session) -> { + List thatAudience = session.getPrincipal().getAudience(); + String thatIssuer = session.getPrincipal().getIssuer().toString(); + String thatSessionId = session.getPrincipal().getClaimAsString(LogoutTokenClaimNames.SID); + if (thatAudience == null) { + return false; + } + return !Collections.disjoint(audience, thatAudience) && issuer.equals(thatIssuer) + && sessionId.equals(thatSessionId); + }; + } + + private static Predicate subjectMatcher(List audience, String issuer, + String subject) { + return (session) -> { + List thatAudience = session.getPrincipal().getAudience(); + String thatIssuer = session.getPrincipal().getIssuer().toString(); + String thatSubject = session.getPrincipal().getSubject(); + 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..d7463151782 --- /dev/null +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/session/OidcSessionInformation.java @@ -0,0 +1,74 @@ +/* + * 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 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) { + super(user, sessionId, new Date()); + this.authorities = (authorities != null) ? new LinkedHashMap<>(authorities) : Collections.emptyMap(); + } + + /** + * Any material needed to authorize operations on this session + * @return the {@link Map} of credentials + */ + 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, getAuthorities(), getPrincipal()); + } + +} diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/session/OidcSessionRegistry.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/session/OidcSessionRegistry.java new file mode 100644 index 00000000000..26bae499db3 --- /dev/null +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/session/OidcSessionRegistry.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.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.2 + * @see Logout + * Token + */ +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 info the {@link OidcSessionInformation} to use + */ + 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 OidcSessionInformation}, could be {@code null} + */ + OidcSessionInformation removeSessionInformation(String clientSessionId); + + /** + * Deregister the OIDC Provider sessions referenced by the provided OIDC Logout Token + * 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 OidcSessionInformation}s, could be empty + */ + Iterable removeSessionInformation(OidcLogoutToken logoutToken); + +} 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 new file mode 100644 index 00000000000..6a04b43fad7 --- /dev/null +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/web/OidcBackChannelLogoutFilter.java @@ -0,0 +1,141 @@ +/* + * 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 java.io.IOException; + +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.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.web.logout.OidcBackChannelLogoutHandler; +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.util.Assert; +import org.springframework.web.filter.OncePerRequestFilter; + +/** + * A filter for the Client-side OIDC Back-Channel Logout endpoint + * + * @author Josh Cummings + * @since 6.2 + * @see OIDC Back-Channel Logout + * Spec + */ +public class OidcBackChannelLogoutFilter extends OncePerRequestFilter { + + private final Log logger = LogFactory.getLog(getClass()); + + private final AuthenticationConverter authenticationConverter; + + private final AuthenticationManager authenticationManager; + + private final OAuth2ErrorHttpMessageConverter errorHttpMessageConverter = new OAuth2ErrorHttpMessageConverter(); + + private LogoutHandler logoutHandler = new OidcBackChannelLogoutHandler(); + + /** + * Construct an {@link OidcBackChannelLogoutFilter} + * @param authenticationConverter the {@link AuthenticationConverter} for deriving + * Logout Token authentication + * @param authenticationManager the {@link AuthenticationManager} for authenticating + * Logout Tokens + */ + public OidcBackChannelLogoutFilter(AuthenticationConverter authenticationConverter, + AuthenticationManager authenticationManager) { + Assert.notNull(authenticationConverter, "authenticationConverter cannot be null"); + Assert.notNull(authenticationManager, "authenticationManager cannot be null"); + this.authenticationConverter = authenticationConverter; + this.authenticationManager = authenticationManager; + } + + /** + * {@inheritDoc} + */ + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws ServletException, IOException { + Authentication token; + try { + token = this.authenticationConverter.convert(request); + } + catch (AuthenticationServiceException ex) { + this.logger.debug("Failed to process OIDC Back-Channel Logout", ex); + throw ex; + } + catch (AuthenticationException ex) { + handleAuthenticationFailure(response, ex); + return; + } + if (token == null) { + chain.doFilter(request, response); + return; + } + Authentication authentication; + try { + authentication = this.authenticationManager.authenticate(token); + } + catch (AuthenticationServiceException ex) { + this.logger.debug("Failed to process OIDC Back-Channel Logout", ex); + throw ex; + } + catch (AuthenticationException ex) { + handleAuthenticationFailure(response, ex); + return; + } + this.logoutHandler.logout(request, response, authentication); + } + + 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(Exception 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"); + } + + /** + * The strategy for expiring all Client sessions indicated by the logout request. + * Defaults to {@link OidcBackChannelLogoutHandler}. + * @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/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..1bf46a2d45a --- /dev/null +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/web/logout/OidcBackChannelLogoutHandler.java @@ -0,0 +1,175 @@ +/* + * 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.io.IOException; +import java.util.ArrayList; +import java.util.Collection; +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.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; +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"; + + private final OAuth2ErrorHttpMessageConverter errorHttpMessageConverter = new OAuth2ErrorHttpMessageConverter(); + + @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()); + Collection errors = new ArrayList<>(); + 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); + 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) { + 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); + } + + 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 + * {@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/main/java/org/springframework/security/oauth2/client/oidc/web/logout/OidcLogoutAuthenticationConverter.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/web/logout/OidcLogoutAuthenticationConverter.java new file mode 100644 index 00000000000..bf21f075e47 --- /dev/null +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/web/logout/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.logout; + +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/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/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..15788c830ea --- /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("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)); + } + return builder; + } + + public static OidcLogoutToken.Builder withSessionId(String issuer, String sessionId) { + 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("client-id")) + .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/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/session/TestOidcSessionInformations.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/session/TestOidcSessionInformations.java new file mode 100644 index 00000000000..47f64868de1 --- /dev/null +++ b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/session/TestOidcSessionInformations.java @@ -0,0 +1,45 @@ +/* + * 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.Map; + +import org.springframework.security.oauth2.core.oidc.user.OidcUser; +import org.springframework.security.oauth2.core.oidc.user.TestOidcUsers; + +/** + * Sample {@link OidcSessionInformation} instances + */ +public final class TestOidcSessionInformations { + + public static OidcSessionInformation create() { + return create("sessionId"); + } + + public static OidcSessionInformation create(String sessionId) { + return create(sessionId, TestOidcUsers.create()); + } + + public static OidcSessionInformation create(String sessionId, OidcUser user) { + return new OidcSessionInformation(sessionId, Map.of("_csrf", "token"), user); + } + + private TestOidcSessionInformations() { + + } + +} 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 new file mode 100644 index 00000000000..698d92c7480 --- /dev/null +++ b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/web/OidcBackChannelLogoutFilterTests.java @@ -0,0 +1,150 @@ +/* + * 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 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.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.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; +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.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +public class OidcBackChannelLogoutFilterTests { + + @Test + public void doFilterRequestDoesNotMatchThenDoesNotRun() throws Exception { + ClientRegistrationRepository clientRegistrationRepository = mock(ClientRegistrationRepository.class); + AuthenticationManager authenticationManager = mock(AuthenticationManager.class); + OidcBackChannelLogoutFilter backChannelLogoutFilter = new OidcBackChannelLogoutFilter( + new OidcLogoutAuthenticationConverter(clientRegistrationRepository), authenticationManager); + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain chain = mock(FilterChain.class); + backChannelLogoutFilter.doFilter(request, response, chain); + verifyNoInteractions(clientRegistrationRepository, authenticationManager); + verify(chain).doFilter(request, response); + } + + @Test + public void doFilterRequestDoesNotMatchContainLogoutTokenThenBadRequest() throws Exception { + 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( + 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(); + FilterChain chain = mock(FilterChain.class); + filter.doFilter(request, response, chain); + verifyNoInteractions(authenticationManager, chain); + assertThat(response.getStatus()).isEqualTo(400); + } + + @Test + public void doFilterWithNoMatchingClientThenBadRequest() throws Exception { + ClientRegistrationRepository clientRegistrationRepository = mock(ClientRegistrationRepository.class); + AuthenticationManager authenticationManager = mock(AuthenticationManager.class); + OidcBackChannelLogoutFilter backChannelLogoutFilter = new OidcBackChannelLogoutFilter( + 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"); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain chain = mock(FilterChain.class); + backChannelLogoutFilter.doFilter(request, response, chain); + verify(clientRegistrationRepository).findByRegistrationId("id"); + verifyNoInteractions(authenticationManager, chain); + assertThat(response.getStatus()).isEqualTo(400); + } + + @Test + public void doFilterWithSessionMatchingLogoutTokenThenInvalidates() throws Exception { + 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(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( + new OidcLogoutAuthenticationConverter(clientRegistrationRepository), authenticationManager); + filter.setLogoutHandler(backChannelLogoutHandler); + MockHttpServletRequest request = new MockHttpServletRequest("POST", + "/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(sessionRegistry).removeSessionInformation(token); + verifyNoInteractions(chain); + assertThat(response.getStatus()).isEqualTo(200); + } + + @Test + public void doFilterWhenInvalidJwtThenBadRequest() throws Exception { + 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 backChannelLogoutFilter = new OidcBackChannelLogoutFilter( + new OidcLogoutAuthenticationConverter(clientRegistrationRepository), authenticationManager); + backChannelLogoutFilter.setLogoutHandler(logoutHandler); + MockHttpServletRequest request = new MockHttpServletRequest("POST", + "/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); + 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