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.oauth2.client.OAuth2ClientConfigurer; | ||||
| import org.springframework.security.config.annotation.web.configurers.oauth2.client.OAuth2LoginConfigurer; | ||||
| import org.springframework.security.config.annotation.web.configurers.oauth2.client.OidcLogoutConfigurer; | ||||
| import org.springframework.security.config.annotation.web.configurers.oauth2.server.resource.OAuth2ResourceServerConfigurer; | ||||
| import org.springframework.security.config.annotation.web.configurers.saml2.Saml2LoginConfigurer; | ||||
| import org.springframework.security.config.annotation.web.configurers.saml2.Saml2LogoutConfigurer; | ||||
| @ -2835,6 +2836,16 @@ public final class HttpSecurity extends AbstractConfiguredSecurityBuilder<Defaul | ||||
| 		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. | ||||
| 	 * @return the {@link OAuth2ClientConfigurer} for further customizations | ||||
|  | ||||
| @ -296,7 +296,7 @@ public final class SessionManagementConfigurer<H extends HttpSecurityBuilder<H>> | ||||
| 	 * @param sessionAuthenticationStrategy | ||||
| 	 * @return the {@link SessionManagementConfigurer} for further customizations | ||||
| 	 */ | ||||
| 	SessionManagementConfigurer<H> addSessionAuthenticationStrategy( | ||||
| 	public SessionManagementConfigurer<H> addSessionAuthenticationStrategy( | ||||
| 			SessionAuthenticationStrategy sessionAuthenticationStrategy) { | ||||
| 		this.sessionAuthenticationStrategies.add(sessionAuthenticationStrategy); | ||||
| 		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.oauth2.client.InMemoryOAuth2AuthorizedClientService; | ||||
| import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService; | ||||
| import org.springframework.security.oauth2.client.oidc.session.InMemoryOidcSessionRegistry; | ||||
| import org.springframework.security.oauth2.client.oidc.session.OidcSessionRegistry; | ||||
| import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; | ||||
| import org.springframework.security.oauth2.client.web.AuthenticatedPrincipalOAuth2AuthorizedClientRepository; | ||||
| import org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepository; | ||||
| @ -112,4 +114,13 @@ final class OAuth2ClientConfigurerUtils { | ||||
| 		return (!authorizedClientServiceMap.isEmpty() ? authorizedClientServiceMap.values().iterator().next() : null); | ||||
| 	} | ||||
| 
 | ||||
| 	static <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.Map; | ||||
| 
 | ||||
| import jakarta.servlet.http.HttpServletRequest; | ||||
| import jakarta.servlet.http.HttpServletResponse; | ||||
| import jakarta.servlet.http.HttpSession; | ||||
| import org.apache.commons.logging.Log; | ||||
| import org.apache.commons.logging.LogFactory; | ||||
| 
 | ||||
| import org.springframework.beans.factory.BeanFactoryUtils; | ||||
| import org.springframework.beans.factory.NoUniqueBeanDefinitionException; | ||||
| import org.springframework.context.ApplicationContext; | ||||
| import org.springframework.context.ApplicationListener; | ||||
| import org.springframework.context.event.GenericApplicationListenerAdapter; | ||||
| import org.springframework.context.event.SmartApplicationListener; | ||||
| import org.springframework.core.ResolvableType; | ||||
| import org.springframework.security.authentication.AuthenticationProvider; | ||||
| import org.springframework.security.config.Customizer; | ||||
| @ -32,9 +41,14 @@ import org.springframework.security.config.annotation.web.HttpSecurityBuilder; | ||||
| import org.springframework.security.config.annotation.web.builders.HttpSecurity; | ||||
| import org.springframework.security.config.annotation.web.configurers.AbstractAuthenticationFilterConfigurer; | ||||
| import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; | ||||
| import org.springframework.security.config.annotation.web.configurers.SessionManagementConfigurer; | ||||
| import org.springframework.security.context.DelegatingApplicationListener; | ||||
| import org.springframework.security.core.Authentication; | ||||
| import org.springframework.security.core.AuthenticationException; | ||||
| import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper; | ||||
| import org.springframework.security.core.session.AbstractSessionEvent; | ||||
| import org.springframework.security.core.session.SessionDestroyedEvent; | ||||
| import org.springframework.security.core.session.SessionIdChangedEvent; | ||||
| import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService; | ||||
| import org.springframework.security.oauth2.client.authentication.OAuth2LoginAuthenticationProvider; | ||||
| import org.springframework.security.oauth2.client.authentication.OAuth2LoginAuthenticationToken; | ||||
| @ -42,6 +56,9 @@ import org.springframework.security.oauth2.client.endpoint.DefaultAuthorizationC | ||||
| import org.springframework.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient; | ||||
| import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest; | ||||
| import org.springframework.security.oauth2.client.oidc.authentication.OidcAuthorizationCodeAuthenticationProvider; | ||||
| import org.springframework.security.oauth2.client.oidc.session.InMemoryOidcSessionRegistry; | ||||
| import org.springframework.security.oauth2.client.oidc.session.OidcSessionInformation; | ||||
| import org.springframework.security.oauth2.client.oidc.session.OidcSessionRegistry; | ||||
| import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest; | ||||
| import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserService; | ||||
| import org.springframework.security.oauth2.client.registration.ClientRegistration; | ||||
| @ -67,7 +84,10 @@ import org.springframework.security.web.AuthenticationEntryPoint; | ||||
| import org.springframework.security.web.RedirectStrategy; | ||||
| import org.springframework.security.web.authentication.DelegatingAuthenticationEntryPoint; | ||||
| import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint; | ||||
| import org.springframework.security.web.authentication.session.SessionAuthenticationException; | ||||
| import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy; | ||||
| import org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter; | ||||
| import org.springframework.security.web.csrf.CsrfToken; | ||||
| import org.springframework.security.web.savedrequest.RequestCache; | ||||
| import org.springframework.security.web.util.matcher.AndRequestMatcher; | ||||
| import org.springframework.security.web.util.matcher.AntPathRequestMatcher; | ||||
| @ -124,6 +144,7 @@ import org.springframework.util.ReflectionUtils; | ||||
|  * <li>{@link DefaultLoginPageGeneratingFilter} - if {@link #loginPage(String)} is not | ||||
|  * configured and {@code DefaultLoginPageGeneratingFilter} is available, then a default | ||||
|  * login page will be made available</li> | ||||
|  * <li>{@link OidcSessionRegistry}</li> | ||||
|  * </ul> | ||||
|  * | ||||
|  * @author Joe Grandja | ||||
| @ -202,6 +223,18 @@ public final class OAuth2LoginConfigurer<B extends HttpSecurityBuilder<B>> | ||||
| 		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 | ||||
| 	 * Server's Authorization Endpoint. | ||||
| @ -397,6 +430,7 @@ public final class OAuth2LoginConfigurer<B extends HttpSecurityBuilder<B>> | ||||
| 			authenticationFilter | ||||
| 					.setAuthorizationRequestRepository(this.authorizationEndpointConfig.authorizationRequestRepository); | ||||
| 		} | ||||
| 		configureOidcSessionRegistry(http); | ||||
| 		super.configure(http); | ||||
| 	} | ||||
| 
 | ||||
| @ -546,6 +580,29 @@ public final class OAuth2LoginConfigurer<B extends HttpSecurityBuilder<B>> | ||||
| 		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. | ||||
| 	 */ | ||||
| @ -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.security.interfaces.RSAPublicKey; | ||||
| import java.time.Duration; | ||||
| import java.time.Instant; | ||||
| import java.util.ArrayList; | ||||
| import java.util.Arrays; | ||||
| import java.util.Collections; | ||||
| @ -28,10 +29,13 @@ import java.util.HashMap; | ||||
| import java.util.List; | ||||
| import java.util.Map; | ||||
| import java.util.UUID; | ||||
| import java.util.function.Consumer; | ||||
| import java.util.function.Function; | ||||
| import java.util.function.Supplier; | ||||
| 
 | ||||
| import io.micrometer.observation.ObservationRegistry; | ||||
| import org.apache.commons.logging.Log; | ||||
| import org.apache.commons.logging.LogFactory; | ||||
| import reactor.core.publisher.Mono; | ||||
| import reactor.util.context.Context; | ||||
| 
 | ||||
| @ -67,6 +71,9 @@ import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCo | ||||
| import org.springframework.security.oauth2.client.endpoint.ReactiveOAuth2AccessTokenResponseClient; | ||||
| import org.springframework.security.oauth2.client.endpoint.WebClientReactiveAuthorizationCodeTokenResponseClient; | ||||
| import org.springframework.security.oauth2.client.oidc.authentication.OidcAuthorizationCodeReactiveAuthenticationManager; | ||||
| import org.springframework.security.oauth2.client.oidc.server.session.InMemoryReactiveOidcSessionRegistry; | ||||
| import org.springframework.security.oauth2.client.oidc.server.session.ReactiveOidcSessionRegistry; | ||||
| import org.springframework.security.oauth2.client.oidc.session.OidcSessionInformation; | ||||
| import org.springframework.security.oauth2.client.oidc.userinfo.OidcReactiveOAuth2UserService; | ||||
| import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest; | ||||
| import org.springframework.security.oauth2.client.registration.ClientRegistration; | ||||
| @ -113,6 +120,7 @@ import org.springframework.security.web.server.MatcherSecurityWebFilterChain; | ||||
| import org.springframework.security.web.server.SecurityWebFilterChain; | ||||
| import org.springframework.security.web.server.ServerAuthenticationEntryPoint; | ||||
| import org.springframework.security.web.server.ServerRedirectStrategy; | ||||
| import org.springframework.security.web.server.WebFilterExchange; | ||||
| import org.springframework.security.web.server.authentication.AnonymousAuthenticationWebFilter; | ||||
| import org.springframework.security.web.server.authentication.AuthenticationConverterServerWebExchangeMatcher; | ||||
| import org.springframework.security.web.server.authentication.AuthenticationWebFilter; | ||||
| @ -147,6 +155,7 @@ import org.springframework.security.web.server.context.SecurityContextServerWebE | ||||
| import org.springframework.security.web.server.context.ServerSecurityContextRepository; | ||||
| import org.springframework.security.web.server.context.WebSessionServerSecurityContextRepository; | ||||
| import org.springframework.security.web.server.csrf.CsrfServerLogoutHandler; | ||||
| import org.springframework.security.web.server.csrf.CsrfToken; | ||||
| import org.springframework.security.web.server.csrf.CsrfWebFilter; | ||||
| import org.springframework.security.web.server.csrf.ServerCsrfTokenRepository; | ||||
| import org.springframework.security.web.server.csrf.ServerCsrfTokenRequestHandler; | ||||
| @ -193,8 +202,10 @@ import org.springframework.web.cors.reactive.CorsWebFilter; | ||||
| import org.springframework.web.cors.reactive.DefaultCorsProcessor; | ||||
| import org.springframework.web.reactive.result.method.annotation.RequestMappingHandlerMapping; | ||||
| import org.springframework.web.server.ServerWebExchange; | ||||
| import org.springframework.web.server.ServerWebExchangeDecorator; | ||||
| import org.springframework.web.server.WebFilter; | ||||
| import org.springframework.web.server.WebFilterChain; | ||||
| import org.springframework.web.server.WebSession; | ||||
| import org.springframework.web.util.pattern.PathPatternParser; | ||||
| 
 | ||||
| /** | ||||
| @ -295,6 +306,8 @@ public class ServerHttpSecurity { | ||||
| 
 | ||||
| 	private OAuth2ClientSpec client; | ||||
| 
 | ||||
| 	private OidcLogoutSpec oidcLogout; | ||||
| 
 | ||||
| 	private LogoutSpec logout = new LogoutSpec(); | ||||
| 
 | ||||
| 	private LoginPageSpec loginPage = new LoginPageSpec(); | ||||
| @ -1093,6 +1106,33 @@ public class ServerHttpSecurity { | ||||
| 		return this; | ||||
| 	} | ||||
| 
 | ||||
| 	/** | ||||
| 	 * Configures OIDC Connect 1.0 Logout support. | ||||
| 	 * | ||||
| 	 * <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: | ||||
| 	 * | ||||
| @ -1537,6 +1577,9 @@ public class ServerHttpSecurity { | ||||
| 		if (this.resourceServer != null) { | ||||
| 			this.resourceServer.configure(this); | ||||
| 		} | ||||
| 		if (this.oidcLogout != null) { | ||||
| 			this.oidcLogout.configure(this); | ||||
| 		} | ||||
| 		if (this.client != null) { | ||||
| 			this.client.configure(this); | ||||
| 		} | ||||
| @ -3689,6 +3732,8 @@ public class ServerHttpSecurity { | ||||
| 
 | ||||
| 		private ServerWebExchangeMatcher authenticationMatcher; | ||||
| 
 | ||||
| 		private ReactiveOidcSessionRegistry oidcSessionRegistry; | ||||
| 
 | ||||
| 		private ServerAuthenticationSuccessHandler authenticationSuccessHandler; | ||||
| 
 | ||||
| 		private ServerAuthenticationFailureHandler authenticationFailureHandler; | ||||
| @ -3720,6 +3765,20 @@ public class ServerHttpSecurity { | ||||
| 			return this; | ||||
| 		} | ||||
| 
 | ||||
| 		/** | ||||
| 		 * Configures the {@link ReactiveOidcSessionRegistry} to use when logins use OIDC. | ||||
| 		 * Default is to look the value up as a Bean, or else use an | ||||
| 		 * {@link InMemoryReactiveOidcSessionRegistry}. | ||||
| 		 * @param oidcSessionRegistry the registry to use | ||||
| 		 * @return the {@link OidcLogoutSpec} to customize | ||||
| 		 * @since 6.2 | ||||
| 		 */ | ||||
| 		public OAuth2LoginSpec oidcSessionRegistry(ReactiveOidcSessionRegistry oidcSessionRegistry) { | ||||
| 			Assert.notNull(oidcSessionRegistry, "oidcSessionRegistry cannot be null"); | ||||
| 			this.oidcSessionRegistry = oidcSessionRegistry; | ||||
| 			return this; | ||||
| 		} | ||||
| 
 | ||||
| 		/** | ||||
| 		 * The {@link ServerAuthenticationSuccessHandler} used after authentication | ||||
| 		 * success. Defaults to {@link RedirectServerAuthenticationSuccessHandler} | ||||
| @ -3913,8 +3972,9 @@ public class ServerHttpSecurity { | ||||
| 			oauthRedirectFilter.setRequestCache(http.requestCache.requestCache); | ||||
| 
 | ||||
| 			ReactiveAuthenticationManager manager = getAuthenticationManager(); | ||||
| 			AuthenticationWebFilter authenticationFilter = new OAuth2LoginAuthenticationWebFilter(manager, | ||||
| 					authorizedClientRepository); | ||||
| 			ReactiveOidcSessionRegistry sessionRegistry = getOidcSessionRegistry(); | ||||
| 			AuthenticationWebFilter authenticationFilter = new OidcSessionRegistryAuthenticationWebFilter(manager, | ||||
| 					authorizedClientRepository, sessionRegistry); | ||||
| 			authenticationFilter.setRequiresAuthenticationMatcher(getAuthenticationMatcher()); | ||||
| 			authenticationFilter | ||||
| 					.setServerAuthenticationConverter(getAuthenticationConverter(clientRegistrationRepository)); | ||||
| @ -3923,6 +3983,8 @@ public class ServerHttpSecurity { | ||||
| 			authenticationFilter.setSecurityContextRepository(this.securityContextRepository); | ||||
| 
 | ||||
| 			setDefaultEntryPoints(http); | ||||
| 			http.addFilterAfter(new OidcSessionRegistryWebFilter(sessionRegistry), | ||||
| 					SecurityWebFiltersOrder.HTTP_HEADERS_WRITER); | ||||
| 			http.addFilterAt(oauthRedirectFilter, SecurityWebFiltersOrder.HTTP_BASIC); | ||||
| 			http.addFilterAt(authenticationFilter, SecurityWebFiltersOrder.AUTHENTICATION); | ||||
| 		} | ||||
| @ -3967,6 +4029,16 @@ public class ServerHttpSecurity { | ||||
| 			http.defaultEntryPoints.add(new DelegateEntry(defaultEntryPointMatcher, defaultEntryPoint)); | ||||
| 		} | ||||
| 
 | ||||
| 		private ReactiveOidcSessionRegistry getOidcSessionRegistry() { | ||||
| 			if (this.oidcSessionRegistry == null) { | ||||
| 				this.oidcSessionRegistry = getBeanOrNull(ReactiveOidcSessionRegistry.class); | ||||
| 			} | ||||
| 			if (this.oidcSessionRegistry == null) { | ||||
| 				this.oidcSessionRegistry = new InMemoryReactiveOidcSessionRegistry(); | ||||
| 			} | ||||
| 			return this.oidcSessionRegistry; | ||||
| 		} | ||||
| 
 | ||||
| 		private ServerAuthenticationSuccessHandler getAuthenticationSuccessHandler(ServerHttpSecurity http) { | ||||
| 			if (this.authenticationSuccessHandler == null) { | ||||
| 				RedirectServerAuthenticationSuccessHandler handler = new RedirectServerAuthenticationSuccessHandler(); | ||||
| @ -4083,6 +4155,154 @@ public class ServerHttpSecurity { | ||||
| 			return new InMemoryReactiveOAuth2AuthorizedClientService(getClientRegistrationRepository()); | ||||
| 		} | ||||
| 
 | ||||
| 		private static final class OidcSessionRegistryWebFilter implements WebFilter { | ||||
| 
 | ||||
| 			private final ReactiveOidcSessionRegistry oidcSessionRegistry; | ||||
| 
 | ||||
| 			OidcSessionRegistryWebFilter(ReactiveOidcSessionRegistry oidcSessionRegistry) { | ||||
| 				this.oidcSessionRegistry = oidcSessionRegistry; | ||||
| 			} | ||||
| 
 | ||||
| 			@Override | ||||
| 			public Mono<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 { | ||||
| @ -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 | ||||
| 	 * | ||||
|  | ||||
| @ -868,6 +868,38 @@ class HttpSecurityDsl(private val http: HttpSecurity, private val init: HttpSecu | ||||
|         this.http.oauth2ResourceServer(oauth2ResourceServerCustomizer) | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Configures OIDC 1.0 logout support. | ||||
|      * | ||||
|      * Example: | ||||
|      * | ||||
|      * ``` | ||||
|      * @Configuration | ||||
|      * @EnableWebSecurity | ||||
|      * class SecurityConfig { | ||||
|      * | ||||
|      *     @Bean | ||||
|      *     fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { | ||||
|      *         http { | ||||
|      *             oauth2Login { } | ||||
|      *             oidcLogout { | ||||
|      *                 backChannel { } | ||||
|      *             } | ||||
|      *         } | ||||
|      *         return http.build() | ||||
|      *     } | ||||
|      * } | ||||
|      * ``` | ||||
|      * | ||||
|      * @param oidcLogoutConfiguration custom configuration to configure the | ||||
|      * OIDC 1.0 logout support | ||||
|      * @see [OidcLogoutDsl] | ||||
|      */ | ||||
|     fun oidcLogout(oidcLogoutConfiguration: OidcLogoutDsl.() -> Unit) { | ||||
|         val oidcLogoutCustomizer = OidcLogoutDsl().apply(oidcLogoutConfiguration).get() | ||||
|         this.http.oidcLogout(oidcLogoutCustomizer) | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Configures Remember Me authentication. | ||||
|      * | ||||
|  | ||||
| @ -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"); | ||||
|  * you may not use this file except in compliance with the License. | ||||
| @ -650,6 +650,38 @@ class ServerHttpSecurityDsl(private val http: ServerHttpSecurity, private val in | ||||
|         this.http.oauth2ResourceServer(oauth2ResourceServerCustomizer) | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Configures logout support using an OpenID Connect 1.0 Provider. | ||||
|      * A [ReactiveClientRegistrationRepository] is required and must be registered as a Bean or | ||||
|      * configured via [ServerOidcLogoutDsl.clientRegistrationRepository]. | ||||
|      * | ||||
|      * Example: | ||||
|      * | ||||
|      * ``` | ||||
|      * @Configuration | ||||
|      * @EnableWebFluxSecurity | ||||
|      * class SecurityConfig { | ||||
|      * | ||||
|      *  @Bean | ||||
|      *  fun springWebFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain { | ||||
|      *      return http { | ||||
|      *          oauth2Login { } | ||||
|      *          oidcLogout { | ||||
|      *              backChannel { } | ||||
|      *          } | ||||
|      *       } | ||||
|      *   } | ||||
|      * } | ||||
|      * ``` | ||||
|      * | ||||
|      * @param oidcLogoutConfiguration custom configuration to configure the OIDC 1.0 Logout | ||||
|      * @see [ServerOidcLogoutDsl] | ||||
|      */ | ||||
|     fun oidcLogout(oidcLogoutConfiguration: ServerOidcLogoutDsl.() -> Unit) { | ||||
|         val oidcLogoutCustomizer = ServerOidcLogoutDsl().apply(oidcLogoutConfiguration).get() | ||||
|         this.http.oidcLogout(oidcLogoutCustomizer) | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Apply all configurations to the provided [ServerHttpSecurity] | ||||
|      */ | ||||
|  | ||||
| @ -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) { | ||||
| 		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() { | ||||
|  | ||||
| @ -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] | ||||
| If more than one `ClientRegistration` is configured for OpenID Connect 1.0 Authentication, the JWS algorithm resolver may evaluate the provided `ClientRegistration` to determine which algorithm to return. | ||||
| 
 | ||||
| 
 | ||||
| [[webflux-oauth2-login-advanced-oidc-logout]] | ||||
| == OpenID Connect 1.0 Logout | ||||
| 
 | ||||
| OpenID Connect Session Management 1.0 allows the ability to log out the End-User at the Provider using the Client. | ||||
| One of the strategies available is https://openid.net/specs/openid-connect-rpinitiated-1_0.html[RP-Initiated Logout]. | ||||
| 
 | ||||
| If the OpenID Provider supports both Session Management and https://openid.net/specs/openid-connect-discovery-1_0.html[Discovery], the client may obtain the `end_session_endpoint` `URL` from the OpenID Provider's https://openid.net/specs/openid-connect-session-1_0.html#OPMetadata[Discovery Metadata]. | ||||
| This can be achieved by configuring the `ClientRegistration` with the `issuer-uri`, as in the following example: | ||||
| 
 | ||||
| [source,yaml] | ||||
| ---- | ||||
| spring: | ||||
|   security: | ||||
|     oauth2: | ||||
|       client: | ||||
|         registration: | ||||
|           okta: | ||||
|             client-id: okta-client-id | ||||
|             client-secret: okta-client-secret | ||||
|             ... | ||||
|         provider: | ||||
|           okta: | ||||
|             issuer-uri: https://dev-1234.oktapreview.com | ||||
| ---- | ||||
| 
 | ||||
| ...and the `OidcClientInitiatedServerLogoutSuccessHandler`, which implements RP-Initiated Logout, may be configured as follows: | ||||
| 
 | ||||
| [tabs] | ||||
| ====== | ||||
| Java:: | ||||
| + | ||||
| [source,java,role="primary",subs="-attributes"] | ||||
| ---- | ||||
| @Configuration | ||||
| @EnableWebFluxSecurity | ||||
| public class OAuth2LoginSecurityConfig { | ||||
| 
 | ||||
| 	@Autowired | ||||
| 	private ReactiveClientRegistrationRepository clientRegistrationRepository; | ||||
| 
 | ||||
| 	@Bean | ||||
| 	public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) { | ||||
| 		http | ||||
| 			.authorizeExchange(authorize -> authorize | ||||
| 				.anyExchange().authenticated() | ||||
| 			) | ||||
| 			.oauth2Login(withDefaults()) | ||||
| 			.logout(logout -> logout | ||||
| 				.logoutSuccessHandler(oidcLogoutSuccessHandler()) | ||||
| 			); | ||||
| 
 | ||||
| 		return http.build(); | ||||
| 	} | ||||
| 
 | ||||
| 	private ServerLogoutSuccessHandler oidcLogoutSuccessHandler() { | ||||
| 		OidcClientInitiatedServerLogoutSuccessHandler oidcLogoutSuccessHandler = | ||||
| 				new OidcClientInitiatedServerLogoutSuccessHandler(this.clientRegistrationRepository); | ||||
| 
 | ||||
| 		// Sets the location that the End-User's User Agent will be redirected to | ||||
| 		// after the logout has been performed at the Provider | ||||
| 		oidcLogoutSuccessHandler.setPostLogoutRedirectUri("{baseUrl}"); | ||||
| 
 | ||||
| 		return oidcLogoutSuccessHandler; | ||||
| 	} | ||||
| } | ||||
| ---- | ||||
| 
 | ||||
| Kotlin:: | ||||
| + | ||||
| [source,kotlin,role="secondary",subs="-attributes"] | ||||
| ---- | ||||
| @Configuration | ||||
| @EnableWebFluxSecurity | ||||
| class OAuth2LoginSecurityConfig { | ||||
| 
 | ||||
|     @Autowired | ||||
|     private lateinit var clientRegistrationRepository: ReactiveClientRegistrationRepository | ||||
| 
 | ||||
|     @Bean | ||||
|     fun securityWebFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain { | ||||
|         http { | ||||
|             authorizeExchange { | ||||
|                 authorize(anyExchange, authenticated) | ||||
|             } | ||||
|             oauth2Login { } | ||||
|             logout { | ||||
|                 logoutSuccessHandler = oidcLogoutSuccessHandler() | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         return http.build() | ||||
|     } | ||||
| 
 | ||||
|     private fun oidcLogoutSuccessHandler(): ServerLogoutSuccessHandler { | ||||
|         val oidcLogoutSuccessHandler = OidcClientInitiatedServerLogoutSuccessHandler(clientRegistrationRepository) | ||||
| 
 | ||||
|         // Sets the location that the End-User's User Agent will be redirected to | ||||
|         // after the logout has been performed at the Provider | ||||
|         oidcLogoutSuccessHandler.setPostLogoutRedirectUri("{baseUrl}") | ||||
|         return oidcLogoutSuccessHandler | ||||
|     } | ||||
| } | ||||
| ---- | ||||
| ====== | ||||
| 
 | ||||
| NOTE: `OidcClientInitiatedServerLogoutSuccessHandler` supports the `+{baseUrl}+` placeholder. | ||||
| If used, the application's base URL, like `https://app.example.org`, will replace it at request time. | ||||
| Then, you can proceed to configure xref:reactive/oauth2/login/logout.adoc[logout]. | ||||
|  | ||||
							
								
								
									
										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. | ||||
| 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(...); | ||||
|     } | ||||
| } | ||||
| ---- | ||||
| ====== | ||||
| 
 | ||||
| @ -11,3 +11,4 @@ | ||||
| ^http://www.springframework.org/schema/security/.* | ||||
| ^http://openoffice.org/.* | ||||
| ^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; | ||||
| 
 | ||||
| import java.time.Instant; | ||||
| import java.util.List; | ||||
| 
 | ||||
| /** | ||||
|  * Test {@link OidcIdToken}s | ||||
| @ -32,6 +33,7 @@ public final class TestOidcIdTokens { | ||||
| 		// @formatter:off | ||||
| 		return OidcIdToken.withTokenValue("id-token") | ||||
| 				.issuer("https://example.com") | ||||
| 				.audience(List.of("client-id")) | ||||
| 				.subject("subject") | ||||
| 				.issuedAt(Instant.now()) | ||||
| 				.expiresAt(Instant.now() | ||||
|  | ||||
| @ -50,7 +50,7 @@ public final class TestOidcUsers { | ||||
| 				.expiresAt(expiresAt) | ||||
| 				.subject("subject") | ||||
| 				.issuer("http://localhost/issuer") | ||||
| 				.audience(Collections.unmodifiableSet(new LinkedHashSet<>(Collections.singletonList("client")))) | ||||
| 				.audience(Collections.unmodifiableSet(new LinkedHashSet<>(Collections.singletonList("client-id")))) | ||||
| 				.authorizedParty("client") | ||||
| 				.build(); | ||||
| 		// @formatter:on | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user