mirror of
				https://github.com/spring-projects/spring-security.git
				synced 2025-10-31 06:38:42 +00:00 
			
		
		
		
	Add OIDC Back-Channel Logout Support
Closes gh-12570
This commit is contained in:
		
							parent
							
								
									1461c0f648
								
							
						
					
					
						commit
						cb33fd7850
					
				| @ -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.X509Configurer; | ||||||
| import org.springframework.security.config.annotation.web.configurers.oauth2.client.OAuth2ClientConfigurer; | 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.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.oauth2.server.resource.OAuth2ResourceServerConfigurer; | ||||||
| import org.springframework.security.config.annotation.web.configurers.saml2.Saml2LoginConfigurer; | import org.springframework.security.config.annotation.web.configurers.saml2.Saml2LoginConfigurer; | ||||||
| import org.springframework.security.config.annotation.web.configurers.saml2.Saml2LogoutConfigurer; | import org.springframework.security.config.annotation.web.configurers.saml2.Saml2LogoutConfigurer; | ||||||
| @ -2835,6 +2836,16 @@ public final class HttpSecurity extends AbstractConfiguredSecurityBuilder<Defaul | |||||||
| 		return HttpSecurity.this; | 		return HttpSecurity.this; | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
|  | 	public OidcLogoutConfigurer<HttpSecurity> oidcLogout() throws Exception { | ||||||
|  | 		return getOrApply(new OidcLogoutConfigurer<>()); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	public HttpSecurity oidcLogout(Customizer<OidcLogoutConfigurer<HttpSecurity>> oidcLogoutCustomizer) | ||||||
|  | 			throws Exception { | ||||||
|  | 		oidcLogoutCustomizer.customize(getOrApply(new OidcLogoutConfigurer<>())); | ||||||
|  | 		return HttpSecurity.this; | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
| 	/** | 	/** | ||||||
| 	 * Configures OAuth 2.0 Client support. | 	 * Configures OAuth 2.0 Client support. | ||||||
| 	 * @return the {@link OAuth2ClientConfigurer} for further customizations | 	 * @return the {@link OAuth2ClientConfigurer} for further customizations | ||||||
|  | |||||||
| @ -296,7 +296,7 @@ public final class SessionManagementConfigurer<H extends HttpSecurityBuilder<H>> | |||||||
| 	 * @param sessionAuthenticationStrategy | 	 * @param sessionAuthenticationStrategy | ||||||
| 	 * @return the {@link SessionManagementConfigurer} for further customizations | 	 * @return the {@link SessionManagementConfigurer} for further customizations | ||||||
| 	 */ | 	 */ | ||||||
| 	SessionManagementConfigurer<H> addSessionAuthenticationStrategy( | 	public SessionManagementConfigurer<H> addSessionAuthenticationStrategy( | ||||||
| 			SessionAuthenticationStrategy sessionAuthenticationStrategy) { | 			SessionAuthenticationStrategy sessionAuthenticationStrategy) { | ||||||
| 		this.sessionAuthenticationStrategies.add(sessionAuthenticationStrategy); | 		this.sessionAuthenticationStrategies.add(sessionAuthenticationStrategy); | ||||||
| 		return this; | 		return this; | ||||||
|  | |||||||
| @ -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<ClientRegistration, OAuth2TokenValidator<Jwt>> { | ||||||
|  | 
 | ||||||
|  | 	@Override | ||||||
|  | 	public OAuth2TokenValidator<Jwt> apply(ClientRegistration clientRegistration) { | ||||||
|  | 		return new DelegatingOAuth2TokenValidator<>(new JwtTimestampValidator(), | ||||||
|  | 				new OidcBackChannelLogoutTokenValidator(clientRegistration)); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | } | ||||||
| @ -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.config.annotation.web.configurers.AbstractHttpConfigurer; | ||||||
| import org.springframework.security.oauth2.client.InMemoryOAuth2AuthorizedClientService; | import org.springframework.security.oauth2.client.InMemoryOAuth2AuthorizedClientService; | ||||||
| import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService; | 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.registration.ClientRegistrationRepository; | ||||||
| import org.springframework.security.oauth2.client.web.AuthenticatedPrincipalOAuth2AuthorizedClientRepository; | import org.springframework.security.oauth2.client.web.AuthenticatedPrincipalOAuth2AuthorizedClientRepository; | ||||||
| import org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepository; | import org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepository; | ||||||
| @ -112,4 +114,13 @@ final class OAuth2ClientConfigurerUtils { | |||||||
| 		return (!authorizedClientServiceMap.isEmpty() ? authorizedClientServiceMap.values().iterator().next() : null); | 		return (!authorizedClientServiceMap.isEmpty() ? authorizedClientServiceMap.values().iterator().next() : null); | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
|  | 	static <B extends HttpSecurityBuilder<B>> OidcSessionRegistry getOidcSessionRegistry(B builder) { | ||||||
|  | 		OidcSessionRegistry sessionRegistry = builder.getSharedObject(OidcSessionRegistry.class); | ||||||
|  | 		if (sessionRegistry == null) { | ||||||
|  | 			sessionRegistry = new InMemoryOidcSessionRegistry(); | ||||||
|  | 			builder.setSharedObject(OidcSessionRegistry.class, sessionRegistry); | ||||||
|  | 		} | ||||||
|  | 		return sessionRegistry; | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
| } | } | ||||||
|  | |||||||
| @ -22,9 +22,18 @@ import java.util.HashMap; | |||||||
| import java.util.LinkedHashMap; | import java.util.LinkedHashMap; | ||||||
| import java.util.Map; | 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.BeanFactoryUtils; | ||||||
| import org.springframework.beans.factory.NoUniqueBeanDefinitionException; | import org.springframework.beans.factory.NoUniqueBeanDefinitionException; | ||||||
| import org.springframework.context.ApplicationContext; | 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.core.ResolvableType; | ||||||
| import org.springframework.security.authentication.AuthenticationProvider; | import org.springframework.security.authentication.AuthenticationProvider; | ||||||
| import org.springframework.security.config.Customizer; | 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.builders.HttpSecurity; | ||||||
| import org.springframework.security.config.annotation.web.configurers.AbstractAuthenticationFilterConfigurer; | 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.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.Authentication; | ||||||
| import org.springframework.security.core.AuthenticationException; | import org.springframework.security.core.AuthenticationException; | ||||||
| import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper; | 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.OAuth2AuthorizedClientService; | ||||||
| import org.springframework.security.oauth2.client.authentication.OAuth2LoginAuthenticationProvider; | import org.springframework.security.oauth2.client.authentication.OAuth2LoginAuthenticationProvider; | ||||||
| import org.springframework.security.oauth2.client.authentication.OAuth2LoginAuthenticationToken; | 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.OAuth2AccessTokenResponseClient; | ||||||
| import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest; | import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest; | ||||||
| import org.springframework.security.oauth2.client.oidc.authentication.OidcAuthorizationCodeAuthenticationProvider; | 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.OidcUserRequest; | ||||||
| import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserService; | import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserService; | ||||||
| import org.springframework.security.oauth2.client.registration.ClientRegistration; | 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.RedirectStrategy; | ||||||
| import org.springframework.security.web.authentication.DelegatingAuthenticationEntryPoint; | import org.springframework.security.web.authentication.DelegatingAuthenticationEntryPoint; | ||||||
| import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint; | 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.authentication.ui.DefaultLoginPageGeneratingFilter; | ||||||
|  | import org.springframework.security.web.csrf.CsrfToken; | ||||||
| import org.springframework.security.web.savedrequest.RequestCache; | import org.springframework.security.web.savedrequest.RequestCache; | ||||||
| import org.springframework.security.web.util.matcher.AndRequestMatcher; | import org.springframework.security.web.util.matcher.AndRequestMatcher; | ||||||
| import org.springframework.security.web.util.matcher.AntPathRequestMatcher; | import org.springframework.security.web.util.matcher.AntPathRequestMatcher; | ||||||
| @ -124,6 +144,7 @@ import org.springframework.util.ReflectionUtils; | |||||||
|  * <li>{@link DefaultLoginPageGeneratingFilter} - if {@link #loginPage(String)} is not |  * <li>{@link DefaultLoginPageGeneratingFilter} - if {@link #loginPage(String)} is not | ||||||
|  * configured and {@code DefaultLoginPageGeneratingFilter} is available, then a default |  * configured and {@code DefaultLoginPageGeneratingFilter} is available, then a default | ||||||
|  * login page will be made available</li> |  * login page will be made available</li> | ||||||
|  |  * <li>{@link OidcSessionRegistry}</li> | ||||||
|  * </ul> |  * </ul> | ||||||
|  * |  * | ||||||
|  * @author Joe Grandja |  * @author Joe Grandja | ||||||
| @ -202,6 +223,18 @@ public final class OAuth2LoginConfigurer<B extends HttpSecurityBuilder<B>> | |||||||
| 		return this; | 		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<B> 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 | 	 * Returns the {@link AuthorizationEndpointConfig} for configuring the Authorization | ||||||
| 	 * Server's Authorization Endpoint. | 	 * Server's Authorization Endpoint. | ||||||
| @ -397,6 +430,7 @@ public final class OAuth2LoginConfigurer<B extends HttpSecurityBuilder<B>> | |||||||
| 			authenticationFilter | 			authenticationFilter | ||||||
| 					.setAuthorizationRequestRepository(this.authorizationEndpointConfig.authorizationRequestRepository); | 					.setAuthorizationRequestRepository(this.authorizationEndpointConfig.authorizationRequestRepository); | ||||||
| 		} | 		} | ||||||
|  | 		configureOidcSessionRegistry(http); | ||||||
| 		super.configure(http); | 		super.configure(http); | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| @ -546,6 +580,29 @@ public final class OAuth2LoginConfigurer<B extends HttpSecurityBuilder<B>> | |||||||
| 		return AnyRequestMatcher.INSTANCE; | 		return AnyRequestMatcher.INSTANCE; | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
|  | 	private void configureOidcSessionRegistry(B http) { | ||||||
|  | 		OidcSessionRegistry sessionRegistry = OAuth2ClientConfigurerUtils.getOidcSessionRegistry(http); | ||||||
|  | 		SessionManagementConfigurer<B> 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. | 	 * Configuration options for the Authorization Server's Authorization Endpoint. | ||||||
| 	 */ | 	 */ | ||||||
| @ -793,4 +850,83 @@ public final class OAuth2LoginConfigurer<B extends HttpSecurityBuilder<B>> | |||||||
| 
 | 
 | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
|  | 	private static final class OidcClientSessionEventListener implements ApplicationListener<AbstractSessionEvent> { | ||||||
|  | 
 | ||||||
|  | 		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<String, String> 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; | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
| } | } | ||||||
|  | |||||||
| @ -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 <a target="_blank" href= | ||||||
|  |  * "https://openid.net/specs/openid-connect-backchannel-1_0.html">OIDC Back-Channel | ||||||
|  |  * Logout</a> | ||||||
|  |  */ | ||||||
|  | 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; | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | } | ||||||
| @ -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. | ||||||
|  |  * | ||||||
|  |  * <p> | ||||||
|  |  * 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 <a target="_blank" href= | ||||||
|  |  * "https://openid.net/specs/openid-connect-backchannel-1_0.html">OIDC Back-Channel | ||||||
|  |  * Logout</a> | ||||||
|  |  */ | ||||||
|  | final class OidcBackChannelLogoutAuthenticationProvider implements AuthenticationProvider { | ||||||
|  | 
 | ||||||
|  | 	private JwtDecoderFactory<ClientRegistration> 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<ClientRegistration> logoutTokenDecoderFactory) { | ||||||
|  | 		Assert.notNull(logoutTokenDecoderFactory, "logoutTokenDecoderFactory cannot be null"); | ||||||
|  | 		this.logoutTokenDecoderFactory = logoutTokenDecoderFactory; | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | } | ||||||
| @ -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 <a target="_blank" href= | ||||||
|  |  * "https://openid.net/specs/openid-connect-backchannel-1_0.html">OIDC Back-Channel Logout | ||||||
|  |  * Spec</a> | ||||||
|  |  */ | ||||||
|  | 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; | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | } | ||||||
| @ -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 <a target="_blank" href= | ||||||
|  |  * "https://openid.net/specs/openid-connect-backchannel-1_0.html">OIDC Back-Channel Logout | ||||||
|  |  * Spec</a> | ||||||
|  |  */ | ||||||
|  | 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<OidcSessionInformation> sessions = this.sessionRegistry.removeSessionInformation(token.getPrincipal()); | ||||||
|  | 		Collection<String> 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<String, String> 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<String> 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}. | ||||||
|  | 	 * | ||||||
|  | 	 * <p> | ||||||
|  | 	 * 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; | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | } | ||||||
| @ -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 <a target="_blank" href= | ||||||
|  |  * "https://openid.net/specs/openid-connect-backchannel-1_0.html#LogoutToken">Logout | ||||||
|  |  * Token</a> | ||||||
|  |  * @see <a target="blank" href= | ||||||
|  |  * "https://openid.net/specs/openid-connect-backchannel-1_0.html#Validation">the OIDC | ||||||
|  |  * Back-Channel Logout spec</a> | ||||||
|  |  */ | ||||||
|  | final class OidcBackChannelLogoutTokenValidator implements OAuth2TokenValidator<Jwt> { | ||||||
|  | 
 | ||||||
|  | 	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<OAuth2Error> errors = new ArrayList<>(); | ||||||
|  | 
 | ||||||
|  | 		LogoutTokenClaimAccessor logoutClaims = jwt::getClaims; | ||||||
|  | 		Map<String, Object> 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<String> 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); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | } | ||||||
| @ -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; | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | } | ||||||
| @ -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; | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | } | ||||||
| @ -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 | ||||||
|  |  * | ||||||
|  |  * <p> | ||||||
|  |  * 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. | ||||||
|  |  * | ||||||
|  |  * | ||||||
|  |  * <h2>Security Filters</h2> | ||||||
|  |  * | ||||||
|  |  * The following {@code Filter} is populated: | ||||||
|  |  * | ||||||
|  |  * <ul> | ||||||
|  |  * <li>{@link OidcBackChannelLogoutFilter}</li> | ||||||
|  |  * </ul> | ||||||
|  |  * | ||||||
|  |  * <h2>Shared Objects Used</h2> | ||||||
|  |  * | ||||||
|  |  * The following shared objects are used: | ||||||
|  |  * | ||||||
|  |  * <ul> | ||||||
|  |  * <li>{@link ClientRegistrationRepository}</li> | ||||||
|  |  * </ul> | ||||||
|  |  * | ||||||
|  |  * @author Josh Cummings | ||||||
|  |  * @since 6.2 | ||||||
|  |  * @see HttpSecurity#oidcLogout() | ||||||
|  |  * @see OidcBackChannelLogoutFilter | ||||||
|  |  * @see ClientRegistrationRepository | ||||||
|  |  */ | ||||||
|  | public final class OidcLogoutConfigurer<B extends HttpSecurityBuilder<B>> | ||||||
|  | 		extends AbstractHttpConfigurer<OidcLogoutConfigurer<B>, 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<B> 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<B> 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<B> backChannel(Customizer<BackChannelLogoutConfigurer> 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); | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | } | ||||||
| @ -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<ClientRegistration, OAuth2TokenValidator<Jwt>> { | ||||||
|  | 
 | ||||||
|  | 	@Override | ||||||
|  | 	public OAuth2TokenValidator<Jwt> apply(ClientRegistration clientRegistration) { | ||||||
|  | 		return new DelegatingOAuth2TokenValidator<>(new JwtTimestampValidator(), | ||||||
|  | 				new OidcBackChannelLogoutTokenValidator(clientRegistration)); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | } | ||||||
| @ -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 <a target="_blank" href= | ||||||
|  |  * "https://openid.net/specs/openid-connect-backchannel-1_0.html">OIDC Back-Channel | ||||||
|  |  * Logout</a> | ||||||
|  |  */ | ||||||
|  | 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; | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | } | ||||||
| @ -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. | ||||||
|  |  * | ||||||
|  |  * <p> | ||||||
|  |  * 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 <a target="_blank" href= | ||||||
|  |  * "https://openid.net/specs/openid-connect-backchannel-1_0.html">OIDC Back-Channel | ||||||
|  |  * Logout</a> | ||||||
|  |  */ | ||||||
|  | final class OidcBackChannelLogoutReactiveAuthenticationManager implements ReactiveAuthenticationManager { | ||||||
|  | 
 | ||||||
|  | 	private ReactiveJwtDecoderFactory<ClientRegistration> logoutTokenDecoderFactory; | ||||||
|  | 
 | ||||||
|  | 	/** | ||||||
|  | 	 * Construct an {@link OidcBackChannelLogoutReactiveAuthenticationManager} | ||||||
|  | 	 */ | ||||||
|  | 	OidcBackChannelLogoutReactiveAuthenticationManager() { | ||||||
|  | 		ReactiveOidcIdTokenDecoderFactory logoutTokenDecoderFactory = new ReactiveOidcIdTokenDecoderFactory(); | ||||||
|  | 		logoutTokenDecoderFactory.setJwtValidatorFactory(new DefaultOidcLogoutTokenValidatorFactory()); | ||||||
|  | 		this.logoutTokenDecoderFactory = logoutTokenDecoderFactory; | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	/** | ||||||
|  | 	 * {@inheritDoc} | ||||||
|  | 	 */ | ||||||
|  | 	@Override | ||||||
|  | 	public Mono<Authentication> 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<Jwt> 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<ClientRegistration> logoutTokenDecoderFactory) { | ||||||
|  | 		Assert.notNull(logoutTokenDecoderFactory, "logoutTokenDecoderFactory cannot be null"); | ||||||
|  | 		this.logoutTokenDecoderFactory = logoutTokenDecoderFactory; | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | } | ||||||
| @ -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 <a target="_blank" href= | ||||||
|  |  * "https://openid.net/specs/openid-connect-backchannel-1_0.html#LogoutToken">Logout | ||||||
|  |  * Token</a> | ||||||
|  |  * @see <a target="blank" href= | ||||||
|  |  * "https://openid.net/specs/openid-connect-backchannel-1_0.html#Validation">the OIDC | ||||||
|  |  * Back-Channel Logout spec</a> | ||||||
|  |  */ | ||||||
|  | final class OidcBackChannelLogoutTokenValidator implements OAuth2TokenValidator<Jwt> { | ||||||
|  | 
 | ||||||
|  | 	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<OAuth2Error> errors = new ArrayList<>(); | ||||||
|  | 
 | ||||||
|  | 		LogoutTokenClaimAccessor logoutClaims = jwt::getClaims; | ||||||
|  | 		Map<String, Object> 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<String> 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); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | } | ||||||
| @ -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 <a target="_blank" href= | ||||||
|  |  * "https://openid.net/specs/openid-connect-backchannel-1_0.html">OIDC Back-Channel Logout | ||||||
|  |  * Spec</a> | ||||||
|  |  */ | ||||||
|  | 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<Void> 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<Void> 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; | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | } | ||||||
| @ -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 <a target="_blank" href= | ||||||
|  |  * "https://openid.net/specs/openid-connect-backchannel-1_0.html">OIDC Back-Channel Logout | ||||||
|  |  * Spec</a> | ||||||
|  |  */ | ||||||
|  | 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<Void> 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<ResponseEntity<Void>> eachLogout(WebFilterExchange exchange, OidcSessionInformation session) { | ||||||
|  | 		HttpHeaders headers = new HttpHeaders(); | ||||||
|  | 		headers.add(HttpHeaders.COOKIE, this.sessionCookieName + "=" + session.getSessionId()); | ||||||
|  | 		for (Map.Entry<String, String> 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<Void> 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}. | ||||||
|  | 	 * | ||||||
|  | 	 * <p> | ||||||
|  | 	 * 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; | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | } | ||||||
| @ -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; | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | } | ||||||
| @ -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<Authentication> 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; | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | } | ||||||
| @ -21,6 +21,7 @@ import java.io.PrintWriter; | |||||||
| import java.io.StringWriter; | import java.io.StringWriter; | ||||||
| import java.security.interfaces.RSAPublicKey; | import java.security.interfaces.RSAPublicKey; | ||||||
| import java.time.Duration; | import java.time.Duration; | ||||||
|  | import java.time.Instant; | ||||||
| import java.util.ArrayList; | import java.util.ArrayList; | ||||||
| import java.util.Arrays; | import java.util.Arrays; | ||||||
| import java.util.Collections; | import java.util.Collections; | ||||||
| @ -28,10 +29,13 @@ import java.util.HashMap; | |||||||
| import java.util.List; | import java.util.List; | ||||||
| import java.util.Map; | import java.util.Map; | ||||||
| import java.util.UUID; | import java.util.UUID; | ||||||
|  | import java.util.function.Consumer; | ||||||
| import java.util.function.Function; | import java.util.function.Function; | ||||||
| import java.util.function.Supplier; | import java.util.function.Supplier; | ||||||
| 
 | 
 | ||||||
