Add OIDC Back-Channel Logout Support

Closes gh-12570
This commit is contained in:
Josh Cummings 2023-01-17 17:25:16 -07:00
parent 1461c0f648
commit cb33fd7850
No known key found for this signature in database
GPG Key ID: A306A51F43B8E5A5
51 changed files with 5397 additions and 114 deletions

View File

@ -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

View File

@ -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;

View File

@ -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));
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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);
}
}
}

View File

@ -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));
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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">
* &#064;Bean
* public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
* http
* // ...
* .oidcLogout((logout) -&gt; 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
*

View File

@ -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.
*

View File

@ -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) }
}
}
}

View File

@ -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 -> }
}
}

View File

@ -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]
*/

View File

@ -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 -> }
}
}

View File

@ -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) }
}
}
}

View File

@ -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);
}
}
}
}

View File

@ -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;
}
}
}

View File

@ -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() {

View File

@ -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)
}
}
}

View File

@ -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)
}
}
}

View File

@ -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].

View 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(...);
}
}
----
======

View File

@ -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]

View 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(...);
}
}
----
======

View File

@ -10,4 +10,5 @@
^http://www.w3.org/2001/04/xmlenc
^http://www.springframework.org/schema/security/.*
^http://openoffice.org/.*
^http://www.w3.org/2003/g/data-view
^http://www.w3.org/2003/g/data-view
^http://schemas.openid.net/event/backchannel-logout

View File

@ -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 &quot;claims&quot; 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);
}
}

View File

@ -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 &quot;claims&quot; 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() {
}
}

View File

@ -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 &quot;claims&quot; 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;
}
}
}

View File

@ -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));
}
}

View File

@ -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);
}

View File

@ -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);
};
}
}

View File

@ -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());
}
}

View File

@ -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);
}

View File

@ -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() {
}
}

View File

@ -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();
}
}

View File

@ -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() {
}
}

View File

@ -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()

View File

@ -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