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 a72f90c6af..84f2a62962 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.SessionMan 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 final class HttpSecurity extends AbstractConfiguredSecurityBuilder 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 aecc450690..74229059f0 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 final class SessionManagementConfigurer> * @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/DefaultOidcLogoutTokenValidatorFactory.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/DefaultOidcLogoutTokenValidatorFactory.java new file mode 100644 index 0000000000..fa7fe8e746 --- /dev/null +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/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.config.annotation.web.configurers.oauth2.client; + +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/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 0f1dc7ab8f..7b4df03335 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.HttpSecurityBuilder; 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 @@ final class OAuth2ClientConfigurerUtils { 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 35288d6924..3ed17b0381 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.HashMap; 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.HttpSecurityBuilder; 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.DefaultAuthorizationC 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.AuthenticationEntryPoint; 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 @@ import org.springframework.util.ReflectionUtils; *
  • {@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,18 @@ public final class OAuth2LoginConfigurer> return this; } + /** + * Sets the registry for managing the OIDC client-provider session link + * @param oidcSessionRegistry the {@link OidcSessionRegistry} to use + * @return the {@link OAuth2LoginConfigurer} for further configuration + * @since 6.2 + */ + public OAuth2LoginConfigurer oidcSessionRegistry(OidcSessionRegistry oidcSessionRegistry) { + Assert.notNull(oidcSessionRegistry, "oidcSessionRegistry cannot be null"); + getBuilder().setSharedObject(OidcSessionRegistry.class, oidcSessionRegistry); + return this; + } + /** * Returns the {@link AuthorizationEndpointConfig} for configuring the Authorization * Server's Authorization Endpoint. @@ -397,6 +430,7 @@ public final class OAuth2LoginConfigurer> authenticationFilter .setAuthorizationRequestRepository(this.authorizationEndpointConfig.authorizationRequestRepository); } + configureOidcSessionRegistry(http); super.configure(http); } @@ -546,6 +580,29 @@ public final class OAuth2LoginConfigurer> 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. */ @@ -793,4 +850,83 @@ public final class OAuth2LoginConfigurer> } + 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/OidcBackChannelLogoutAuthentication.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcBackChannelLogoutAuthentication.java new file mode 100644 index 0000000000..f65b1c11c0 --- /dev/null +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcBackChannelLogoutAuthentication.java @@ -0,0 +1,66 @@ +/* + * Copyright 2002-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.config.annotation.web.configurers.oauth2.client; + +import java.util.Collections; + +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcLogoutToken; + +/** + * 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 + */ +class OidcBackChannelLogoutAuthentication extends AbstractAuthenticationToken { + + private final OidcLogoutToken logoutToken; + + /** + * Construct an {@link OidcBackChannelLogoutAuthentication} + * @param logoutToken a deserialized, verified OIDC Logout Token + */ + 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/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcBackChannelLogoutAuthenticationProvider.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcBackChannelLogoutAuthenticationProvider.java new file mode 100644 index 0000000000..0d1783c977 --- /dev/null +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcBackChannelLogoutAuthenticationProvider.java @@ -0,0 +1,113 @@ +/* + * Copyright 2002-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.config.annotation.web.configurers.oauth2.client; + +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.oidc.authentication.logout.OidcLogoutToken; +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 + */ +final class OidcBackChannelLogoutAuthenticationProvider implements AuthenticationProvider { + + private JwtDecoderFactory logoutTokenDecoderFactory; + + /** + * Construct an {@link OidcBackChannelLogoutAuthenticationProvider} + */ + 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 + */ + void setLogoutTokenDecoderFactory(JwtDecoderFactory logoutTokenDecoderFactory) { + Assert.notNull(logoutTokenDecoderFactory, "logoutTokenDecoderFactory cannot be null"); + this.logoutTokenDecoderFactory = logoutTokenDecoderFactory; + } + +} diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcBackChannelLogoutFilter.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcBackChannelLogoutFilter.java new file mode 100644 index 0000000000..0a03ec8383 --- /dev/null +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcBackChannelLogoutFilter.java @@ -0,0 +1,139 @@ +/* + * 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 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.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 + */ +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 + */ + 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 + */ + void setLogoutHandler(LogoutHandler logoutHandler) { + Assert.notNull(logoutHandler, "logoutHandler cannot be null"); + this.logoutHandler = logoutHandler; + } + +} diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcBackChannelLogoutHandler.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcBackChannelLogoutHandler.java new file mode 100644 index 0000000000..5fc6f5bfde --- /dev/null +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/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.config.annotation.web.configurers.oauth2.client; + +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.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 + */ +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 + */ + 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 + */ + 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 + */ + 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 + */ + void setSessionCookieName(String sessionCookieName) { + Assert.hasText(sessionCookieName, "clientSessionCookieName cannot be empty"); + this.sessionCookieName = sessionCookieName; + } + +} diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcBackChannelLogoutTokenValidator.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcBackChannelLogoutTokenValidator.java new file mode 100644 index 0000000000..7b6634f933 --- /dev/null +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcBackChannelLogoutTokenValidator.java @@ -0,0 +1,118 @@ +/* + * Copyright 2002-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.config.annotation.web.configurers.oauth2.client; + +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.oidc.authentication.logout.LogoutTokenClaimAccessor; +import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcLogoutToken; +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 + */ +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; + + 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/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutAuthenticationConverter.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutAuthenticationConverter.java new file mode 100644 index 0000000000..2809991894 --- /dev/null +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutAuthenticationConverter.java @@ -0,0 +1,85 @@ +/* + * 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 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.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 + */ +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"); + + 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 + */ + void setRequestMatcher(RequestMatcher requestMatcher) { + Assert.notNull(requestMatcher, "requestMatcher cannot be null"); + this.requestMatcher = requestMatcher; + } + +} diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutAuthenticationToken.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutAuthenticationToken.java new file mode 100644 index 0000000000..4a227e3be8 --- /dev/null +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/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.config.annotation.web.configurers.oauth2.client; + +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 + */ +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 + */ + 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 + */ + String getLogoutToken() { + return this.logoutToken; + } + + /** + * Get the {@link ClientRegistration} associated with this logout token + * @return the {@link ClientRegistration} + */ + ClientRegistration getClientRegistration() { + return this.clientRegistration; + } + +} 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 0000000000..e6e43b4df9 --- /dev/null +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurer.java @@ -0,0 +1,159 @@ +/* + * 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.session.OidcSessionRegistry; +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; + + /** + * Sets the repository of client registrations. + * @param clientRegistrationRepository the repository of client registrations + * @return the {@link OAuth2LoginConfigurer} for further configuration + */ + public OidcLogoutConfigurer clientRegistrationRepository( + ClientRegistrationRepository clientRegistrationRepository) { + Assert.notNull(clientRegistrationRepository, "clientRegistrationRepository cannot be null"); + this.getBuilder().setSharedObject(ClientRegistrationRepository.class, clientRegistrationRepository); + return this; + } + + /** + * Sets the registry for managing the OIDC client-provider session link + * @param oidcSessionRegistry the {@link OidcSessionRegistry} to use + * @return the {@link OAuth2LoginConfigurer} for further configuration + */ + public OidcLogoutConfigurer oidcSessionRegistry(OidcSessionRegistry oidcSessionRegistry) { + Assert.notNull(oidcSessionRegistry, "oidcSessionRegistry cannot be null"); + getBuilder().setSharedObject(OidcSessionRegistry.class, oidcSessionRegistry); + return this; + } + + /** + * 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 final AuthenticationManager authenticationManager = new ProviderManager( + new OidcBackChannelLogoutAuthenticationProvider()); + + private LogoutHandler logoutHandler; + + 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/main/java/org/springframework/security/config/web/server/DefaultOidcLogoutTokenValidatorFactory.java b/config/src/main/java/org/springframework/security/config/web/server/DefaultOidcLogoutTokenValidatorFactory.java new file mode 100644 index 0000000000..22c26aa8d7 --- /dev/null +++ b/config/src/main/java/org/springframework/security/config/web/server/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.config.web.server; + +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/config/src/main/java/org/springframework/security/config/web/server/OidcBackChannelLogoutAuthentication.java b/config/src/main/java/org/springframework/security/config/web/server/OidcBackChannelLogoutAuthentication.java new file mode 100644 index 0000000000..c68063b614 --- /dev/null +++ b/config/src/main/java/org/springframework/security/config/web/server/OidcBackChannelLogoutAuthentication.java @@ -0,0 +1,66 @@ +/* + * Copyright 2002-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.config.web.server; + +import java.util.Collections; + +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcLogoutToken; + +/** + * 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 + */ +class OidcBackChannelLogoutAuthentication extends AbstractAuthenticationToken { + + private final OidcLogoutToken logoutToken; + + /** + * Construct an {@link OidcBackChannelLogoutAuthentication} + * @param logoutToken a deserialized, verified OIDC Logout Token + */ + 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/config/src/main/java/org/springframework/security/config/web/server/OidcBackChannelLogoutReactiveAuthenticationManager.java b/config/src/main/java/org/springframework/security/config/web/server/OidcBackChannelLogoutReactiveAuthenticationManager.java new file mode 100644 index 0000000000..0ebbda5649 --- /dev/null +++ b/config/src/main/java/org/springframework/security/config/web/server/OidcBackChannelLogoutReactiveAuthenticationManager.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.config.web.server; + +import reactor.core.publisher.Mono; + +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.AuthenticationServiceException; +import org.springframework.security.authentication.ReactiveAuthenticationManager; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.oauth2.client.oidc.authentication.ReactiveOidcIdTokenDecoderFactory; +import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcLogoutToken; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; +import org.springframework.security.oauth2.jwt.BadJwtException; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtDecoderFactory; +import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder; +import org.springframework.security.oauth2.jwt.ReactiveJwtDecoderFactory; +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 + */ +final class OidcBackChannelLogoutReactiveAuthenticationManager implements ReactiveAuthenticationManager { + + private ReactiveJwtDecoderFactory logoutTokenDecoderFactory; + + /** + * Construct an {@link OidcBackChannelLogoutReactiveAuthenticationManager} + */ + OidcBackChannelLogoutReactiveAuthenticationManager() { + ReactiveOidcIdTokenDecoderFactory logoutTokenDecoderFactory = new ReactiveOidcIdTokenDecoderFactory(); + logoutTokenDecoderFactory.setJwtValidatorFactory(new DefaultOidcLogoutTokenValidatorFactory()); + this.logoutTokenDecoderFactory = logoutTokenDecoderFactory; + } + + /** + * {@inheritDoc} + */ + @Override + public Mono authenticate(Authentication authentication) throws AuthenticationException { + if (!(authentication instanceof OidcLogoutAuthenticationToken token)) { + return Mono.empty(); + } + String logoutToken = token.getLogoutToken(); + ClientRegistration registration = token.getClientRegistration(); + return decode(registration, logoutToken) + .map((jwt) -> OidcLogoutToken + .withTokenValue(logoutToken) + .claims((claims) -> claims.putAll(jwt.getClaims())).build() + ) + .map(OidcBackChannelLogoutAuthentication::new); + } + + private Mono decode(ClientRegistration registration, String token) { + ReactiveJwtDecoder 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"); + return Mono.error(new OAuth2AuthenticationException(error, failed)); + } + catch (Exception failed) { + return Mono.error(new AuthenticationServiceException(failed.getMessage(), failed)); + } + } + + /** + * Use this {@link ReactiveJwtDecoderFactory} to generate {@link JwtDecoder}s that + * correspond to the {@link ClientRegistration} associated with the OIDC logout token. + * @param logoutTokenDecoderFactory the {@link JwtDecoderFactory} to use + */ + void setLogoutTokenDecoderFactory(ReactiveJwtDecoderFactory logoutTokenDecoderFactory) { + Assert.notNull(logoutTokenDecoderFactory, "logoutTokenDecoderFactory cannot be null"); + this.logoutTokenDecoderFactory = logoutTokenDecoderFactory; + } + +} diff --git a/config/src/main/java/org/springframework/security/config/web/server/OidcBackChannelLogoutTokenValidator.java b/config/src/main/java/org/springframework/security/config/web/server/OidcBackChannelLogoutTokenValidator.java new file mode 100644 index 0000000000..7053689171 --- /dev/null +++ b/config/src/main/java/org/springframework/security/config/web/server/OidcBackChannelLogoutTokenValidator.java @@ -0,0 +1,118 @@ +/* + * Copyright 2002-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.config.web.server; + +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.oidc.authentication.logout.LogoutTokenClaimAccessor; +import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcLogoutToken; +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 + */ +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; + + 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/config/src/main/java/org/springframework/security/config/web/server/OidcBackChannelLogoutWebFilter.java b/config/src/main/java/org/springframework/security/config/web/server/OidcBackChannelLogoutWebFilter.java new file mode 100644 index 0000000000..9955952362 --- /dev/null +++ b/config/src/main/java/org/springframework/security/config/web/server/OidcBackChannelLogoutWebFilter.java @@ -0,0 +1,135 @@ +/* + * 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.web.server; + +import java.nio.charset.StandardCharsets; + +import jakarta.servlet.http.HttpServletResponse; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.http.server.reactive.ServerHttpResponse; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.AuthenticationServiceException; +import org.springframework.security.authentication.ReactiveAuthenticationManager; +import org.springframework.security.core.AuthenticationException; +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.web.authentication.AuthenticationConverter; +import org.springframework.security.web.authentication.logout.LogoutHandler; +import org.springframework.security.web.server.WebFilterExchange; +import org.springframework.security.web.server.authentication.ServerAuthenticationConverter; +import org.springframework.security.web.server.authentication.logout.ServerLogoutHandler; +import org.springframework.util.Assert; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.WebFilter; +import org.springframework.web.server.WebFilterChain; + +/** + * A filter for the Client-side OIDC Back-Channel Logout endpoint + * + * @author Josh Cummings + * @since 6.2 + * @see OIDC Back-Channel Logout + * Spec + */ +class OidcBackChannelLogoutWebFilter implements WebFilter { + + private final Log logger = LogFactory.getLog(getClass()); + + private final ServerAuthenticationConverter authenticationConverter; + + private final ReactiveAuthenticationManager authenticationManager; + + private ServerLogoutHandler logoutHandler = new OidcBackChannelServerLogoutHandler(); + + /** + * Construct an {@link OidcBackChannelLogoutWebFilter} + * @param authenticationConverter the {@link AuthenticationConverter} for deriving + * Logout Token authentication + * @param authenticationManager the {@link AuthenticationManager} for authenticating + * Logout Tokens + */ + OidcBackChannelLogoutWebFilter(ServerAuthenticationConverter authenticationConverter, + ReactiveAuthenticationManager authenticationManager) { + Assert.notNull(authenticationConverter, "authenticationConverter cannot be null"); + Assert.notNull(authenticationManager, "authenticationManager cannot be null"); + this.authenticationConverter = authenticationConverter; + this.authenticationManager = authenticationManager; + } + + @Override + public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { + return this.authenticationConverter.convert(exchange).onErrorResume(AuthenticationException.class, (ex) -> { + this.logger.debug("Failed to process OIDC Back-Channel Logout", ex); + if (ex instanceof AuthenticationServiceException) { + return Mono.error(ex); + } + return handleAuthenticationFailure(exchange.getResponse(), ex).then(Mono.empty()); + }).switchIfEmpty(chain.filter(exchange).then(Mono.empty())).flatMap(this.authenticationManager::authenticate) + .onErrorResume(AuthenticationException.class, (ex) -> { + this.logger.debug("Failed to process OIDC Back-Channel Logout", ex); + if (ex instanceof AuthenticationServiceException) { + return Mono.error(ex); + } + return handleAuthenticationFailure(exchange.getResponse(), ex).then(Mono.empty()); + }).flatMap((authentication) -> { + WebFilterExchange webFilterExchange = new WebFilterExchange(exchange, chain); + return this.logoutHandler.logout(webFilterExchange, authentication); + }); + } + + private Mono handleAuthenticationFailure(ServerHttpResponse response, Exception ex) { + this.logger.debug("Failed to process OIDC Back-Channel Logout", ex); + response.setRawStatusCode(HttpServletResponse.SC_BAD_REQUEST); + OAuth2Error error = oauth2Error(ex); + byte[] bytes = String.format(""" + { + "error_code": "%s", + "error_description": "%s", + "error_uri: "%s" + } + """, error.getErrorCode(), error.getDescription(), error.getUri()) + .getBytes(StandardCharsets.UTF_8); + DataBuffer buffer = response.bufferFactory().wrap(bytes); + return response.writeWith(Flux.just(buffer)); + } + + 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 OidcBackChannelServerLogoutHandler}. + * @param logoutHandler the {@link LogoutHandler} to use + */ + void setLogoutHandler(ServerLogoutHandler logoutHandler) { + Assert.notNull(logoutHandler, "logoutHandler cannot be null"); + this.logoutHandler = logoutHandler; + } + +} diff --git a/config/src/main/java/org/springframework/security/config/web/server/OidcBackChannelServerLogoutHandler.java b/config/src/main/java/org/springframework/security/config/web/server/OidcBackChannelServerLogoutHandler.java new file mode 100644 index 0000000000..f71e62a4ba --- /dev/null +++ b/config/src/main/java/org/springframework/security/config/web/server/OidcBackChannelServerLogoutHandler.java @@ -0,0 +1,183 @@ +/* + * 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.web.server; + +import java.nio.charset.StandardCharsets; +import java.util.Collection; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import jakarta.servlet.http.HttpServletResponse; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.http.server.reactive.ServerHttpResponse; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcLogoutToken; +import org.springframework.security.oauth2.client.oidc.server.session.InMemoryReactiveOidcSessionRegistry; +import org.springframework.security.oauth2.client.oidc.server.session.ReactiveOidcSessionRegistry; +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.web.server.WebFilterExchange; +import org.springframework.security.web.server.authentication.logout.ServerLogoutHandler; +import org.springframework.util.Assert; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.util.UriComponentsBuilder; + +/** + * A {@link ServerLogoutHandler} 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 + */ +final class OidcBackChannelServerLogoutHandler implements ServerLogoutHandler { + + private final Log logger = LogFactory.getLog(getClass()); + + private ReactiveOidcSessionRegistry sessionRegistry = new InMemoryReactiveOidcSessionRegistry(); + + private WebClient web = WebClient.create(); + + private String logoutEndpointName = "/logout"; + + private String sessionCookieName = "SESSION"; + + @Override + public Mono logout(WebFilterExchange exchange, Authentication authentication) { + if (!(authentication instanceof OidcBackChannelLogoutAuthentication token)) { + return Mono.defer(() -> { + 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 Mono.empty(); + }); + } + AtomicInteger totalCount = new AtomicInteger(0); + AtomicInteger invalidatedCount = new AtomicInteger(0); + return this.sessionRegistry.removeSessionInformation(token.getPrincipal()) + .concatMap((session) -> { + totalCount.incrementAndGet(); + return eachLogout(exchange, session) + .flatMap((response) -> { + invalidatedCount.incrementAndGet(); + return Mono.empty(); + }) + .onErrorResume((ex) -> { + this.logger.debug("Failed to invalidate session", ex); + return this.sessionRegistry.saveSessionInformation(session) + .then(Mono.just(ex.getMessage())); + }); + }).collectList().flatMap((list) -> { + if (this.logger.isTraceEnabled()) { + this.logger.trace(String.format("Invalidated %d out of %d sessions", invalidatedCount.intValue(), totalCount.intValue())); + } + if (!list.isEmpty()) { + return handleLogoutFailure(exchange.getExchange().getResponse(), oauth2Error(list)); + } + else { + return Mono.empty(); + } + }); + } + + private Mono> eachLogout(WebFilterExchange exchange, 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 = exchange.getExchange().getRequest().getURI().toString(); + String logout = UriComponentsBuilder.fromHttpUrl(url).replacePath(this.logoutEndpointName).build() + .toUriString(); + return this.web.post().uri(logout).headers((h) -> h.putAll(headers)).retrieve().toBodilessEntity(); + } + + 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 Mono handleLogoutFailure(ServerHttpResponse response, OAuth2Error error) { + response.setRawStatusCode(HttpServletResponse.SC_BAD_REQUEST); + byte[] bytes = String.format(""" + { + "error_code": "%s", + "error_description": "%s", + "error_uri: "%s" + } + """, error.getErrorCode(), error.getDescription(), error.getUri()) + .getBytes(StandardCharsets.UTF_8); + DataBuffer buffer = response.bufferFactory().wrap(bytes); + return response.writeWith(Flux.just(buffer)); + } + + /** + * 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 + */ + void setSessionRegistry(ReactiveOidcSessionRegistry sessionRegistry) { + Assert.notNull(sessionRegistry, "sessionRegistry cannot be null"); + this.sessionRegistry = sessionRegistry; + } + + /** + * Use this {@link WebClient} to perform the per-session back-channel logout + * @param web the {@link WebClient} to use + */ + void setWebClient(WebClient web) { + Assert.notNull(web, "web cannot be null"); + this.web = web; + } + + /** + * 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 + */ + 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 + */ + void setSessionCookieName(String sessionCookieName) { + Assert.hasText(sessionCookieName, "clientSessionCookieName cannot be empty"); + this.sessionCookieName = sessionCookieName; + } + +} diff --git a/config/src/main/java/org/springframework/security/config/web/server/OidcLogoutAuthenticationToken.java b/config/src/main/java/org/springframework/security/config/web/server/OidcLogoutAuthenticationToken.java new file mode 100644 index 0000000000..8d5ab818a5 --- /dev/null +++ b/config/src/main/java/org/springframework/security/config/web/server/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.config.web.server; + +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 + */ +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 + */ + 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 + */ + String getLogoutToken() { + return this.logoutToken; + } + + /** + * Get the {@link ClientRegistration} associated with this logout token + * @return the {@link ClientRegistration} + */ + ClientRegistration getClientRegistration() { + return this.clientRegistration; + } + +} diff --git a/config/src/main/java/org/springframework/security/config/web/server/OidcLogoutServerAuthenticationConverter.java b/config/src/main/java/org/springframework/security/config/web/server/OidcLogoutServerAuthenticationConverter.java new file mode 100644 index 0000000000..988b92e6da --- /dev/null +++ b/config/src/main/java/org/springframework/security/config/web/server/OidcLogoutServerAuthenticationConverter.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.config.web.server; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import reactor.core.publisher.Mono; + +import org.springframework.http.HttpMethod; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.client.registration.ReactiveClientRegistrationRepository; +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.server.authentication.ServerAuthenticationConverter; +import org.springframework.security.web.server.util.matcher.PathPatternParserServerWebExchangeMatcher; +import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher; +import org.springframework.util.Assert; +import org.springframework.web.server.ServerWebExchange; + +/** + * An {@link AuthenticationConverter} that extracts the OIDC Logout Token authentication + * request + * + * @author Josh Cummings + * @since 6.2 + */ +final class OidcLogoutServerAuthenticationConverter implements ServerAuthenticationConverter { + + private static final String DEFAULT_LOGOUT_URI = "/logout/connect/back-channel/{registrationId}"; + + private final Log logger = LogFactory.getLog(getClass()); + + private final ReactiveClientRegistrationRepository clientRegistrationRepository; + + private ServerWebExchangeMatcher exchangeMatcher = new PathPatternParserServerWebExchangeMatcher(DEFAULT_LOGOUT_URI, + HttpMethod.POST); + + OidcLogoutServerAuthenticationConverter(ReactiveClientRegistrationRepository clientRegistrationRepository) { + Assert.notNull(clientRegistrationRepository, "clientRegistrationRepository cannot be null"); + this.clientRegistrationRepository = clientRegistrationRepository; + } + + @Override + public Mono convert(ServerWebExchange exchange) { + return this.exchangeMatcher.matches(exchange).filter(ServerWebExchangeMatcher.MatchResult::isMatch) + .flatMap((match) -> { + String registrationId = (String) match.getVariables().get("registrationId"); + return this.clientRegistrationRepository.findByRegistrationId(registrationId) + .switchIfEmpty(Mono.error(() -> { + this.logger.debug( + "Did not process OIDC Back-Channel Logout since no ClientRegistration was found"); + return new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_REQUEST); + })); + }).flatMap((clientRegistration) -> exchange.getFormData().map((data) -> { + String logoutToken = data.getFirst("logout_token"); + return new OidcLogoutAuthenticationToken(logoutToken, clientRegistration); + }).switchIfEmpty(Mono.error(() -> { + this.logger.debug("Failed to process OIDC Back-Channel Logout since no logout token was found"); + return new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_REQUEST); + }))); + } + + /** + * The logout endpoint. Defaults to + * {@code /logout/connect/back-channel/{registrationId}}. + * @param exchangeMatcher the {@link ServerWebExchangeMatcher} to use + */ + void setExchangeMatcher(ServerWebExchangeMatcher exchangeMatcher) { + Assert.notNull(exchangeMatcher, "exchangeMatcher cannot be null"); + this.exchangeMatcher = exchangeMatcher; + } + +} diff --git a/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java b/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java index 4c9a8f205f..3f17c1c995 100644 --- a/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java +++ b/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java @@ -21,6 +21,7 @@ import java.io.PrintWriter; import java.io.StringWriter; import java.security.interfaces.RSAPublicKey; import java.time.Duration; +import java.time.Instant; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -28,10 +29,13 @@ import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.UUID; +import java.util.function.Consumer; import java.util.function.Function; import java.util.function.Supplier; import io.micrometer.observation.ObservationRegistry; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; import reactor.core.publisher.Mono; import reactor.util.context.Context; @@ -67,6 +71,9 @@ import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCo import org.springframework.security.oauth2.client.endpoint.ReactiveOAuth2AccessTokenResponseClient; import org.springframework.security.oauth2.client.endpoint.WebClientReactiveAuthorizationCodeTokenResponseClient; import org.springframework.security.oauth2.client.oidc.authentication.OidcAuthorizationCodeReactiveAuthenticationManager; +import org.springframework.security.oauth2.client.oidc.server.session.InMemoryReactiveOidcSessionRegistry; +import org.springframework.security.oauth2.client.oidc.server.session.ReactiveOidcSessionRegistry; +import org.springframework.security.oauth2.client.oidc.session.OidcSessionInformation; import org.springframework.security.oauth2.client.oidc.userinfo.OidcReactiveOAuth2UserService; import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest; import org.springframework.security.oauth2.client.registration.ClientRegistration; @@ -113,6 +120,7 @@ import org.springframework.security.web.server.MatcherSecurityWebFilterChain; import org.springframework.security.web.server.SecurityWebFilterChain; import org.springframework.security.web.server.ServerAuthenticationEntryPoint; import org.springframework.security.web.server.ServerRedirectStrategy; +import org.springframework.security.web.server.WebFilterExchange; import org.springframework.security.web.server.authentication.AnonymousAuthenticationWebFilter; import org.springframework.security.web.server.authentication.AuthenticationConverterServerWebExchangeMatcher; import org.springframework.security.web.server.authentication.AuthenticationWebFilter; @@ -147,6 +155,7 @@ import org.springframework.security.web.server.context.SecurityContextServerWebE import org.springframework.security.web.server.context.ServerSecurityContextRepository; import org.springframework.security.web.server.context.WebSessionServerSecurityContextRepository; import org.springframework.security.web.server.csrf.CsrfServerLogoutHandler; +import org.springframework.security.web.server.csrf.CsrfToken; import org.springframework.security.web.server.csrf.CsrfWebFilter; import org.springframework.security.web.server.csrf.ServerCsrfTokenRepository; import org.springframework.security.web.server.csrf.ServerCsrfTokenRequestHandler; @@ -193,8 +202,10 @@ import org.springframework.web.cors.reactive.CorsWebFilter; import org.springframework.web.cors.reactive.DefaultCorsProcessor; import org.springframework.web.reactive.result.method.annotation.RequestMappingHandlerMapping; import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.ServerWebExchangeDecorator; import org.springframework.web.server.WebFilter; import org.springframework.web.server.WebFilterChain; +import org.springframework.web.server.WebSession; import org.springframework.web.util.pattern.PathPatternParser; /** @@ -295,6 +306,8 @@ public class ServerHttpSecurity { private OAuth2ClientSpec client; + private OidcLogoutSpec oidcLogout; + private LogoutSpec logout = new LogoutSpec(); private LoginPageSpec loginPage = new LoginPageSpec(); @@ -1093,6 +1106,33 @@ public class ServerHttpSecurity { return this; } + /** + * Configures OIDC Connect 1.0 Logout support. + * + *

    +	 *  @Bean
    +	 *  public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
    +	 *      http
    +	 *          // ...
    +	 *          .oidcLogout((logout) -> logout
    +	 *              .backChannel(Customizer.withDefaults())
    +	 *          );
    +	 *      return http.build();
    +	 *  }
    +	 * 
    + * @param oidcLogoutCustomizer the {@link Customizer} to provide more options for the + * {@link OidcLogoutSpec} + * @return the {@link ServerHttpSecurity} to customize + * @since 6.2 + */ + public ServerHttpSecurity oidcLogout(Customizer oidcLogoutCustomizer) { + if (this.oidcLogout == null) { + this.oidcLogout = new OidcLogoutSpec(); + } + oidcLogoutCustomizer.customize(this.oidcLogout); + return this; + } + /** * Configures HTTP Response Headers. The default headers are: * @@ -1537,6 +1577,9 @@ public class ServerHttpSecurity { if (this.resourceServer != null) { this.resourceServer.configure(this); } + if (this.oidcLogout != null) { + this.oidcLogout.configure(this); + } if (this.client != null) { this.client.configure(this); } @@ -3689,6 +3732,8 @@ public class ServerHttpSecurity { private ServerWebExchangeMatcher authenticationMatcher; + private ReactiveOidcSessionRegistry oidcSessionRegistry; + private ServerAuthenticationSuccessHandler authenticationSuccessHandler; private ServerAuthenticationFailureHandler authenticationFailureHandler; @@ -3720,6 +3765,20 @@ public class ServerHttpSecurity { return this; } + /** + * Configures the {@link ReactiveOidcSessionRegistry} to use when logins use OIDC. + * Default is to look the value up as a Bean, or else use an + * {@link InMemoryReactiveOidcSessionRegistry}. + * @param oidcSessionRegistry the registry to use + * @return the {@link OidcLogoutSpec} to customize + * @since 6.2 + */ + public OAuth2LoginSpec oidcSessionRegistry(ReactiveOidcSessionRegistry oidcSessionRegistry) { + Assert.notNull(oidcSessionRegistry, "oidcSessionRegistry cannot be null"); + this.oidcSessionRegistry = oidcSessionRegistry; + return this; + } + /** * The {@link ServerAuthenticationSuccessHandler} used after authentication * success. Defaults to {@link RedirectServerAuthenticationSuccessHandler} @@ -3913,8 +3972,9 @@ public class ServerHttpSecurity { oauthRedirectFilter.setRequestCache(http.requestCache.requestCache); ReactiveAuthenticationManager manager = getAuthenticationManager(); - AuthenticationWebFilter authenticationFilter = new OAuth2LoginAuthenticationWebFilter(manager, - authorizedClientRepository); + ReactiveOidcSessionRegistry sessionRegistry = getOidcSessionRegistry(); + AuthenticationWebFilter authenticationFilter = new OidcSessionRegistryAuthenticationWebFilter(manager, + authorizedClientRepository, sessionRegistry); authenticationFilter.setRequiresAuthenticationMatcher(getAuthenticationMatcher()); authenticationFilter .setServerAuthenticationConverter(getAuthenticationConverter(clientRegistrationRepository)); @@ -3923,6 +3983,8 @@ public class ServerHttpSecurity { authenticationFilter.setSecurityContextRepository(this.securityContextRepository); setDefaultEntryPoints(http); + http.addFilterAfter(new OidcSessionRegistryWebFilter(sessionRegistry), + SecurityWebFiltersOrder.HTTP_HEADERS_WRITER); http.addFilterAt(oauthRedirectFilter, SecurityWebFiltersOrder.HTTP_BASIC); http.addFilterAt(authenticationFilter, SecurityWebFiltersOrder.AUTHENTICATION); } @@ -3967,6 +4029,16 @@ public class ServerHttpSecurity { http.defaultEntryPoints.add(new DelegateEntry(defaultEntryPointMatcher, defaultEntryPoint)); } + private ReactiveOidcSessionRegistry getOidcSessionRegistry() { + if (this.oidcSessionRegistry == null) { + this.oidcSessionRegistry = getBeanOrNull(ReactiveOidcSessionRegistry.class); + } + if (this.oidcSessionRegistry == null) { + this.oidcSessionRegistry = new InMemoryReactiveOidcSessionRegistry(); + } + return this.oidcSessionRegistry; + } + private ServerAuthenticationSuccessHandler getAuthenticationSuccessHandler(ServerHttpSecurity http) { if (this.authenticationSuccessHandler == null) { RedirectServerAuthenticationSuccessHandler handler = new RedirectServerAuthenticationSuccessHandler(); @@ -4083,6 +4155,154 @@ public class ServerHttpSecurity { return new InMemoryReactiveOAuth2AuthorizedClientService(getClientRegistrationRepository()); } + private static final class OidcSessionRegistryWebFilter implements WebFilter { + + private final ReactiveOidcSessionRegistry oidcSessionRegistry; + + OidcSessionRegistryWebFilter(ReactiveOidcSessionRegistry oidcSessionRegistry) { + this.oidcSessionRegistry = oidcSessionRegistry; + } + + @Override + public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { + return chain.filter(new OidcSessionRegistryServerWebExchange(exchange)); + } + + private final class OidcSessionRegistryServerWebExchange extends ServerWebExchangeDecorator { + + private final Mono sessionMono; + + protected OidcSessionRegistryServerWebExchange(ServerWebExchange delegate) { + super(delegate); + this.sessionMono = delegate.getSession().map(OidcSessionRegistryWebSession::new); + } + + @Override + public Mono getSession() { + return this.sessionMono; + } + + private final class OidcSessionRegistryWebSession implements WebSession { + + private final WebSession session; + + OidcSessionRegistryWebSession(WebSession session) { + this.session = session; + } + + @Override + public String getId() { + return this.session.getId(); + } + + @Override + public Map getAttributes() { + return this.session.getAttributes(); + } + + @Override + public void start() { + this.session.start(); + } + + @Override + public boolean isStarted() { + return this.session.isStarted(); + } + + @Override + public Mono changeSessionId() { + String currentId = this.session.getId(); + return this.session.changeSessionId() + .then(Mono.defer(() -> OidcSessionRegistryWebFilter.this.oidcSessionRegistry + .removeSessionInformation(currentId).flatMap((information) -> { + information = information.withSessionId(this.session.getId()); + return OidcSessionRegistryWebFilter.this.oidcSessionRegistry + .saveSessionInformation(information); + }))); + } + + @Override + public Mono invalidate() { + String currentId = this.session.getId(); + return this.session.invalidate() + .then(Mono.defer(() -> OidcSessionRegistryWebFilter.this.oidcSessionRegistry + .removeSessionInformation(currentId).then(Mono.empty()))); + } + + @Override + public Mono save() { + return this.session.save(); + } + + @Override + public boolean isExpired() { + return this.session.isExpired(); + } + + @Override + public Instant getCreationTime() { + return this.session.getCreationTime(); + } + + @Override + public Instant getLastAccessTime() { + return this.session.getLastAccessTime(); + } + + @Override + public void setMaxIdleTime(Duration maxIdleTime) { + this.session.setMaxIdleTime(maxIdleTime); + } + + @Override + public Duration getMaxIdleTime() { + return this.session.getMaxIdleTime(); + } + + } + + } + + } + + private static final class OidcSessionRegistryAuthenticationWebFilter + extends OAuth2LoginAuthenticationWebFilter { + + private final Log logger = LogFactory.getLog(getClass()); + + private final ReactiveOidcSessionRegistry oidcSessionRegistry; + + OidcSessionRegistryAuthenticationWebFilter(ReactiveAuthenticationManager authenticationManager, + ServerOAuth2AuthorizedClientRepository authorizedClientRepository, + ReactiveOidcSessionRegistry oidcSessionRegistry) { + super(authenticationManager, authorizedClientRepository); + this.oidcSessionRegistry = oidcSessionRegistry; + } + + @Override + protected Mono onAuthenticationSuccess(Authentication authentication, WebFilterExchange webFilterExchange) { + if (!(authentication.getPrincipal() instanceof OidcUser user)) { + return super.onAuthenticationSuccess(authentication, webFilterExchange); + } + return webFilterExchange.getExchange().getSession() + .doOnNext((session) -> { + if (this.logger.isTraceEnabled()) { + this.logger.trace(String.format("Linking a provider [%s] session to this client's session", user.getIssuer())); + } + }) + .flatMap((session) -> { + Mono csrfToken = webFilterExchange.getExchange().getAttribute(CsrfToken.class.getName()); + return (csrfToken != null) ? + csrfToken.map((token) -> new OidcSessionInformation(session.getId(), Map.of(token.getHeaderName(), token.getToken()), user)) : + Mono.just(new OidcSessionInformation(session.getId(), Map.of(), user)); + }) + .flatMap(this.oidcSessionRegistry::saveSessionInformation) + .then(super.onAuthenticationSuccess(authentication, webFilterExchange)); + } + + } + } public final class OAuth2ClientSpec { @@ -4755,6 +4975,129 @@ public class ServerHttpSecurity { } + /** + * Configures OIDC 1.0 Logout support + * + * @author Josh Cummings + * @since 6.2 + */ + public final class OidcLogoutSpec { + + private ReactiveClientRegistrationRepository clientRegistrationRepository; + + private ReactiveOidcSessionRegistry sessionRegistry; + + private BackChannelLogoutConfigurer backChannel; + + /** + * Configures the {@link ReactiveClientRegistrationRepository}. Default is to look + * the value up as a Bean. + * @param clientRegistrationRepository the repository to use + * @return the {@link OidcLogoutSpec} to customize + */ + public OidcLogoutSpec clientRegistrationRepository( + ReactiveClientRegistrationRepository clientRegistrationRepository) { + Assert.notNull(clientRegistrationRepository, "clientRegistrationRepository cannot be null"); + this.clientRegistrationRepository = clientRegistrationRepository; + return this; + } + + /** + * Configures the {@link ReactiveOidcSessionRegistry}. Default is to use the value + * from {@link OAuth2LoginSpec#oidcSessionRegistry}, then look the value up as a + * Bean, or else use an {@link InMemoryReactiveOidcSessionRegistry}. + * @param sessionRegistry the registry to use + * @return the {@link OidcLogoutSpec} to customize + */ + public OidcLogoutSpec oidcSessionRegistry(ReactiveOidcSessionRegistry sessionRegistry) { + Assert.notNull(sessionRegistry, "sessionRegistry cannot be null"); + this.sessionRegistry = sessionRegistry; + return this; + } + + /** + * Configure OIDC Back-Channel Logout using the provided {@link Consumer} + * @return the {@link OidcLogoutSpec} for further configuration + */ + public OidcLogoutSpec backChannel(Customizer backChannelLogoutConfigurer) { + if (this.backChannel == null) { + this.backChannel = new OidcLogoutSpec.BackChannelLogoutConfigurer(); + } + backChannelLogoutConfigurer.customize(this.backChannel); + return this; + } + + @Deprecated(forRemoval = true, since = "6.2") + public ServerHttpSecurity and() { + return ServerHttpSecurity.this; + } + + void configure(ServerHttpSecurity http) { + if (this.backChannel != null) { + this.backChannel.configure(http); + } + } + + private ReactiveClientRegistrationRepository getClientRegistrationRepository() { + if (this.clientRegistrationRepository == null) { + this.clientRegistrationRepository = getBeanOrNull(ReactiveClientRegistrationRepository.class); + } + return this.clientRegistrationRepository; + } + + private ReactiveOidcSessionRegistry getSessionRegistry() { + if (this.sessionRegistry == null && ServerHttpSecurity.this.oauth2Login == null) { + return new InMemoryReactiveOidcSessionRegistry(); + } + if (this.sessionRegistry == null) { + return ServerHttpSecurity.this.oauth2Login.oidcSessionRegistry; + } + return this.sessionRegistry; + } + + /** + * A configurer for configuring OIDC Back-Channel Logout + */ + public final class BackChannelLogoutConfigurer { + + private ServerAuthenticationConverter authenticationConverter; + + private final ReactiveAuthenticationManager authenticationManager = new OidcBackChannelLogoutReactiveAuthenticationManager(); + + private ServerLogoutHandler logoutHandler; + + private ServerAuthenticationConverter authenticationConverter() { + if (this.authenticationConverter == null) { + this.authenticationConverter = new OidcLogoutServerAuthenticationConverter( + OidcLogoutSpec.this.getClientRegistrationRepository()); + } + return this.authenticationConverter; + } + + private ReactiveAuthenticationManager authenticationManager() { + return this.authenticationManager; + } + + private ServerLogoutHandler logoutHandler() { + if (this.logoutHandler == null) { + OidcBackChannelServerLogoutHandler logoutHandler = new OidcBackChannelServerLogoutHandler(); + logoutHandler.setSessionRegistry(OidcLogoutSpec.this.getSessionRegistry()); + this.logoutHandler = logoutHandler; + } + return this.logoutHandler; + } + + void configure(ServerHttpSecurity http) { + OidcBackChannelLogoutWebFilter filter = new OidcBackChannelLogoutWebFilter(authenticationConverter(), + authenticationManager()); + filter.setLogoutHandler(logoutHandler()); + http.addFilterBefore(filter, SecurityWebFiltersOrder.CSRF); + } + + } + + } + /** * Configures anonymous authentication * diff --git a/config/src/main/kotlin/org/springframework/security/config/annotation/web/HttpSecurityDsl.kt b/config/src/main/kotlin/org/springframework/security/config/annotation/web/HttpSecurityDsl.kt index 6d992692ef..60342d2af8 100644 --- a/config/src/main/kotlin/org/springframework/security/config/annotation/web/HttpSecurityDsl.kt +++ b/config/src/main/kotlin/org/springframework/security/config/annotation/web/HttpSecurityDsl.kt @@ -868,6 +868,38 @@ class HttpSecurityDsl(private val http: HttpSecurity, private val init: HttpSecu this.http.oauth2ResourceServer(oauth2ResourceServerCustomizer) } + /** + * Configures OIDC 1.0 logout support. + * + * Example: + * + * ``` + * @Configuration + * @EnableWebSecurity + * class SecurityConfig { + * + * @Bean + * fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { + * http { + * oauth2Login { } + * oidcLogout { + * backChannel { } + * } + * } + * return http.build() + * } + * } + * ``` + * + * @param oidcLogoutConfiguration custom configuration to configure the + * OIDC 1.0 logout support + * @see [OidcLogoutDsl] + */ + fun oidcLogout(oidcLogoutConfiguration: OidcLogoutDsl.() -> Unit) { + val oidcLogoutCustomizer = OidcLogoutDsl().apply(oidcLogoutConfiguration).get() + this.http.oidcLogout(oidcLogoutCustomizer) + } + /** * Configures Remember Me authentication. * diff --git a/config/src/main/kotlin/org/springframework/security/config/annotation/web/OidcLogoutDsl.kt b/config/src/main/kotlin/org/springframework/security/config/annotation/web/OidcLogoutDsl.kt new file mode 100644 index 0000000000..f9fdd7dc4d --- /dev/null +++ b/config/src/main/kotlin/org/springframework/security/config/annotation/web/OidcLogoutDsl.kt @@ -0,0 +1,75 @@ +/* + * 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 + +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.config.annotation.web.configurers.oauth2.client.OidcLogoutConfigurer +import org.springframework.security.config.annotation.web.oauth2.login.OidcBackChannelLogoutDsl +import org.springframework.security.oauth2.client.oidc.session.OidcSessionRegistry +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository + +/** + * A Kotlin DSL to configure [HttpSecurity] OAuth 1.0 Logout using idiomatic Kotlin code. + * + * @author Josh Cummings + * @since 6.2 + */ +@SecurityMarker +class OidcLogoutDsl { + var clientRegistrationRepository: ClientRegistrationRepository? = null + var oidcSessionRegistry: OidcSessionRegistry? = null + + private var backChannel: ((OidcLogoutConfigurer.BackChannelLogoutConfigurer) -> Unit)? = null + + /** + * Configures the OIDC 1.0 Back-Channel endpoint. + * + * Example: + * + * ``` + * @Configuration + * @EnableWebSecurity + * class SecurityConfig { + * + * @Bean + * fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { + * http { + * oauth2Login { } + * oidcLogout { + * backChannel { } + * } + * } + * return http.build() + * } + * } + * ``` + * + * @param backChannelConfig custom configurations to configure the back-channel endpoint + * @see [OidcBackChannelLogoutDsl] + */ + fun backChannel(backChannelConfig: OidcBackChannelLogoutDsl.() -> Unit) { + this.backChannel = OidcBackChannelLogoutDsl().apply(backChannelConfig).get() + } + + internal fun get(): (OidcLogoutConfigurer) -> Unit { + return { oidcLogout -> + clientRegistrationRepository?.also { oidcLogout.clientRegistrationRepository(clientRegistrationRepository) } + oidcSessionRegistry?.also { oidcLogout.oidcSessionRegistry(oidcSessionRegistry) } + backChannel?.also { oidcLogout.backChannel(backChannel) } + } + } +} diff --git a/config/src/main/kotlin/org/springframework/security/config/annotation/web/oauth2/login/OidcBackChannelLogoutDsl.kt b/config/src/main/kotlin/org/springframework/security/config/annotation/web/oauth2/login/OidcBackChannelLogoutDsl.kt new file mode 100644 index 0000000000..efac77a566 --- /dev/null +++ b/config/src/main/kotlin/org/springframework/security/config/annotation/web/oauth2/login/OidcBackChannelLogoutDsl.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2002-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.config.annotation.web.oauth2.login + +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.config.annotation.web.configurers.oauth2.client.OidcLogoutConfigurer + +/** + * A Kotlin DSL to configure the OIDC 1.0 Back-Channel configuration using + * idiomatic Kotlin code. + * + * @author Josh Cummings + * @since 6.2 + */ +@OAuth2LoginSecurityMarker +class OidcBackChannelLogoutDsl { + internal fun get(): (OidcLogoutConfigurer.BackChannelLogoutConfigurer) -> Unit { + return { backChannel -> } + } +} diff --git a/config/src/main/kotlin/org/springframework/security/config/web/server/ServerHttpSecurityDsl.kt b/config/src/main/kotlin/org/springframework/security/config/web/server/ServerHttpSecurityDsl.kt index ce17734444..300a3d6a60 100644 --- a/config/src/main/kotlin/org/springframework/security/config/web/server/ServerHttpSecurityDsl.kt +++ b/config/src/main/kotlin/org/springframework/security/config/web/server/ServerHttpSecurityDsl.kt @@ -1,5 +1,5 @@ /* - * Copyright 2002-2021 the original author or authors. + * 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. @@ -650,6 +650,38 @@ class ServerHttpSecurityDsl(private val http: ServerHttpSecurity, private val in this.http.oauth2ResourceServer(oauth2ResourceServerCustomizer) } + /** + * Configures logout support using an OpenID Connect 1.0 Provider. + * A [ReactiveClientRegistrationRepository] is required and must be registered as a Bean or + * configured via [ServerOidcLogoutDsl.clientRegistrationRepository]. + * + * Example: + * + * ``` + * @Configuration + * @EnableWebFluxSecurity + * class SecurityConfig { + * + * @Bean + * fun springWebFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain { + * return http { + * oauth2Login { } + * oidcLogout { + * backChannel { } + * } + * } + * } + * } + * ``` + * + * @param oidcLogoutConfiguration custom configuration to configure the OIDC 1.0 Logout + * @see [ServerOidcLogoutDsl] + */ + fun oidcLogout(oidcLogoutConfiguration: ServerOidcLogoutDsl.() -> Unit) { + val oidcLogoutCustomizer = ServerOidcLogoutDsl().apply(oidcLogoutConfiguration).get() + this.http.oidcLogout(oidcLogoutCustomizer) + } + /** * Apply all configurations to the provided [ServerHttpSecurity] */ diff --git a/config/src/main/kotlin/org/springframework/security/config/web/server/ServerOidcBackChannelLogoutDsl.kt b/config/src/main/kotlin/org/springframework/security/config/web/server/ServerOidcBackChannelLogoutDsl.kt new file mode 100644 index 0000000000..5a245e5092 --- /dev/null +++ b/config/src/main/kotlin/org/springframework/security/config/web/server/ServerOidcBackChannelLogoutDsl.kt @@ -0,0 +1,30 @@ +/* + * 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.web.server + +/** + * A Kotlin DSL to configure [ServerHttpSecurity] OIDC 1.0 Back-Channel Logout support using idiomatic Kotlin code. + * + * @author Josh Cummings + * @since 6.2 + */ +@ServerSecurityMarker +class ServerOidcBackChannelLogoutDsl { + internal fun get(): (ServerHttpSecurity.OidcLogoutSpec.BackChannelLogoutConfigurer) -> Unit { + return { backChannel -> } + } +} diff --git a/config/src/main/kotlin/org/springframework/security/config/web/server/ServerOidcLogoutDsl.kt b/config/src/main/kotlin/org/springframework/security/config/web/server/ServerOidcLogoutDsl.kt new file mode 100644 index 0000000000..503a5b0c84 --- /dev/null +++ b/config/src/main/kotlin/org/springframework/security/config/web/server/ServerOidcLogoutDsl.kt @@ -0,0 +1,71 @@ +/* + * 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.web.server + +import org.springframework.security.oauth2.client.oidc.server.session.ReactiveOidcSessionRegistry +import org.springframework.security.oauth2.client.registration.ReactiveClientRegistrationRepository + +/** + * A Kotlin DSL to configure [ServerHttpSecurity] OIDC 1.0 login using idiomatic Kotlin code. + * + * @author Josh Cummings + * @since 6.2 + */ +@ServerSecurityMarker +class ServerOidcLogoutDsl { + var clientRegistrationRepository: ReactiveClientRegistrationRepository? = null + var oidcSessionRegistry: ReactiveOidcSessionRegistry? = null + + private var backChannel: ((ServerHttpSecurity.OidcLogoutSpec.BackChannelLogoutConfigurer) -> Unit)? = null + + /** + * Enables OIDC 1.0 Back-Channel Logout support. + * + * Example: + * + * ``` + * @Configuration + * @EnableWebFluxSecurity + * class SecurityConfig { + * + * @Bean + * fun springWebFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain { + * return http { + * oauth2Login { } + * oidcLogout { + * backChannel { } + * } + * } + * } + * } + * ``` + * + * @param backChannelConfig custom configurations to configure OIDC 1.0 Back-Channel Logout support + * @see [ServerOidcBackChannelLogoutDsl] + */ + fun backChannel(backChannelConfig: ServerOidcBackChannelLogoutDsl.() -> Unit) { + this.backChannel = ServerOidcBackChannelLogoutDsl().apply(backChannelConfig).get() + } + + internal fun get(): (ServerHttpSecurity.OidcLogoutSpec) -> Unit { + return { oidcLogout -> + clientRegistrationRepository?.also { oidcLogout.clientRegistrationRepository(clientRegistrationRepository) } + oidcSessionRegistry?.also { oidcLogout.oidcSessionRegistry(oidcSessionRegistry) } + backChannel?.also { oidcLogout.backChannel(backChannel) } + } + } +} 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 0000000000..003551e095 --- /dev/null +++ b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurerTests.java @@ -0,0 +1,550 @@ +/* + * 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.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 jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpSession; +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.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.OidcLogoutToken; +import org.springframework.security.oauth2.client.oidc.authentication.logout.TestOidcLogoutTokens; +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.ClientRegistration; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository; +import org.springframework.security.oauth2.client.registration.TestClientRegistrations; +import org.springframework.security.oauth2.core.oidc.OidcIdToken; +import org.springframework.security.oauth2.core.oidc.TestOidcIdTokens; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; +import org.springframework.security.oauth2.jwt.JwtClaimsSet; +import org.springframework.security.oauth2.jwt.JwtEncoder; +import org.springframework.security.oauth2.jwt.JwtEncoderParameters; +import org.springframework.security.oauth2.jwt.NimbusJwtEncoder; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.logout.LogoutHandler; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.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.hamcrest.Matchers.containsString; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.willThrow; +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.content; +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(); + String registrationId = this.clientRegistration.getRegistrationId(); + MockHttpSession session = login(); + 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(get("/token/logout").session(session)).andExpect(status().isOk()); + } + + @Test + void logoutWhenLogoutTokenSpecifiesOneSessionThenRemotelyInvalidatesOnlyThatSession() throws Exception { + this.spring.register(WebServerConfig.class, OidcProviderConfig.class, DefaultConfig.class).autowire(); + String registrationId = this.clientRegistration.getRegistrationId(); + MockHttpSession one = login(); + MockHttpSession two = login(); + MockHttpSession three = login(); + String logoutToken = this.mvc.perform(get("/token/logout").session(one)).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(one)).andExpect(status().isUnauthorized()); + this.mvc.perform(get("/token/logout").session(two)).andExpect(status().isOk()); + logoutToken = this.mvc.perform(get("/token/logout/all").session(three)).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(two)).andExpect(status().isUnauthorized()); + this.mvc.perform(get("/token/logout").session(three)).andExpect(status().isUnauthorized()); + } + + @Test + void logoutWhenRemoteLogoutFailsThenReportsPartialLogout() throws Exception { + this.spring.register(WebServerConfig.class, OidcProviderConfig.class, WithBrokenLogoutConfig.class).autowire(); + LogoutHandler logoutHandler = this.spring.getContext().getBean(LogoutHandler.class); + willThrow(IllegalStateException.class).given(logoutHandler).logout(any(), any(), any()); + String registrationId = this.clientRegistration.getRegistrationId(); + MockHttpSession one = login(); + String logoutToken = this.mvc.perform(get("/token/logout/all").session(one)).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().isBadRequest()) + .andExpect(content().string(containsString("partial_logout"))); + this.mvc.perform(get("/token/logout").session(one)).andExpect(status().isOk()); + } + + @Test + void logoutWhenCustomComponentsThenUses() throws Exception { + this.spring.register(WebServerConfig.class, OidcProviderConfig.class, WithCustomComponentsConfig.class) + .autowire(); + String registrationId = this.clientRegistration.getRegistrationId(); + MockHttpSession session = login(); + 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()); + OidcSessionRegistry sessionRegistry = this.spring.getContext().getBean(OidcSessionRegistry.class); + verify(sessionRegistry).saveSessionInformation(any()); + verify(sessionRegistry).removeSessionInformation(any(OidcLogoutToken.class)); + } + + private MockHttpSession login() throws Exception { + 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); + return session; + } + + @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 { + + OidcSessionRegistry sessionRegistry = spy(new InMemoryOidcSessionRegistry()); + + @Bean + @Order(1) + SecurityFilterChain filters(HttpSecurity http) throws Exception { + // @formatter:off + http + .authorizeHttpRequests((authorize) -> authorize.anyRequest().authenticated()) + .oauth2Login((oauth2) -> oauth2.oidcSessionRegistry(this.sessionRegistry)) + .oidcLogout((oidc) -> oidc.backChannel(Customizer.withDefaults())); + // @formatter:on + + return http.build(); + } + + @Bean + OidcSessionRegistry sessionRegistry() { + return this.sessionRegistry; + } + + } + + @Configuration + @EnableWebSecurity + @Import(RegistrationConfig.class) + static class WithBrokenLogoutConfig { + + private final LogoutHandler logoutHandler = mock(LogoutHandler.class); + + @Bean + @Order(1) + SecurityFilterChain filters(HttpSecurity http) throws Exception { + // @formatter:off + http + .authorizeHttpRequests((authorize) -> authorize.anyRequest().authenticated()) + .logout((logout) -> logout.addLogoutHandler(this.logoutHandler)) + .oauth2Login(Customizer.withDefaults()) + .oidcLogout((oidc) -> oidc.backChannel(Customizer.withDefaults())); + // @formatter:on + + return http.build(); + } + + @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 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(HttpServletRequest request) { + HttpSession session = request.getSession(); + 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(session.getId()), new BearerAccessToken(token, 86400, new Scope("openid")), null) + .toJSONObject(); + } + + String idToken(String sessionId) { + 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, 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(); + } + + @GetMapping("/token/logout/all") + String logoutTokenAll(@AuthenticationPrincipal OidcUser user) { + OidcLogoutToken token = TestOidcLogoutTokens.withUser(user) + .audience(List.of(this.registration.getClientId())) + .claims((claims) -> claims.remove(LogoutTokenClaimNames.SID)).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()); + } + try { + MockHttpServletResponse mvcResponse = this.mvc.perform(builder.session(session)).andReturn().getResponse(); + return toMockResponse(mvcResponse); + } + catch (Exception ex) { + MockResponse response = new MockResponse(); + response.setResponseCode(500); + return response; + } + } + + 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 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/config/src/test/java/org/springframework/security/config/web/server/OidcLogoutSpecTests.java b/config/src/test/java/org/springframework/security/config/web/server/OidcLogoutSpecTests.java new file mode 100644 index 0000000000..6881c4066a --- /dev/null +++ b/config/src/test/java/org/springframework/security/config/web/server/OidcLogoutSpecTests.java @@ -0,0 +1,595 @@ +/* + * Copyright 2002-2021 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.web.server; + +import java.io.IOException; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.interfaces.RSAPublicKey; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +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 reactor.core.publisher.Mono; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationContext; +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.http.ResponseCookie; +import org.springframework.http.client.reactive.ClientHttpConnector; +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity; +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.MapReactiveUserDetailsService; +import org.springframework.security.core.userdetails.ReactiveUserDetailsService; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.oauth2.client.oidc.authentication.logout.LogoutTokenClaimNames; +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.server.session.InMemoryReactiveOidcSessionRegistry; +import org.springframework.security.oauth2.client.oidc.server.session.ReactiveOidcSessionRegistry; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.registration.InMemoryReactiveClientRegistrationRepository; +import org.springframework.security.oauth2.client.registration.ReactiveClientRegistrationRepository; +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.web.server.SecurityWebFilterChain; +import org.springframework.security.web.server.authentication.logout.ServerLogoutHandler; +import org.springframework.security.web.server.util.matcher.OrServerWebExchangeMatcher; +import org.springframework.security.web.server.util.matcher.PathPatternParserServerWebExchangeMatcher; +import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher; +import org.springframework.test.web.reactive.server.FluxExchangeResult; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.test.web.reactive.server.WebTestClientConfigurer; +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.context.ConfigurableWebApplicationContext; +import org.springframework.web.reactive.config.EnableWebFlux; +import org.springframework.web.reactive.function.BodyInserters; +import org.springframework.web.server.WebSession; +import org.springframework.web.server.adapter.WebHttpHandlerBuilder; + +import static org.hamcrest.Matchers.containsString; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.atLeastOnce; +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.reactive.server.SecurityMockServerConfigurers.csrf; +import static org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers.mockAuthentication; +import static org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers.springSecurity; + +/** + * Tests for + * {@link ServerHttpSecurity.OAuth2ResourceServerSpec} + */ +@ExtendWith({ SpringTestContextExtension.class }) +public class OidcLogoutSpecTests { + + private static final String SESSION_COOKIE_NAME = "SESSION"; + + private WebTestClient test; + + @Autowired(required = false) + private MockWebServer web; + + @Autowired + private ClientRegistration clientRegistration; + + public final SpringTestContext spring = new SpringTestContext(this); + + @Autowired + public void setApplicationContext(ApplicationContext context) { + this.test = WebTestClient.bindToApplicationContext(context) + .apply(springSecurity()) + .configureClient().responseTimeout(Duration.ofDays(1)) + .build(); + if (context instanceof ConfigurableWebApplicationContext configurable) { + configurable.getBeanFactory().registerResolvableDependency(WebTestClient.class, this.test); + } + } + + @Test + void logoutWhenDefaultsThenRemotelyInvalidatesSessions() { + this.spring.register(WebServerConfig.class, OidcProviderConfig.class, DefaultConfig.class).autowire(); + String registrationId = this.clientRegistration.getRegistrationId(); + String session = login(); + String logoutToken = this.test.mutateWith(session(session)).get().uri("/token/logout").exchange().expectStatus() + .isOk().returnResult(String.class).getResponseBody().blockFirst(); + this.test.post().uri(this.web.url("/logout/connect/back-channel/" + registrationId).toString()) + .body(BodyInserters.fromFormData("logout_token", logoutToken)).exchange().expectStatus().isOk(); + this.test.mutateWith(session(session)).get().uri("/token/logout").exchange().expectStatus().isUnauthorized(); + } + + @Test + void logoutWhenInvalidLogoutTokenThenBadRequest() { + this.spring.register(WebServerConfig.class, OidcProviderConfig.class, DefaultConfig.class).autowire(); + this.test.get().uri("/token/logout").exchange().expectStatus().isUnauthorized(); + String registrationId = this.clientRegistration.getRegistrationId(); + FluxExchangeResult result = this.test.get().uri("/oauth2/authorization/" + registrationId).exchange() + .expectStatus().isFound().returnResult(String.class); + String session = sessionId(result); + String redirectUrl = UrlUtils.decode(result.getResponseHeaders().getLocation().toString()); + String state = this.test + .mutateWith(mockAuthentication(new TestingAuthenticationToken(this.clientRegistration.getClientId(), + this.clientRegistration.getClientSecret(), "APP"))) + .get().uri(redirectUrl).exchange().returnResult(String.class).getResponseBody().blockFirst(); + result = this.test.get().uri("/login/oauth2/code/" + registrationId + "?code=code&state=" + state) + .cookie("SESSION", session).exchange().expectStatus().isFound().returnResult(String.class); + session = sessionId(result); + this.test.post().uri(this.web.url("/logout/connect/back-channel/" + registrationId).toString()) + .body(BodyInserters.fromFormData("logout_token", "invalid")).exchange().expectStatus().isBadRequest(); + this.test.get().uri("/token/logout").cookie("SESSION", session).exchange().expectStatus().isOk(); + } + + @Test + void logoutWhenLogoutTokenSpecifiesOneSessionThenRemotelyInvalidatesOnlyThatSession() throws Exception { + this.spring.register(WebServerConfig.class, OidcProviderConfig.class, DefaultConfig.class).autowire(); + String registrationId = this.clientRegistration.getRegistrationId(); + String one = login(); + String two = login(); + String three = login(); + String logoutToken = this.test.get().uri("/token/logout").cookie("SESSION", one).exchange().expectStatus() + .isOk().returnResult(String.class).getResponseBody().blockFirst(); + this.test.post().uri(this.web.url("/logout/connect/back-channel/" + registrationId).toString()) + .body(BodyInserters.fromFormData("logout_token", logoutToken)).exchange().expectStatus().isOk(); + this.test.get().uri("/token/logout").cookie("SESSION", one).exchange().expectStatus().isUnauthorized(); + this.test.get().uri("/token/logout").cookie("SESSION", two).exchange().expectStatus().isOk(); + logoutToken = this.test.get().uri("/token/logout/all").cookie("SESSION", three).exchange().expectStatus().isOk() + .returnResult(String.class).getResponseBody().blockFirst(); + this.test.post().uri(this.web.url("/logout/connect/back-channel/" + registrationId).toString()) + .body(BodyInserters.fromFormData("logout_token", logoutToken)).exchange().expectStatus().isOk(); + this.test.get().uri("/token/logout").cookie("SESSION", two).exchange().expectStatus().isUnauthorized(); + this.test.get().uri("/token/logout").cookie("SESSION", three).exchange().expectStatus().isUnauthorized(); + } + + @Test + void logoutWhenRemoteLogoutFailsThenReportsPartialLogout() { + this.spring.register(WebServerConfig.class, OidcProviderConfig.class, WithBrokenLogoutConfig.class).autowire(); + ServerLogoutHandler logoutHandler = this.spring.getContext().getBean(ServerLogoutHandler.class); + given(logoutHandler.logout(any(), any())).willReturn(Mono.error(() -> new IllegalStateException("illegal"))); + String registrationId = this.clientRegistration.getRegistrationId(); + String one = login(); + String logoutToken = this.test.get().uri("/token/logout/all").cookie("SESSION", one).exchange().expectStatus() + .isOk().returnResult(String.class).getResponseBody().blockFirst(); + this.test.post().uri(this.web.url("/logout/connect/back-channel/" + registrationId).toString()) + .body(BodyInserters.fromFormData("logout_token", logoutToken)).exchange().expectStatus().isBadRequest() + .expectBody(String.class).value(containsString("partial_logout")); + this.test.get().uri("/token/logout").cookie("SESSION", one).exchange().expectStatus().isOk(); + } + + @Test + void logoutWhenCustomComponentsThenUses() { + this.spring.register(WebServerConfig.class, OidcProviderConfig.class, WithCustomComponentsConfig.class) + .autowire(); + String registrationId = this.clientRegistration.getRegistrationId(); + String sessionId = login(); + String logoutToken = this.test.get().uri("/token/logout").cookie("SESSION", sessionId).exchange().expectStatus() + .isOk().returnResult(String.class).getResponseBody().blockFirst(); + this.test.post().uri(this.web.url("/logout/connect/back-channel/" + registrationId).toString()) + .body(BodyInserters.fromFormData("logout_token", logoutToken)).exchange().expectStatus().isOk(); + this.test.get().uri("/token/logout").cookie("SESSION", sessionId).exchange().expectStatus().isUnauthorized(); + ReactiveOidcSessionRegistry sessionRegistry = this.spring.getContext() + .getBean(ReactiveOidcSessionRegistry.class); + verify(sessionRegistry, atLeastOnce()).saveSessionInformation(any()); + verify(sessionRegistry, atLeastOnce()).removeSessionInformation(any(OidcLogoutToken.class)); + } + + private String login() { + this.test.get().uri("/token/logout").exchange().expectStatus().isUnauthorized(); + String registrationId = this.clientRegistration.getRegistrationId(); + FluxExchangeResult result = this.test.get().uri("/oauth2/authorization/" + registrationId).exchange() + .expectStatus().isFound().returnResult(String.class); + String sessionId = sessionId(result); + String redirectUrl = UrlUtils.decode(result.getResponseHeaders().getLocation().toString()); + result = this.test + .mutateWith(mockAuthentication(new TestingAuthenticationToken(this.clientRegistration.getClientId(), + this.clientRegistration.getClientSecret(), "APP"))) + .get().uri(redirectUrl).exchange().returnResult(String.class); + String state = result.getResponseBody().blockFirst(); + result = this.test.mutateWith(session(sessionId)).get() + .uri("/login/oauth2/code/" + registrationId + "?code=code&state=" + state).exchange().expectStatus() + .isFound().returnResult(String.class); + return sessionId(result); + } + + private String sessionId(FluxExchangeResult result) { + List cookies = result.getResponseCookies().get(SESSION_COOKIE_NAME); + if (cookies == null || cookies.isEmpty()) { + return null; + } + return cookies.get(0).getValue(); + } + + static SessionMutator session(String session) { + return new SessionMutator(session); + } + + private record SessionMutator(String session) implements WebTestClientConfigurer { + + @Override + public void afterConfigurerAdded(WebTestClient.Builder builder, WebHttpHandlerBuilder httpHandlerBuilder, + ClientHttpConnector connector) { + builder.defaultCookie(SESSION_COOKIE_NAME, this.session); + } + +} + +@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 + ReactiveClientRegistrationRepository clientRegistrationRepository(ClientRegistration clientRegistration) { + return new InMemoryReactiveClientRegistrationRepository(clientRegistration); + } + +} + +@Configuration +@EnableWebFluxSecurity +@Import(RegistrationConfig.class) +static class DefaultConfig { + + @Bean + @Order(1) + SecurityWebFilterChain filters(ServerHttpSecurity http) throws Exception { + // @formatter:off + http + .authorizeExchange((authorize) -> authorize.anyExchange().authenticated()) + .oauth2Login(Customizer.withDefaults()) + .oidcLogout((oidc) -> oidc.backChannel(Customizer.withDefaults())); + // @formatter:on + + return http.build(); + } + +} + +@Configuration +@EnableWebFluxSecurity +@Import(RegistrationConfig.class) +static class WithCustomComponentsConfig { + + ReactiveOidcSessionRegistry sessionRegistry = spy(new InMemoryReactiveOidcSessionRegistry()); + + @Bean + @Order(1) + SecurityWebFilterChain filters(ServerHttpSecurity http) throws Exception { + // @formatter:off + http + .authorizeExchange((authorize) -> authorize.anyExchange().authenticated()) + .oauth2Login((oauth2) -> oauth2.oidcSessionRegistry(this.sessionRegistry)) + .oidcLogout((oidc) -> oidc.backChannel(Customizer.withDefaults())); + // @formatter:on + + return http.build(); + } + + @Bean + ReactiveOidcSessionRegistry sessionRegistry() { + return this.sessionRegistry; + } + +} + +@Configuration +@EnableWebFluxSecurity +@Import(RegistrationConfig.class) +static class WithBrokenLogoutConfig { + + private final ServerLogoutHandler logoutHandler = mock(ServerLogoutHandler.class); + + @Bean + @Order(1) + SecurityWebFilterChain filters(ServerHttpSecurity http) throws Exception { + // @formatter:off + http + .authorizeExchange((authorize) -> authorize.anyExchange().authenticated()) + .logout((logout) -> logout.logoutHandler(this.logoutHandler)) + .oauth2Login(Customizer.withDefaults()) + .oidcLogout((oidc) -> oidc.backChannel(Customizer.withDefaults())); + // @formatter:on + + return http.build(); + } + + @Bean + ServerLogoutHandler logoutHandler() { + return this.logoutHandler; + } + +} + +@Configuration +@EnableWebFluxSecurity +@EnableWebFlux +@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 JwtEncoder encoder = new NimbusJwtEncoder(jwks); + + private String nonce; + + @Autowired + ClientRegistration registration; + + static ServerWebExchangeMatcher or(String... patterns) { + List matchers = new ArrayList<>(); + for (String pattern : patterns) { + matchers.add(new PathPatternParserServerWebExchangeMatcher(pattern)); + } + return new OrServerWebExchangeMatcher(matchers); + } + + @Bean + @Order(0) + SecurityWebFilterChain authorizationServer(ServerHttpSecurity http, ClientRegistration registration) + throws Exception { + // @formatter:off + http + .securityMatcher(or("/jwks", "/login/oauth/authorize", "/nonce", "/token", "/token/logout", "/user")) + .authorizeExchange((authorize) -> authorize + .pathMatchers("/jwks").permitAll() + .anyExchange().authenticated() + ) + .httpBasic(Customizer.withDefaults()) + .oauth2ResourceServer((oauth2) -> oauth2 + .jwt((jwt) -> jwt.jwkSetUri(registration.getProviderDetails().getJwkSetUri())) + ); + // @formatter:off + + return http.build(); + } + + @Bean + ReactiveUserDetailsService users(ClientRegistration registration) { + return new MapReactiveUserDetailsService(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(WebSession session) { + 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(session.getId()), new BearerAccessToken(token, 86400, new Scope("openid")), null) + .toJSONObject(); + } + + String idToken(String sessionId) { + 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, 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(); + } + + @GetMapping("/token/logout/all") + String logoutTokenAll(@AuthenticationPrincipal OidcUser user) { + OidcLogoutToken token = TestOidcLogoutTokens.withUser(user) + .audience(List.of(this.registration.getClientId())) + .claims((claims) -> claims.remove(LogoutTokenClaimNames.SID)).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 web) { + this.server.setDispatcher(new WebTestClientDispatcher(web)); + return this.server; + } + + @PreDestroy + void shutdown() throws IOException { + this.server.shutdown(); + } + + } + + private static class WebTestClientDispatcher extends Dispatcher { + + private final ObjectProvider webProvider; + + private WebTestClient web; + + WebTestClientDispatcher(ObjectProvider web) { + this.webProvider = web; + } + + @Override + public MockResponse dispatch(RecordedRequest request) throws InterruptedException { + this.web = this.webProvider.getObject(); + String method = request.getMethod(); + String path = request.getPath(); + String csrf = request.getHeader("X-CSRF-TOKEN"); + String sessionId = session(request); + WebTestClient.RequestHeadersSpec r; + if ("GET".equals(method)) { + r = this.web.get().uri(path); + } + else { + WebTestClient.RequestBodySpec body; + if (csrf == null) { + body = this.web.mutateWith(csrf()).post().uri(path); + } + else { + body = this.web.post().uri(path).header("X-CSRF-TOKEN", csrf); + } + body.body(BodyInserters.fromValue(request.getBody().readUtf8())); + r = body; + } + for (Map.Entry> header : request.getHeaders().toMultimap().entrySet()) { + if (header.getKey().equalsIgnoreCase("Cookie")) { + continue; + } + r.header(header.getKey(), header.getValue().iterator().next()); + } + if (sessionId != null) { + r.cookie(SESSION_COOKIE_NAME, sessionId); + } + + try { + FluxExchangeResult result = r.exchange().returnResult(String.class); + return toMockResponse(result); + } + catch (Exception ex) { + MockResponse response = new MockResponse(); + response.setResponseCode(500); + response.setBody(ex.getMessage()); + return response; + } + } + + private String session(RecordedRequest request) { + String cookieHeaderValue = request.getHeader("Cookie"); + if (cookieHeaderValue == null) { + return null; + } + String[] cookies = cookieHeaderValue.split(";"); + for (String cookie : cookies) { + String[] parts = cookie.split("="); + if (SESSION_COOKIE_NAME.equals(parts[0])) { + return parts[1]; + } + } + return null; + } + + private MockResponse toMockResponse(FluxExchangeResult result) { + MockResponse response = new MockResponse(); + response.setResponseCode(result.getStatus().value()); + for (String name : result.getResponseHeaders().keySet()) { + response.addHeader(name, result.getResponseHeaders().getFirst(name)); + } + String body = result.getResponseBody().blockFirst(); + if (body != null) { + response.setBody(body); + } + return response; + } + + } + +} diff --git a/config/src/test/java/org/springframework/security/config/web/server/ServerHttpSecurityTests.java b/config/src/test/java/org/springframework/security/config/web/server/ServerHttpSecurityTests.java index 5acbe6327e..cbf58b1162 100644 --- a/config/src/test/java/org/springframework/security/config/web/server/ServerHttpSecurityTests.java +++ b/config/src/test/java/org/springframework/security/config/web/server/ServerHttpSecurityTests.java @@ -717,7 +717,7 @@ public class ServerHttpSecurityTests { private Optional getWebFilter(SecurityWebFilterChain filterChain, Class filterClass) { return (Optional) filterChain.getWebFilters().filter(Objects::nonNull) - .filter((filter) -> filter.getClass().isAssignableFrom(filterClass)).singleOrEmpty().blockOptional(); + .filter((filter) -> filterClass.isAssignableFrom(filter.getClass())).singleOrEmpty().blockOptional(); } private WebTestClient buildClient() { diff --git a/config/src/test/kotlin/org/springframework/security/config/annotation/web/OidcLogoutDslTests.kt b/config/src/test/kotlin/org/springframework/security/config/annotation/web/OidcLogoutDslTests.kt new file mode 100644 index 0000000000..468be4251b --- /dev/null +++ b/config/src/test/kotlin/org/springframework/security/config/annotation/web/OidcLogoutDslTests.kt @@ -0,0 +1,87 @@ +/* + * 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.config.annotation.web + +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +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.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.web.SecurityFilterChain +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.post + +/** + * Tests for [OAuth2ClientDsl] + * + * @author Eleftheria Stein + */ +@ExtendWith(SpringTestContextExtension::class) +class OidcLogoutDslTests { + @JvmField + val spring = SpringTestContext(this) + + @Autowired + lateinit var mockMvc: MockMvc + + @Test + fun `oidcLogout when invalid token then errors`() { + this.spring.register(ClientRepositoryConfig::class.java).autowire() + val clientRegistration = this.spring.context.getBean(ClientRegistration::class.java) + this.mockMvc.post("/logout/connect/back-channel/" + clientRegistration.registrationId) { + param("logout_token", "token") + }.andExpect { status { isBadRequest() } } + } + + @Configuration + @EnableWebSecurity + open class ClientRepositoryConfig { + + @Bean + open fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { + http { + oauth2Login { } + oidcLogout { + backChannel { } + } + authorizeHttpRequests { + authorize(anyRequest, authenticated) + } + } + return http.build() + } + + @Bean + open fun clientRegistration(): ClientRegistration { + return TestClientRegistrations.clientRegistration().build() + } + + @Bean + open fun clientRegistrationRepository(clientRegistration: ClientRegistration): ClientRegistrationRepository { + return InMemoryClientRegistrationRepository(clientRegistration) + } + } + +} diff --git a/config/src/test/kotlin/org/springframework/security/config/web/server/ServerOidcLogoutDslTests.kt b/config/src/test/kotlin/org/springframework/security/config/web/server/ServerOidcLogoutDslTests.kt new file mode 100644 index 0000000000..a4b62b2dc0 --- /dev/null +++ b/config/src/test/kotlin/org/springframework/security/config/web/server/ServerOidcLogoutDslTests.kt @@ -0,0 +1,97 @@ +/* + * 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.web.server + +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.context.ApplicationContext +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity +import org.springframework.security.config.test.SpringTestContext +import org.springframework.security.config.test.SpringTestContextExtension +import org.springframework.security.oauth2.client.registration.ClientRegistration +import org.springframework.security.oauth2.client.registration.InMemoryReactiveClientRegistrationRepository +import org.springframework.security.oauth2.client.registration.ReactiveClientRegistrationRepository +import org.springframework.security.oauth2.client.registration.TestClientRegistrations +import org.springframework.security.web.server.SecurityWebFilterChain +import org.springframework.test.web.reactive.server.WebTestClient +import org.springframework.web.reactive.config.EnableWebFlux +import org.springframework.web.reactive.function.BodyInserters + +/** + * Tests for [ServerOidcLogoutDsl] + * + * @author Josh Cummings + */ +@ExtendWith(SpringTestContextExtension::class) +class ServerOidcLogoutDslTests { + @JvmField + val spring = SpringTestContext(this) + + private lateinit var client: WebTestClient + + @Autowired + fun setup(context: ApplicationContext) { + this.client = WebTestClient + .bindToApplicationContext(context) + .configureClient() + .build() + } + + @Test + fun `oidcLogout when invalid token then errors`() { + this.spring.register(ClientRepositoryConfig::class.java).autowire() + val clientRegistration = this.spring.context.getBean(ClientRegistration::class.java) + this.client.post() + .uri("/logout/connect/back-channel/" + clientRegistration.registrationId) + .body(BodyInserters.fromFormData("logout_token", "token")) + .exchange() + .expectStatus().isBadRequest + } + + @Configuration + @EnableWebFlux + @EnableWebFluxSecurity + open class ClientRepositoryConfig { + + @Bean + open fun securityFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain { + return http { + oauth2Login { } + oidcLogout { + backChannel { } + } + authorizeExchange { + authorize(anyExchange, authenticated) + } + } + } + + @Bean + open fun clientRegistration(): ClientRegistration { + return TestClientRegistrations.clientRegistration().build() + } + + @Bean + open fun clientRegistrationRepository(clientRegistration: ClientRegistration): ReactiveClientRegistrationRepository { + return InMemoryReactiveClientRegistrationRepository(clientRegistration) + } + } + +} diff --git a/docs/modules/ROOT/pages/reactive/oauth2/login/advanced.adoc b/docs/modules/ROOT/pages/reactive/oauth2/login/advanced.adoc index 0035fd2ec5..319cba192b 100644 --- a/docs/modules/ROOT/pages/reactive/oauth2/login/advanced.adoc +++ b/docs/modules/ROOT/pages/reactive/oauth2/login/advanced.adoc @@ -700,111 +700,5 @@ For MAC based algorithms such as `HS256`, `HS384` or `HS512`, the `client-secret [TIP] If more than one `ClientRegistration` is configured for OpenID Connect 1.0 Authentication, the JWS algorithm resolver may evaluate the provided `ClientRegistration` to determine which algorithm to return. - [[webflux-oauth2-login-advanced-oidc-logout]] -== OpenID Connect 1.0 Logout - -OpenID Connect Session Management 1.0 allows the ability to log out the End-User at the Provider using the Client. -One of the strategies available is https://openid.net/specs/openid-connect-rpinitiated-1_0.html[RP-Initiated Logout]. - -If the OpenID Provider supports both Session Management and https://openid.net/specs/openid-connect-discovery-1_0.html[Discovery], the client may obtain the `end_session_endpoint` `URL` from the OpenID Provider's https://openid.net/specs/openid-connect-session-1_0.html#OPMetadata[Discovery Metadata]. -This can be achieved by configuring the `ClientRegistration` with the `issuer-uri`, as in the following example: - -[source,yaml] ----- -spring: - security: - oauth2: - client: - registration: - okta: - client-id: okta-client-id - client-secret: okta-client-secret - ... - provider: - okta: - issuer-uri: https://dev-1234.oktapreview.com ----- - -...and the `OidcClientInitiatedServerLogoutSuccessHandler`, which implements RP-Initiated Logout, may be configured as follows: - -[tabs] -====== -Java:: -+ -[source,java,role="primary",subs="-attributes"] ----- -@Configuration -@EnableWebFluxSecurity -public class OAuth2LoginSecurityConfig { - - @Autowired - private ReactiveClientRegistrationRepository clientRegistrationRepository; - - @Bean - public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) { - http - .authorizeExchange(authorize -> authorize - .anyExchange().authenticated() - ) - .oauth2Login(withDefaults()) - .logout(logout -> logout - .logoutSuccessHandler(oidcLogoutSuccessHandler()) - ); - - return http.build(); - } - - private ServerLogoutSuccessHandler oidcLogoutSuccessHandler() { - OidcClientInitiatedServerLogoutSuccessHandler oidcLogoutSuccessHandler = - new OidcClientInitiatedServerLogoutSuccessHandler(this.clientRegistrationRepository); - - // Sets the location that the End-User's User Agent will be redirected to - // after the logout has been performed at the Provider - oidcLogoutSuccessHandler.setPostLogoutRedirectUri("{baseUrl}"); - - return oidcLogoutSuccessHandler; - } -} ----- - -Kotlin:: -+ -[source,kotlin,role="secondary",subs="-attributes"] ----- -@Configuration -@EnableWebFluxSecurity -class OAuth2LoginSecurityConfig { - - @Autowired - private lateinit var clientRegistrationRepository: ReactiveClientRegistrationRepository - - @Bean - fun securityWebFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain { - http { - authorizeExchange { - authorize(anyExchange, authenticated) - } - oauth2Login { } - logout { - logoutSuccessHandler = oidcLogoutSuccessHandler() - } - } - - return http.build() - } - - private fun oidcLogoutSuccessHandler(): ServerLogoutSuccessHandler { - val oidcLogoutSuccessHandler = OidcClientInitiatedServerLogoutSuccessHandler(clientRegistrationRepository) - - // Sets the location that the End-User's User Agent will be redirected to - // after the logout has been performed at the Provider - oidcLogoutSuccessHandler.setPostLogoutRedirectUri("{baseUrl}") - return oidcLogoutSuccessHandler - } -} ----- -====== - -NOTE: `OidcClientInitiatedServerLogoutSuccessHandler` supports the `+{baseUrl}+` placeholder. -If used, the application's base URL, like `https://app.example.org`, will replace it at request time. +Then, you can proceed to configure xref:reactive/oauth2/login/logout.adoc[logout]. diff --git a/docs/modules/ROOT/pages/reactive/oauth2/login/logout.adoc b/docs/modules/ROOT/pages/reactive/oauth2/login/logout.adoc new file mode 100644 index 0000000000..1f059a0827 --- /dev/null +++ b/docs/modules/ROOT/pages/reactive/oauth2/login/logout.adoc @@ -0,0 +1,267 @@ += OIDC Logout + +Once an end user is able to login to your application, it's important to consider how they will log out. + +Generally speaking, there are three use cases for you to consider: + +1. I want to perform only a local logout +2. I want to log out both my application and the OIDC Provider, initiated by my application +3. I want to log out both my application and the OIDC Provider, initiated by the OIDC Provider + +[[configure-local-logout]] +== Local Logout + +To perform a local logout, no special OIDC configuration is needed. +Spring Security automatically stands up a local logout endpoint, which you can xref:reactive/authentication/logout.adoc[configure through the `logout()` DSL]. + +[[configure-client-initiated-oidc-logout]] +[[oauth2login-advanced-oidc-logout]] +== OpenID Connect 1.0 Client-Initiated Logout + +OpenID Connect Session Management 1.0 allows the ability to log out the end user at the Provider by using the Client. +One of the strategies available is https://openid.net/specs/openid-connect-rpinitiated-1_0.html[RP-Initiated Logout]. + +If the OpenID Provider supports both Session Management and https://openid.net/specs/openid-connect-discovery-1_0.html[Discovery], the client can obtain the `end_session_endpoint` `URL` from the OpenID Provider's https://openid.net/specs/openid-connect-session-1_0.html#OPMetadata[Discovery Metadata]. +You can do so by configuring the `ClientRegistration` with the `issuer-uri`, as follows: + +[source,yaml] +---- +spring: + security: + oauth2: + client: + registration: + okta: + client-id: okta-client-id + client-secret: okta-client-secret + ... + provider: + okta: + issuer-uri: https://dev-1234.oktapreview.com +---- + +Also, you should configure `OidcClientInitiatedServerLogoutSuccessHandler`, which implements RP-Initiated Logout, as follows: + +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +@Configuration +@EnableWebFluxSecurity +public class OAuth2LoginSecurityConfig { + + @Autowired + private ReactiveClientRegistrationRepository clientRegistrationRepository; + + @Bean + public SecurityWebFilterChain filterChain(ServerHttpSecurity http) throws Exception { + http + .authorizeExchange((authorize) -> authorize + .anyExchange().authenticated() + ) + .oauth2Login(withDefaults()) + .logout((logout) -> logout + .logoutSuccessHandler(oidcLogoutSuccessHandler()) + ); + return http.build(); + } + + private ServerLogoutSuccessHandler oidcLogoutSuccessHandler() { + OidcClientInitiatedServerLogoutSuccessHandler oidcLogoutSuccessHandler = + new OidcClientInitiatedServerLogoutSuccessHandler(this.clientRegistrationRepository); + + // Sets the location that the End-User's User Agent will be redirected to + // after the logout has been performed at the Provider + oidcLogoutSuccessHandler.setPostLogoutRedirectUri("{baseUrl}"); + + return oidcLogoutSuccessHandler; + } +} +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +@Configuration +@EnableWebFluxSecurity +class OAuth2LoginSecurityConfig { + @Autowired + private lateinit var clientRegistrationRepository: ReactiveClientRegistrationRepository + + @Bean + open fun filterChain(http: ServerHttpSecurity): SecurityWebFilterChain { + http { + authorizeExchange { + authorize(anyExchange, authenticated) + } + oauth2Login { } + logout { + logoutSuccessHandler = oidcLogoutSuccessHandler() + } + } + return http.build() + } + + private fun oidcLogoutSuccessHandler(): ServerLogoutSuccessHandler { + val oidcLogoutSuccessHandler = OidcClientInitiatedServerLogoutSuccessHandler(clientRegistrationRepository) + + // Sets the location that the End-User's User Agent will be redirected to + // after the logout has been performed at the Provider + oidcLogoutSuccessHandler.setPostLogoutRedirectUri("{baseUrl}") + return oidcLogoutSuccessHandler + } +} +---- +====== + +[NOTE] +==== +`OidcClientInitiatedServerLogoutSuccessHandler` supports the `+{baseUrl}+` placeholder. +If used, the application's base URL, such as `https://app.example.org`, replaces it at request time. +==== + +[[configure-provider-initiated-oidc-logout]] +== OpenID Connect 1.0 Back-Channel Logout + +OpenID Connect Session Management 1.0 allows the ability to log out the end user at the Client by having the Provider make an API call to the Client. +This is referred to as https://openid.net/specs/openid-connect-backchannel-1_0.html[OIDC Back-Channel Logout]. + +To enable this, you can stand up the Back-Channel Logout endpoint in the DSL like so: + +[tabs] +====== +Java:: ++ +[source=java,role="primary"] +---- +@Bean +public SecurityWebFilterChain filterChain(ServerHttpSecurity http) throws Exception { + http + .authorizeExchange((authorize) -> authorize + .anyExchange().authenticated() + ) + .oauth2Login(withDefaults()) + .oidcLogout((logout) -> logout + .backChannel(Customizer.withDefaults()) + ); + return http.build(); +} +---- + +Kotlin:: ++ +[source=kotlin,role="secondary"] +---- +@Bean +open fun filterChain(http: ServerHttpSecurity): SecurityWebFilterChain { + http { + authorizeExchange { + authorize(anyExchange, authenticated) + } + oauth2Login { } + oidcLogout { + backChannel { } + } + } + return http.build() +} +---- +====== + +And that's it! + +This will stand up the endpoint `/logout/connect/back-channel/+{registrationId}` which the OIDC Provider can request to invalidate a given session of an end user in your application. + +[NOTE] +`oidcLogout` requires that `oauth2Login` also be configured. + +[NOTE] +`oidcLogout` requires that the session cookie be called `JSESSIONID` in order to correctly log out each session through a backchannel. + +=== Back-Channel Logout Architecture + +Consider a `ClientRegistration` whose identifier is `registrationId`. + +The overall flow for a Back-Channel logout is like this: + +1. At login time, Spring Security correlates the ID Token, CSRF Token, and Provider Session ID (if any) to your application's session id in its `ReactiveOidcSessionStrategy` implementation. +2. Then at logout time, your OIDC Provider makes an API call to `/logout/connect/back-channel/registrationId` including a Logout Token that indicates either the `sub` (the End User) or the `sid` (the Provider Session ID) to logout. +3. Spring Security validates the token's signature and claims. +4. If the token contains a `sid` claim, then only the Client's session that correlates to that provider session is terminated. +5. Otherwise, if the token contains a `sub` claim, then all that Client's sessions for that End User are terminated. + +[NOTE] +Remember that Spring Security's OIDC support is multi-tenant. +This means that it will only terminate sessions whose Client matches the `aud` claim in the Logout Token. + +=== Customizing the OIDC Provider Session Strategy + +By default, Spring Security stores in-memory all links between the OIDC Provider session and the Client session. + +There are a number of circumstances, like a clustered application, where it would be nice to store this instead in a separate location, like a database. + +You can achieve this by configuring a custom `ReactiveOidcSessionStrategy`, like so: + +[tabs] +====== +Java:: ++ +[source=java,role="primary"] +---- +@Component +public final class MySpringDataOidcSessionStrategy implements OidcSessionStrategy { + private final OidcProviderSessionRepository sessions; + + // ... + + @Override + public void saveSessionInformation(OidcSessionInformation info) { + this.sessions.save(info); + } + + @Override + public OidcSessionInformation(String clientSessionId) { + return this.sessions.removeByClientSessionId(clientSessionId); + } + + @Override + public Iterable removeSessionInformation(OidcLogoutToken token) { + return token.getSessionId() != null ? + this.sessions.removeBySessionIdAndIssuerAndAudience(...) : + this.sessions.removeBySubjectAndIssuerAndAudience(...); + } +} +---- + +Kotlin:: ++ +[source=kotlin,role="secondary"] +---- +@Component +class MySpringDataOidcSessionStrategy: ReactiveOidcSessionStrategy { + val sessions: OidcProviderSessionRepository + + // ... + + @Override + fun saveSessionInformation(info: OidcSessionInformation): Mono { + return this.sessions.save(info) + } + + @Override + fun removeSessionInformation(clientSessionId: String): Mono { + return this.sessions.removeByClientSessionId(clientSessionId); + } + + @Override + fun removeSessionInformation(token: OidcLogoutToken): Flux { + return token.getSessionId() != null ? + this.sessions.removeBySessionIdAndIssuerAndAudience(...) : + this.sessions.removeBySubjectAndIssuerAndAudience(...); + } +} +---- +====== diff --git a/docs/modules/ROOT/pages/servlet/oauth2/login/advanced.adoc b/docs/modules/ROOT/pages/servlet/oauth2/login/advanced.adoc index a0ee7eeced..765b5b89e3 100644 --- a/docs/modules/ROOT/pages/servlet/oauth2/login/advanced.adoc +++ b/docs/modules/ROOT/pages/servlet/oauth2/login/advanced.adoc @@ -1037,3 +1037,6 @@ class OAuth2LoginSecurityConfig { `OidcClientInitiatedLogoutSuccessHandler` supports the `+{baseUrl}+` placeholder. If used, the application's base URL, such as `https://app.example.org`, replaces it at request time. ==== + +[[oauth2login-advanced-oidc-logout]] +Then, you can proceed to configure xref:reactive/oauth2/login/logout.adoc[logout] diff --git a/docs/modules/ROOT/pages/servlet/oauth2/login/logout.adoc b/docs/modules/ROOT/pages/servlet/oauth2/login/logout.adoc new file mode 100644 index 0000000000..de36b364ad --- /dev/null +++ b/docs/modules/ROOT/pages/servlet/oauth2/login/logout.adoc @@ -0,0 +1,267 @@ += OIDC Logout + +Once an end user is able to login to your application, it's important to consider how they will log out. + +Generally speaking, there are three use cases for you to consider: + +1. I want to perform only a local logout +2. I want to log out both my application and the OIDC Provider, initiated by my application +3. I want to log out both my application and the OIDC Provider, initiated by the OIDC Provider + +[[configure-local-logout]] +== Local Logout + +To perform a local logout, no special OIDC configuration is needed. +Spring Security automatically stands up a local logout endpoint, which you can xref:servlet/authentication/logout.adoc[configure through the `logout()` DSL]. + +[[configure-client-initiated-oidc-logout]] +== OpenID Connect 1.0 Client-Initiated Logout + +OpenID Connect Session Management 1.0 allows the ability to log out the end user at the Provider by using the Client. +One of the strategies available is https://openid.net/specs/openid-connect-rpinitiated-1_0.html[RP-Initiated Logout]. + +If the OpenID Provider supports both Session Management and https://openid.net/specs/openid-connect-discovery-1_0.html[Discovery], the client can obtain the `end_session_endpoint` `URL` from the OpenID Provider's https://openid.net/specs/openid-connect-session-1_0.html#OPMetadata[Discovery Metadata]. +You can do so by configuring the `ClientRegistration` with the `issuer-uri`, as follows: + +[source,yaml] +---- +spring: + security: + oauth2: + client: + registration: + okta: + client-id: okta-client-id + client-secret: okta-client-secret + ... + provider: + okta: + issuer-uri: https://dev-1234.oktapreview.com +---- + +Also, you should configure `OidcClientInitiatedLogoutSuccessHandler`, which implements RP-Initiated Logout, as follows: + +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +@Configuration +@EnableWebSecurity +public class OAuth2LoginSecurityConfig { + + @Autowired + private ClientRegistrationRepository clientRegistrationRepository; + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .authorizeHttpRequests(authorize -> authorize + .anyRequest().authenticated() + ) + .oauth2Login(withDefaults()) + .logout(logout -> logout + .logoutSuccessHandler(oidcLogoutSuccessHandler()) + ); + return http.build(); + } + + private LogoutSuccessHandler oidcLogoutSuccessHandler() { + OidcClientInitiatedLogoutSuccessHandler oidcLogoutSuccessHandler = + new OidcClientInitiatedLogoutSuccessHandler(this.clientRegistrationRepository); + + // Sets the location that the End-User's User Agent will be redirected to + // after the logout has been performed at the Provider + oidcLogoutSuccessHandler.setPostLogoutRedirectUri("{baseUrl}"); + + return oidcLogoutSuccessHandler; + } +} +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +@Configuration +@EnableWebSecurity +class OAuth2LoginSecurityConfig { + @Autowired + private lateinit var clientRegistrationRepository: ClientRegistrationRepository + + @Bean + open fun filterChain(http: HttpSecurity): SecurityFilterChain { + http { + authorizeHttpRequests { + authorize(anyRequest, authenticated) + } + oauth2Login { } + logout { + logoutSuccessHandler = oidcLogoutSuccessHandler() + } + } + return http.build() + } + + private fun oidcLogoutSuccessHandler(): LogoutSuccessHandler { + val oidcLogoutSuccessHandler = OidcClientInitiatedLogoutSuccessHandler(clientRegistrationRepository) + + // Sets the location that the End-User's User Agent will be redirected to + // after the logout has been performed at the Provider + oidcLogoutSuccessHandler.setPostLogoutRedirectUri("{baseUrl}") + return oidcLogoutSuccessHandler + } +} +---- +====== + +[NOTE] +==== +`OidcClientInitiatedLogoutSuccessHandler` supports the `+{baseUrl}+` placeholder. +If used, the application's base URL, such as `https://app.example.org`, replaces it at request time. +==== + +[[configure-provider-initiated-oidc-logout]] +== OpenID Connect 1.0 Back-Channel Logout + +OpenID Connect Session Management 1.0 allows the ability to log out the end user at the Client by having the Provider make an API call to the Client. +This is referred to as https://openid.net/specs/openid-connect-backchannel-1_0.html[OIDC Back-Channel Logout]. + +To enable this, you can stand up the Back-Channel Logout endpoint in the DSL like so: + +[tabs] +====== +Java:: ++ +[source=java,role="primary"] +---- +@Bean +public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .authorizeHttpRequests((authorize) -> authorize + .anyRequest().authenticated() + ) + .oauth2Login(withDefaults()) + .oidcLogout((logout) -> logout + .backChannel(Customizer.withDefaults()) + ); + return http.build(); +} +---- + +Kotlin:: ++ +[source=kotlin,role="secondary"] +---- +@Bean +open fun filterChain(http: HttpSecurity): SecurityFilterChain { + http { + authorizeRequests { + authorize(anyRequest, authenticated) + } + oauth2Login { } + oidcLogout { + backChannel { } + } + } + return http.build() +} +---- +====== + +And that's it! + +This will stand up the endpoint `/logout/connect/back-channel/+{registrationId}` which the OIDC Provider can request to invalidate a given session of an end user in your application. + +[NOTE] +`oidcLogout` requires that `oauth2Login` also be configured. + +[NOTE] +`oidcLogout` requires that the session cookie be called `JSESSIONID` in order to correctly log out each session through a backchannel. + +=== Back-Channel Logout Architecture + +Consider a `ClientRegistration` whose identifier is `registrationId`. + +The overall flow for a Back-Channel logout is like this: + +1. At login time, Spring Security correlates the ID Token, CSRF Token, and Provider Session ID (if any) to your application's session id in its `OidcSessionStrategy` implementation. +2. Then at logout time, your OIDC Provider makes an API call to `/logout/connect/back-channel/registrationId` including a Logout Token that indicates either the `sub` (the End User) or the `sid` (the Provider Session ID) to logout. +3. Spring Security validates the token's signature and claims. +4. If the token contains a `sid` claim, then only the Client's session that correlates to that provider session is terminated. +5. Otherwise, if the token contains a `sub` claim, then all that Client's sessions for that End User are terminated. + +[NOTE] +Remember that Spring Security's OIDC support is multi-tenant. +This means that it will only terminate sessions whose Client matches the `aud` claim in the Logout Token. + +=== Customizing the OIDC Provider Session Strategy + +By default, Spring Security stores in-memory all links between the OIDC Provider session and the Client session. + +There are a number of circumstances, like a clustered application, where it would be nice to store this instead in a separate location, like a database. + +You can achieve this by configuring a custom `OidcSessionStrategy`, like so: + +[tabs] +====== +Java:: ++ +[source=java,role="primary"] +---- +@Component +public final class MySpringDataOidcSessionStrategy implements OidcSessionStrategy { + private final OidcProviderSessionRepository sessions; + + // ... + + @Override + public void saveSessionInformation(OidcSessionInformation info) { + this.sessions.save(info); + } + + @Override + public OidcSessionInformation(String clientSessionId) { + return this.sessions.removeByClientSessionId(clientSessionId); + } + + @Override + public Iterable removeSessionInformation(OidcLogoutToken token) { + return token.getSessionId() != null ? + this.sessions.removeBySessionIdAndIssuerAndAudience(...) : + this.sessions.removeBySubjectAndIssuerAndAudience(...); + } +} +---- + +Kotlin:: ++ +[source=kotlin,role="secondary"] +---- +@Component +class MySpringDataOidcSessionStrategy: OidcSessionStrategy { + val sessions: OidcProviderSessionRepository + + // ... + + @Override + fun saveSessionInformation(info: OidcSessionInformation) { + this.sessions.save(info) + } + + @Override + fun removeSessionInformation(clientSessionId: String): OidcSessionInformation { + return this.sessions.removeByClientSessionId(clientSessionId); + } + + @Override + fun removeSessionInformation(token: OidcLogoutToken): Iterable { + return token.getSessionId() != null ? + this.sessions.removeBySessionIdAndIssuerAndAudience(...) : + this.sessions.removeBySubjectAndIssuerAndAudience(...); + } +} +---- +====== + diff --git a/etc/nohttp/allowlist.lines b/etc/nohttp/allowlist.lines index a378625640..330ed0f5ce 100644 --- a/etc/nohttp/allowlist.lines +++ b/etc/nohttp/allowlist.lines @@ -10,4 +10,5 @@ ^http://www.w3.org/2001/04/xmlenc ^http://www.springframework.org/schema/security/.* ^http://openoffice.org/.* -^http://www.w3.org/2003/g/data-view \ No newline at end of file +^http://www.w3.org/2003/g/data-view +^http://schemas.openid.net/event/backchannel-logout diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/LogoutTokenClaimAccessor.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/LogoutTokenClaimAccessor.java new file mode 100644 index 0000000000..49aeff4c3c --- /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 0000000000..9893aa350a --- /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/OidcLogoutToken.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/logout/OidcLogoutToken.java new file mode 100644 index 0000000000..41b425bf40 --- /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/server/session/InMemoryReactiveOidcSessionRegistry.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/server/session/InMemoryReactiveOidcSessionRegistry.java new file mode 100644 index 0000000000..cd1e92ac93 --- /dev/null +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/server/session/InMemoryReactiveOidcSessionRegistry.java @@ -0,0 +1,53 @@ +/* + * Copyright 2002-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.oauth2.client.oidc.server.session; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +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; + +/** + * An in-memory implementation of + * {@link org.springframework.security.oauth2.client.oidc.server.session.ReactiveOidcSessionRegistry} + * + * @author Josh Cummings + * @since 6.2 + */ +public final class InMemoryReactiveOidcSessionRegistry implements ReactiveOidcSessionRegistry { + + private final InMemoryOidcSessionRegistry delegate = new InMemoryOidcSessionRegistry(); + + @Override + public Mono saveSessionInformation(OidcSessionInformation info) { + this.delegate.saveSessionInformation(info); + return Mono.empty(); + } + + @Override + public Mono removeSessionInformation(String clientSessionId) { + return Mono.justOrEmpty(this.delegate.removeSessionInformation(clientSessionId)); + } + + @Override + public Flux removeSessionInformation(OidcLogoutToken token) { + return Flux.fromIterable(this.delegate.removeSessionInformation(token)); + } + +} diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/server/session/ReactiveOidcSessionRegistry.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/server/session/ReactiveOidcSessionRegistry.java new file mode 100644 index 0000000000..a4cbb39f46 --- /dev/null +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/server/session/ReactiveOidcSessionRegistry.java @@ -0,0 +1,63 @@ +/* + * 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.server.session; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcLogoutToken; +import org.springframework.security.oauth2.client.oidc.session.OidcSessionInformation; + +/** + * 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 ReactiveOidcSessionRegistry { + + /** + * 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 + */ + Mono 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} + */ + Mono 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 + */ + Flux removeSessionInformation(OidcLogoutToken logoutToken); + +} 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 0000000000..f5bb6235df --- /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 0000000000..d746315178 --- /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 0000000000..26bae499db --- /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/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 0000000000..15788c830e --- /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 0000000000..861eccce7e --- /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 0000000000..47f64868de --- /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-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 ca859473d1..2271a52e00 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 final class TestOidcIdTokens { // @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 3bda7ec32d..ca2c37abf7 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 @@ public final class TestOidcUsers { .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