| import io.micrometer.observation.ObservationRegistry; | import io.micrometer.observation.ObservationRegistry; | ||||||
|  | import org.apache.commons.logging.Log; | ||||||
|  | import org.apache.commons.logging.LogFactory; | ||||||
| import reactor.core.publisher.Mono; | import reactor.core.publisher.Mono; | ||||||
| import reactor.util.context.Context; | 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.ReactiveOAuth2AccessTokenResponseClient; | ||||||
| import org.springframework.security.oauth2.client.endpoint.WebClientReactiveAuthorizationCodeTokenResponseClient; | import org.springframework.security.oauth2.client.endpoint.WebClientReactiveAuthorizationCodeTokenResponseClient; | ||||||
| import org.springframework.security.oauth2.client.oidc.authentication.OidcAuthorizationCodeReactiveAuthenticationManager; | 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.OidcReactiveOAuth2UserService; | ||||||
| import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest; | import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest; | ||||||
| import org.springframework.security.oauth2.client.registration.ClientRegistration; | 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.SecurityWebFilterChain; | ||||||
| import org.springframework.security.web.server.ServerAuthenticationEntryPoint; | import org.springframework.security.web.server.ServerAuthenticationEntryPoint; | ||||||
| import org.springframework.security.web.server.ServerRedirectStrategy; | 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.AnonymousAuthenticationWebFilter; | ||||||
| import org.springframework.security.web.server.authentication.AuthenticationConverterServerWebExchangeMatcher; | import org.springframework.security.web.server.authentication.AuthenticationConverterServerWebExchangeMatcher; | ||||||
| import org.springframework.security.web.server.authentication.AuthenticationWebFilter; | 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.ServerSecurityContextRepository; | ||||||
| import org.springframework.security.web.server.context.WebSessionServerSecurityContextRepository; | import org.springframework.security.web.server.context.WebSessionServerSecurityContextRepository; | ||||||
| import org.springframework.security.web.server.csrf.CsrfServerLogoutHandler; | 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.CsrfWebFilter; | ||||||
| import org.springframework.security.web.server.csrf.ServerCsrfTokenRepository; | import org.springframework.security.web.server.csrf.ServerCsrfTokenRepository; | ||||||
| import org.springframework.security.web.server.csrf.ServerCsrfTokenRequestHandler; | 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.cors.reactive.DefaultCorsProcessor; | ||||||
| import org.springframework.web.reactive.result.method.annotation.RequestMappingHandlerMapping; | import org.springframework.web.reactive.result.method.annotation.RequestMappingHandlerMapping; | ||||||
| import org.springframework.web.server.ServerWebExchange; | import org.springframework.web.server.ServerWebExchange; | ||||||
|  | import org.springframework.web.server.ServerWebExchangeDecorator; | ||||||
| import org.springframework.web.server.WebFilter; | import org.springframework.web.server.WebFilter; | ||||||
| import org.springframework.web.server.WebFilterChain; | import org.springframework.web.server.WebFilterChain; | ||||||
|  | import org.springframework.web.server.WebSession; | ||||||
| import org.springframework.web.util.pattern.PathPatternParser; | import org.springframework.web.util.pattern.PathPatternParser; | ||||||
| 
 | 
 | ||||||
| /** | /** | ||||||
| @ -295,6 +306,8 @@ public class ServerHttpSecurity { | |||||||
| 
 | 
 | ||||||
| 	private OAuth2ClientSpec client; | 	private OAuth2ClientSpec client; | ||||||
| 
 | 
 | ||||||
|  | 	private OidcLogoutSpec oidcLogout; | ||||||
|  | 
 | ||||||
| 	private LogoutSpec logout = new LogoutSpec(); | 	private LogoutSpec logout = new LogoutSpec(); | ||||||
| 
 | 
 | ||||||
| 	private LoginPageSpec loginPage = new LoginPageSpec(); | 	private LoginPageSpec loginPage = new LoginPageSpec(); | ||||||
| @ -1093,6 +1106,33 @@ public class ServerHttpSecurity { | |||||||
| 		return this; | 		return this; | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
|  | 	/** | ||||||
|  | 	 * Configures OIDC Connect 1.0 Logout support. | ||||||
|  | 	 * | ||||||
|  | 	 * <pre class="code"> | ||||||
|  | 	 *  @Bean | ||||||
|  | 	 *  public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) { | ||||||
|  | 	 *      http | ||||||
|  | 	 *          // ... | ||||||
|  | 	 *          .oidcLogout((logout) -> logout | ||||||
|  | 	 *              .backChannel(Customizer.withDefaults()) | ||||||
|  | 	 *          ); | ||||||
|  | 	 *      return http.build(); | ||||||
|  | 	 *  } | ||||||
|  | 	 * </pre> | ||||||
|  | 	 * @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<OidcLogoutSpec> oidcLogoutCustomizer) { | ||||||
|  | 		if (this.oidcLogout == null) { | ||||||
|  | 			this.oidcLogout = new OidcLogoutSpec(); | ||||||
|  | 		} | ||||||
|  | 		oidcLogoutCustomizer.customize(this.oidcLogout); | ||||||
|  | 		return this; | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
| 	/** | 	/** | ||||||
| 	 * Configures HTTP Response Headers. The default headers are: | 	 * Configures HTTP Response Headers. The default headers are: | ||||||
| 	 * | 	 * | ||||||
| @ -1537,6 +1577,9 @@ public class ServerHttpSecurity { | |||||||
| 		if (this.resourceServer != null) { | 		if (this.resourceServer != null) { | ||||||
| 			this.resourceServer.configure(this); | 			this.resourceServer.configure(this); | ||||||
| 		} | 		} | ||||||
|  | 		if (this.oidcLogout != null) { | ||||||
|  | 			this.oidcLogout.configure(this); | ||||||
|  | 		} | ||||||
| 		if (this.client != null) { | 		if (this.client != null) { | ||||||
| 			this.client.configure(this); | 			this.client.configure(this); | ||||||
| 		} | 		} | ||||||
| @ -3689,6 +3732,8 @@ public class ServerHttpSecurity { | |||||||
| 
 | 
 | ||||||
| 		private ServerWebExchangeMatcher authenticationMatcher; | 		private ServerWebExchangeMatcher authenticationMatcher; | ||||||
| 
 | 
 | ||||||
|  | 		private ReactiveOidcSessionRegistry oidcSessionRegistry; | ||||||
|  | 
 | ||||||
| 		private ServerAuthenticationSuccessHandler authenticationSuccessHandler; | 		private ServerAuthenticationSuccessHandler authenticationSuccessHandler; | ||||||
| 
 | 
 | ||||||
| 		private ServerAuthenticationFailureHandler authenticationFailureHandler; | 		private ServerAuthenticationFailureHandler authenticationFailureHandler; | ||||||
| @ -3720,6 +3765,20 @@ public class ServerHttpSecurity { | |||||||
| 			return this; | 			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 | 		 * The {@link ServerAuthenticationSuccessHandler} used after authentication | ||||||
| 		 * success. Defaults to {@link RedirectServerAuthenticationSuccessHandler} | 		 * success. Defaults to {@link RedirectServerAuthenticationSuccessHandler} | ||||||
| @ -3913,8 +3972,9 @@ public class ServerHttpSecurity { | |||||||
| 			oauthRedirectFilter.setRequestCache(http.requestCache.requestCache); | 			oauthRedirectFilter.setRequestCache(http.requestCache.requestCache); | ||||||
| 
 | 
 | ||||||
| 			ReactiveAuthenticationManager manager = getAuthenticationManager(); | 			ReactiveAuthenticationManager manager = getAuthenticationManager(); | ||||||
| 			AuthenticationWebFilter authenticationFilter = new OAuth2LoginAuthenticationWebFilter(manager, | 			ReactiveOidcSessionRegistry sessionRegistry = getOidcSessionRegistry(); | ||||||
| 					authorizedClientRepository); | 			AuthenticationWebFilter authenticationFilter = new OidcSessionRegistryAuthenticationWebFilter(manager, | ||||||
|  | 					authorizedClientRepository, sessionRegistry); | ||||||
| 			authenticationFilter.setRequiresAuthenticationMatcher(getAuthenticationMatcher()); | 			authenticationFilter.setRequiresAuthenticationMatcher(getAuthenticationMatcher()); | ||||||
| 			authenticationFilter | 			authenticationFilter | ||||||
| 					.setServerAuthenticationConverter(getAuthenticationConverter(clientRegistrationRepository)); | 					.setServerAuthenticationConverter(getAuthenticationConverter(clientRegistrationRepository)); | ||||||
| @ -3923,6 +3983,8 @@ public class ServerHttpSecurity { | |||||||
| 			authenticationFilter.setSecurityContextRepository(this.securityContextRepository); | 			authenticationFilter.setSecurityContextRepository(this.securityContextRepository); | ||||||
| 
 | 
 | ||||||
| 			setDefaultEntryPoints(http); | 			setDefaultEntryPoints(http); | ||||||
|  | 			http.addFilterAfter(new OidcSessionRegistryWebFilter(sessionRegistry), | ||||||
|  | 					SecurityWebFiltersOrder.HTTP_HEADERS_WRITER); | ||||||
| 			http.addFilterAt(oauthRedirectFilter, SecurityWebFiltersOrder.HTTP_BASIC); | 			http.addFilterAt(oauthRedirectFilter, SecurityWebFiltersOrder.HTTP_BASIC); | ||||||
| 			http.addFilterAt(authenticationFilter, SecurityWebFiltersOrder.AUTHENTICATION); | 			http.addFilterAt(authenticationFilter, SecurityWebFiltersOrder.AUTHENTICATION); | ||||||
| 		} | 		} | ||||||
| @ -3967,6 +4029,16 @@ public class ServerHttpSecurity { | |||||||
| 			http.defaultEntryPoints.add(new DelegateEntry(defaultEntryPointMatcher, defaultEntryPoint)); | 			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) { | 		private ServerAuthenticationSuccessHandler getAuthenticationSuccessHandler(ServerHttpSecurity http) { | ||||||
| 			if (this.authenticationSuccessHandler == null) { | 			if (this.authenticationSuccessHandler == null) { | ||||||
| 				RedirectServerAuthenticationSuccessHandler handler = new RedirectServerAuthenticationSuccessHandler(); | 				RedirectServerAuthenticationSuccessHandler handler = new RedirectServerAuthenticationSuccessHandler(); | ||||||
| @ -4083,6 +4155,154 @@ public class ServerHttpSecurity { | |||||||
| 			return new InMemoryReactiveOAuth2AuthorizedClientService(getClientRegistrationRepository()); | 			return new InMemoryReactiveOAuth2AuthorizedClientService(getClientRegistrationRepository()); | ||||||
| 		} | 		} | ||||||
| 
 | 
 | ||||||
|  | 		private static final class OidcSessionRegistryWebFilter implements WebFilter { | ||||||
|  | 
 | ||||||
|  | 			private final ReactiveOidcSessionRegistry oidcSessionRegistry; | ||||||
|  | 
 | ||||||
|  | 			OidcSessionRegistryWebFilter(ReactiveOidcSessionRegistry oidcSessionRegistry) { | ||||||
|  | 				this.oidcSessionRegistry = oidcSessionRegistry; | ||||||
|  | 			} | ||||||
|  | 
 | ||||||
|  | 			@Override | ||||||
|  | 			public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) { | ||||||
|  | 				return chain.filter(new OidcSessionRegistryServerWebExchange(exchange)); | ||||||
|  | 			} | ||||||
|  | 
 | ||||||
|  | 			private final class OidcSessionRegistryServerWebExchange extends ServerWebExchangeDecorator { | ||||||
|  | 
 | ||||||
|  | 				private final Mono<WebSession> sessionMono; | ||||||
|  | 
 | ||||||
|  | 				protected OidcSessionRegistryServerWebExchange(ServerWebExchange delegate) { | ||||||
|  | 					super(delegate); | ||||||
|  | 					this.sessionMono = delegate.getSession().map(OidcSessionRegistryWebSession::new); | ||||||
|  | 				} | ||||||
|  | 
 | ||||||
|  | 				@Override | ||||||
|  | 				public Mono<WebSession> 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<String, Object> getAttributes() { | ||||||
|  | 						return this.session.getAttributes(); | ||||||
|  | 					} | ||||||
|  | 
 | ||||||
|  | 					@Override | ||||||
|  | 					public void start() { | ||||||
|  | 						this.session.start(); | ||||||
|  | 					} | ||||||
|  | 
 | ||||||
|  | 					@Override | ||||||
|  | 					public boolean isStarted() { | ||||||
|  | 						return this.session.isStarted(); | ||||||
|  | 					} | ||||||
|  | 
 | ||||||
|  | 					@Override | ||||||
|  | 					public Mono<Void> 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<Void> invalidate() { | ||||||
|  | 						String currentId = this.session.getId(); | ||||||
|  | 						return this.session.invalidate() | ||||||
|  | 								.then(Mono.defer(() -> OidcSessionRegistryWebFilter.this.oidcSessionRegistry | ||||||
|  | 										.removeSessionInformation(currentId).then(Mono.empty()))); | ||||||
|  | 					} | ||||||
|  | 
 | ||||||
|  | 					@Override | ||||||
|  | 					public Mono<Void> 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<Void> 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> 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 { | 	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> 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 | 	 * Configures anonymous authentication | ||||||
| 	 * | 	 * | ||||||
|  | |||||||
| @ -868,6 +868,38 @@ class HttpSecurityDsl(private val http: HttpSecurity, private val init: HttpSecu | |||||||
|         this.http.oauth2ResourceServer(oauth2ResourceServerCustomizer) |         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. |      * Configures Remember Me authentication. | ||||||
|      * |      * | ||||||
|  | |||||||
| @ -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<HttpSecurity>.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<HttpSecurity>) -> Unit { | ||||||
|  |         return { oidcLogout -> | ||||||
|  |             clientRegistrationRepository?.also { oidcLogout.clientRegistrationRepository(clientRegistrationRepository) } | ||||||
|  |             oidcSessionRegistry?.also { oidcLogout.oidcSessionRegistry(oidcSessionRegistry) } | ||||||
|  |             backChannel?.also { oidcLogout.backChannel(backChannel) } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @ -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<HttpSecurity>.BackChannelLogoutConfigurer) -> Unit { | ||||||
|  |         return { backChannel -> } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @ -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"); |  * Licensed under the Apache License, Version 2.0 (the "License"); | ||||||
|  * you may not use this file except in compliance with 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) |         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] |      * Apply all configurations to the provided [ServerHttpSecurity] | ||||||
|      */ |      */ | ||||||
|  | |||||||
| @ -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 -> } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @ -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) } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @ -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<SecurityContext> 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<SecurityContext> 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<String, Object> 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<String, Object> 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<MockMvc> 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<String, MockHttpSession> session = new ConcurrentHashMap<>(); | ||||||
|  | 
 | ||||||
|  | 		private final ObjectProvider<MockMvc> mvcProvider; | ||||||
|  | 
 | ||||||
|  | 		private MockMvc mvc; | ||||||
|  | 
 | ||||||
|  | 		MockMvcDispatcher(ObjectProvider<MockMvc> 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<String, List<String>> 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); | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | } | ||||||
| @ -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<String> 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<String> 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<ResponseCookie> 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<SecurityContext> 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<SecurityContext> 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<ServerWebExchangeMatcher> 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<String, Object> 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<String, Object> 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<WebTestClient> 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<WebTestClient> webProvider; | ||||||
|  | 
 | ||||||
|  | 		private WebTestClient web; | ||||||
|  | 
 | ||||||
|  | 		WebTestClientDispatcher(ObjectProvider<WebTestClient> 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<String, List<String>> 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<String> 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<String> 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; | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | } | ||||||
| @ -717,7 +717,7 @@ public class ServerHttpSecurityTests { | |||||||
| 
 | 
 | ||||||
| 	private <T extends WebFilter> Optional<T> getWebFilter(SecurityWebFilterChain filterChain, Class<T> filterClass) { | 	private <T extends WebFilter> Optional<T> getWebFilter(SecurityWebFilterChain filterChain, Class<T> filterClass) { | ||||||
| 		return (Optional<T>) filterChain.getWebFilters().filter(Objects::nonNull) | 		return (Optional<T>) filterChain.getWebFilters().filter(Objects::nonNull) | ||||||
| 				.filter((filter) -> filter.getClass().isAssignableFrom(filterClass)).singleOrEmpty().blockOptional(); | 				.filter((filter) -> filterClass.isAssignableFrom(filter.getClass())).singleOrEmpty().blockOptional(); | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	private WebTestClient buildClient() { | 	private WebTestClient buildClient() { | ||||||
|  | |||||||
| @ -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) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  | } | ||||||
| @ -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) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  | } | ||||||
| @ -700,111 +700,5 @@ For MAC based algorithms such as `HS256`, `HS384` or `HS512`, the `client-secret | |||||||
| [TIP] | [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. | 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]] | [[webflux-oauth2-login-advanced-oidc-logout]] | ||||||
| == OpenID Connect 1.0 Logout | Then, you can proceed to configure xref:reactive/oauth2/login/logout.adoc[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. |  | ||||||
|  | |||||||
							
								
								
									
										267
									
								
								docs/modules/ROOT/pages/reactive/oauth2/login/logout.adoc
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										267
									
								
								docs/modules/ROOT/pages/reactive/oauth2/login/logout.adoc
									
									
									
									
									
										Normal file
									
								
							| @ -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<OidcSessionInformation> 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<Void> { | ||||||
|  |         return this.sessions.save(info) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     @Override | ||||||
|  |     fun removeSessionInformation(clientSessionId: String): Mono<OidcSessionInformation> { | ||||||
|  |        return this.sessions.removeByClientSessionId(clientSessionId); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     @Override | ||||||
|  |     fun removeSessionInformation(token: OidcLogoutToken): Flux<OidcSessionInformation> { | ||||||
|  |         return token.getSessionId() != null ? | ||||||
|  |             this.sessions.removeBySessionIdAndIssuerAndAudience(...) : | ||||||
|  |             this.sessions.removeBySubjectAndIssuerAndAudience(...); | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | ---- | ||||||
|  | ====== | ||||||
| @ -1037,3 +1037,6 @@ class OAuth2LoginSecurityConfig { | |||||||
| `OidcClientInitiatedLogoutSuccessHandler` supports the `+{baseUrl}+` placeholder. | `OidcClientInitiatedLogoutSuccessHandler` supports the `+{baseUrl}+` placeholder. | ||||||
| If used, the application's base URL, such as `https://app.example.org`, replaces it at request time. | 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] | ||||||
|  | |||||||
							
								
								
									
										267
									
								
								docs/modules/ROOT/pages/servlet/oauth2/login/logout.adoc
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										267
									
								
								docs/modules/ROOT/pages/servlet/oauth2/login/logout.adoc
									
									
									
									
									
										Normal file
									
								
							| @ -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<OidcSessionInformation> 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<OidcSessionInformation> { | ||||||
|  |         return token.getSessionId() != null ? | ||||||
|  |             this.sessions.removeBySessionIdAndIssuerAndAudience(...) : | ||||||
|  |             this.sessions.removeBySubjectAndIssuerAndAudience(...); | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | ---- | ||||||
|  | ====== | ||||||
|  | 
 | ||||||
| @ -10,4 +10,5 @@ | |||||||
| ^http://www.w3.org/2001/04/xmlenc | ^http://www.w3.org/2001/04/xmlenc | ||||||
| ^http://www.springframework.org/schema/security/.* | ^http://www.springframework.org/schema/security/.* | ||||||
| ^http://openoffice.org/.* | ^http://openoffice.org/.* | ||||||
| ^http://www.w3.org/2003/g/data-view | ^http://www.w3.org/2003/g/data-view | ||||||
|  | ^http://schemas.openid.net/event/backchannel-logout | ||||||
|  | |||||||
| @ -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 <a target="_blank" href= | ||||||
|  |  * "https://openid.net/specs/openid-connect-backchannel-1_0.html#LogoutToken">OIDC | ||||||
|  |  * Back-Channel Logout Token</a> | ||||||
|  |  */ | ||||||
|  | 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<String> 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<String, Object> 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); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | } | ||||||
| @ -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 <a target="_blank" href= | ||||||
|  |  * "https://openid.net/specs/openid-connect-backchannel-1_0.html#LogoutToken">OIDC | ||||||
|  |  * Back-Channel Logout Token</a> | ||||||
|  |  */ | ||||||
|  | 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() { | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | } | ||||||
| @ -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. | ||||||
|  |  * | ||||||
|  |  * <p> | ||||||
|  |  * 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 <a target="_blank" href= | ||||||
|  |  * "https://openid.net/specs/openid-connect-backchannel-1_0.html#LogoutToken">Logout | ||||||
|  |  * Token</a> | ||||||
|  |  */ | ||||||
|  | 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<String, Object> 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<String, Object> claims) { | ||||||
|  | 		super(tokenValue, issuedAt, Instant.MAX); | ||||||
|  | 		this.claims = Collections.unmodifiableMap(claims); | ||||||
|  | 		Assert.notNull(claims, "claims must not be null"); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	@Override | ||||||
|  | 	public Map<String, Object> 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<String, Object> 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<Map<String, Object>> 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<String> 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<String, Object> 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; | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | } | ||||||
| @ -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<Void> saveSessionInformation(OidcSessionInformation info) { | ||||||
|  | 		this.delegate.saveSessionInformation(info); | ||||||
|  | 		return Mono.empty(); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	@Override | ||||||
|  | 	public Mono<OidcSessionInformation> removeSessionInformation(String clientSessionId) { | ||||||
|  | 		return Mono.justOrEmpty(this.delegate.removeSessionInformation(clientSessionId)); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	@Override | ||||||
|  | 	public Flux<OidcSessionInformation> removeSessionInformation(OidcLogoutToken token) { | ||||||
|  | 		return Flux.fromIterable(this.delegate.removeSessionInformation(token)); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | } | ||||||
| @ -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 <a target="_blank" href= | ||||||
|  |  * "https://openid.net/specs/openid-connect-backchannel-1_0.html#LogoutToken">Logout | ||||||
|  |  * Token</a> | ||||||
|  |  */ | ||||||
|  | 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<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} | ||||||
|  | 	 */ | ||||||
|  | 	Mono<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 | ||||||
|  | 	 */ | ||||||
|  | 	Flux<OidcSessionInformation> removeSessionInformation(OidcLogoutToken logoutToken); | ||||||
|  | 
 | ||||||
|  | } | ||||||
| @ -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<String, OidcSessionInformation> 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<OidcSessionInformation> removeSessionInformation(OidcLogoutToken token) { | ||||||
|  | 		List<String> audience = token.getAudience(); | ||||||
|  | 		String issuer = token.getIssuer().toString(); | ||||||
|  | 		String subject = token.getSubject(); | ||||||
|  | 		String providerSessionId = token.getSessionId(); | ||||||
|  | 		Predicate<OidcSessionInformation> 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<OidcSessionInformation> 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<OidcSessionInformation> sessionIdMatcher(List<String> audience, String issuer, | ||||||
|  | 			String sessionId) { | ||||||
|  | 		return (session) -> { | ||||||
|  | 			List<String> 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<OidcSessionInformation> subjectMatcher(List<String> audience, String issuer, | ||||||
|  | 			String subject) { | ||||||
|  | 		return (session) -> { | ||||||
|  | 			List<String> 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); | ||||||
|  | 		}; | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | } | ||||||
| @ -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<String, String> 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<String, String> 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<String, String> 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()); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | } | ||||||
| @ -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 <a target="_blank" href= | ||||||
|  |  * "https://openid.net/specs/openid-connect-backchannel-1_0.html#LogoutToken">Logout | ||||||
|  |  * Token</a> | ||||||
|  |  */ | ||||||
|  | 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<OidcSessionInformation> removeSessionInformation(OidcLogoutToken logoutToken); | ||||||
|  | 
 | ||||||
|  | } | ||||||
| @ -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() { | ||||||
|  | 
 | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | } | ||||||
| @ -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<OidcSessionInformation> 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<OidcSessionInformation> 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<OidcSessionInformation> 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(); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | } | ||||||
| @ -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() { | ||||||
|  | 
 | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | } | ||||||
| @ -17,6 +17,7 @@ | |||||||
| package org.springframework.security.oauth2.core.oidc; | package org.springframework.security.oauth2.core.oidc; | ||||||
| 
 | 
 | ||||||
| import java.time.Instant; | import java.time.Instant; | ||||||
|  | import java.util.List; | ||||||
| 
 | 
 | ||||||
| /** | /** | ||||||
|  * Test {@link OidcIdToken}s |  * Test {@link OidcIdToken}s | ||||||
| @ -32,6 +33,7 @@ public final class TestOidcIdTokens { | |||||||
| 		// @formatter:off | 		// @formatter:off | ||||||
| 		return OidcIdToken.withTokenValue("id-token") | 		return OidcIdToken.withTokenValue("id-token") | ||||||
| 				.issuer("https://example.com") | 				.issuer("https://example.com") | ||||||
|  | 				.audience(List.of("client-id")) | ||||||
| 				.subject("subject") | 				.subject("subject") | ||||||
| 				.issuedAt(Instant.now()) | 				.issuedAt(Instant.now()) | ||||||
| 				.expiresAt(Instant.now() | 				.expiresAt(Instant.now() | ||||||
|  | |||||||
| @ -50,7 +50,7 @@ public final class TestOidcUsers { | |||||||
| 				.expiresAt(expiresAt) | 				.expiresAt(expiresAt) | ||||||
| 				.subject("subject") | 				.subject("subject") | ||||||
| 				.issuer("http://localhost/issuer") | 				.issuer("http://localhost/issuer") | ||||||
| 				.audience(Collections.unmodifiableSet(new LinkedHashSet<>(Collections.singletonList("client")))) | 				.audience(Collections.unmodifiableSet(new LinkedHashSet<>(Collections.singletonList("client-id")))) | ||||||
| 				.authorizedParty("client") | 				.authorizedParty("client") | ||||||
| 				.build(); | 				.build(); | ||||||
| 		// @formatter:on | 		// @formatter:on | ||||||
|  | |||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user