1
0
mirror of synced 2026-08-05 09:47:05 +00:00

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
51 changed files with 5397 additions and 114 deletions
@@ -70,6 +70,7 @@ import org.springframework.security.config.annotation.web.configurers.SessionMan
import org.springframework.security.config.annotation.web.configurers.X509Configurer;
import org.springframework.security.config.annotation.web.configurers.oauth2.client.OAuth2ClientConfigurer;
import org.springframework.security.config.annotation.web.configurers.oauth2.client.OAuth2LoginConfigurer;
import org.springframework.security.config.annotation.web.configurers.oauth2.client.OidcLogoutConfigurer;
import org.springframework.security.config.annotation.web.configurers.oauth2.server.resource.OAuth2ResourceServerConfigurer;
import org.springframework.security.config.annotation.web.configurers.saml2.Saml2LoginConfigurer;
import org.springframework.security.config.annotation.web.configurers.saml2.Saml2LogoutConfigurer;
@@ -2835,6 +2836,16 @@ public final class HttpSecurity extends AbstractConfiguredSecurityBuilder<Defaul
return HttpSecurity.this;
}
public OidcLogoutConfigurer<HttpSecurity> oidcLogout() throws Exception {
return getOrApply(new OidcLogoutConfigurer<>());
}
public HttpSecurity oidcLogout(Customizer<OidcLogoutConfigurer<HttpSecurity>> oidcLogoutCustomizer)
throws Exception {
oidcLogoutCustomizer.customize(getOrApply(new OidcLogoutConfigurer<>()));
return HttpSecurity.this;
}
/**
* Configures OAuth 2.0 Client support.
* @return the {@link OAuth2ClientConfigurer} for further customizations
@@ -296,7 +296,7 @@ public final class SessionManagementConfigurer<H extends HttpSecurityBuilder<H>>
* @param sessionAuthenticationStrategy
* @return the {@link SessionManagementConfigurer} for further customizations
*/
SessionManagementConfigurer<H> addSessionAuthenticationStrategy(
public SessionManagementConfigurer<H> addSessionAuthenticationStrategy(
SessionAuthenticationStrategy sessionAuthenticationStrategy) {
this.sessionAuthenticationStrategies.add(sessionAuthenticationStrategy);
return this;
@@ -0,0 +1,35 @@
/*
* Copyright 2002-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.config.annotation.web.configurers.oauth2.client;
import java.util.function.Function;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtTimestampValidator;
final class DefaultOidcLogoutTokenValidatorFactory implements Function<ClientRegistration, OAuth2TokenValidator<Jwt>> {
@Override
public OAuth2TokenValidator<Jwt> apply(ClientRegistration clientRegistration) {
return new DelegatingOAuth2TokenValidator<>(new JwtTimestampValidator(),
new OidcBackChannelLogoutTokenValidator(clientRegistration));
}
}
@@ -25,6 +25,8 @@ import org.springframework.security.config.annotation.web.HttpSecurityBuilder;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.oauth2.client.InMemoryOAuth2AuthorizedClientService;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService;
import org.springframework.security.oauth2.client.oidc.session.InMemoryOidcSessionRegistry;
import org.springframework.security.oauth2.client.oidc.session.OidcSessionRegistry;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.security.oauth2.client.web.AuthenticatedPrincipalOAuth2AuthorizedClientRepository;
import org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepository;
@@ -112,4 +114,13 @@ final class OAuth2ClientConfigurerUtils {
return (!authorizedClientServiceMap.isEmpty() ? authorizedClientServiceMap.values().iterator().next() : null);
}
static <B extends HttpSecurityBuilder<B>> OidcSessionRegistry getOidcSessionRegistry(B builder) {
OidcSessionRegistry sessionRegistry = builder.getSharedObject(OidcSessionRegistry.class);
if (sessionRegistry == null) {
sessionRegistry = new InMemoryOidcSessionRegistry();
builder.setSharedObject(OidcSessionRegistry.class, sessionRegistry);
}
return sessionRegistry;
}
}
@@ -22,9 +22,18 @@ import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.beans.factory.BeanFactoryUtils;
import org.springframework.beans.factory.NoUniqueBeanDefinitionException;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationListener;
import org.springframework.context.event.GenericApplicationListenerAdapter;
import org.springframework.context.event.SmartApplicationListener;
import org.springframework.core.ResolvableType;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.config.Customizer;
@@ -32,9 +41,14 @@ import org.springframework.security.config.annotation.web.HttpSecurityBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractAuthenticationFilterConfigurer;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.annotation.web.configurers.SessionManagementConfigurer;
import org.springframework.security.context.DelegatingApplicationListener;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper;
import org.springframework.security.core.session.AbstractSessionEvent;
import org.springframework.security.core.session.SessionDestroyedEvent;
import org.springframework.security.core.session.SessionIdChangedEvent;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService;
import org.springframework.security.oauth2.client.authentication.OAuth2LoginAuthenticationProvider;
import org.springframework.security.oauth2.client.authentication.OAuth2LoginAuthenticationToken;
@@ -42,6 +56,9 @@ import org.springframework.security.oauth2.client.endpoint.DefaultAuthorizationC
import org.springframework.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient;
import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest;
import org.springframework.security.oauth2.client.oidc.authentication.OidcAuthorizationCodeAuthenticationProvider;
import org.springframework.security.oauth2.client.oidc.session.InMemoryOidcSessionRegistry;
import org.springframework.security.oauth2.client.oidc.session.OidcSessionInformation;
import org.springframework.security.oauth2.client.oidc.session.OidcSessionRegistry;
import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest;
import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserService;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
@@ -67,7 +84,10 @@ import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.RedirectStrategy;
import org.springframework.security.web.authentication.DelegatingAuthenticationEntryPoint;
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
import org.springframework.security.web.authentication.session.SessionAuthenticationException;
import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy;
import org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.security.web.savedrequest.RequestCache;
import org.springframework.security.web.util.matcher.AndRequestMatcher;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
@@ -124,6 +144,7 @@ import org.springframework.util.ReflectionUtils;
* <li>{@link DefaultLoginPageGeneratingFilter} - if {@link #loginPage(String)} is not
* configured and {@code DefaultLoginPageGeneratingFilter} is available, then a default
* login page will be made available</li>
* <li>{@link OidcSessionRegistry}</li>
* </ul>
*
* @author Joe Grandja
@@ -202,6 +223,18 @@ public final class OAuth2LoginConfigurer<B extends HttpSecurityBuilder<B>>
return this;
}
/**
* Sets the registry for managing the OIDC client-provider session link
* @param oidcSessionRegistry the {@link OidcSessionRegistry} to use
* @return the {@link OAuth2LoginConfigurer} for further configuration
* @since 6.2
*/
public OAuth2LoginConfigurer<B> oidcSessionRegistry(OidcSessionRegistry oidcSessionRegistry) {
Assert.notNull(oidcSessionRegistry, "oidcSessionRegistry cannot be null");
getBuilder().setSharedObject(OidcSessionRegistry.class, oidcSessionRegistry);
return this;
}
/**
* Returns the {@link AuthorizationEndpointConfig} for configuring the Authorization
* Server's Authorization Endpoint.
@@ -397,6 +430,7 @@ public final class OAuth2LoginConfigurer<B extends HttpSecurityBuilder<B>>
authenticationFilter
.setAuthorizationRequestRepository(this.authorizationEndpointConfig.authorizationRequestRepository);
}
configureOidcSessionRegistry(http);
super.configure(http);
}
@@ -546,6 +580,29 @@ public final class OAuth2LoginConfigurer<B extends HttpSecurityBuilder<B>>
return AnyRequestMatcher.INSTANCE;
}
private void configureOidcSessionRegistry(B http) {
OidcSessionRegistry sessionRegistry = OAuth2ClientConfigurerUtils.getOidcSessionRegistry(http);
SessionManagementConfigurer<B> sessionConfigurer = http.getConfigurer(SessionManagementConfigurer.class);
if (sessionConfigurer != null) {
OidcSessionRegistryAuthenticationStrategy sessionAuthenticationStrategy = new OidcSessionRegistryAuthenticationStrategy();
sessionAuthenticationStrategy.setSessionRegistry(sessionRegistry);
sessionConfigurer.addSessionAuthenticationStrategy(sessionAuthenticationStrategy);
}
OidcClientSessionEventListener listener = new OidcClientSessionEventListener();
listener.setSessionRegistry(sessionRegistry);
registerDelegateApplicationListener(listener);
}
private void registerDelegateApplicationListener(ApplicationListener<?> delegate) {
DelegatingApplicationListener delegating = getBeanOrNull(
ResolvableType.forType(DelegatingApplicationListener.class));
if (delegating == null) {
return;
}
SmartApplicationListener smartListener = new GenericApplicationListenerAdapter(delegate);
delegating.addListener(smartListener);
}
/**
* Configuration options for the Authorization Server's Authorization Endpoint.
*/
@@ -793,4 +850,83 @@ public final class OAuth2LoginConfigurer<B extends HttpSecurityBuilder<B>>
}
private static final class OidcClientSessionEventListener implements ApplicationListener<AbstractSessionEvent> {
private final Log logger = LogFactory.getLog(OidcClientSessionEventListener.class);
private OidcSessionRegistry sessionRegistry = new InMemoryOidcSessionRegistry();
/**
* {@inheritDoc}
*/
@Override
public void onApplicationEvent(AbstractSessionEvent event) {
if (event instanceof SessionDestroyedEvent destroyed) {
this.logger.debug("Received SessionDestroyedEvent");
this.sessionRegistry.removeSessionInformation(destroyed.getId());
return;
}
if (event instanceof SessionIdChangedEvent changed) {
this.logger.debug("Received SessionIdChangedEvent");
OidcSessionInformation information = this.sessionRegistry.removeSessionInformation(changed.getOldSessionId());
if (information == null) {
this.logger.debug("Failed to register new session id since old session id was not found in registry");
return;
}
this.sessionRegistry.saveSessionInformation(information.withSessionId(changed.getNewSessionId()));
}
}
/**
* The registry where OIDC Provider sessions are linked to the Client session.
* Defaults to in-memory storage.
* @param sessionRegistry the {@link OidcSessionRegistry} to use
*/
void setSessionRegistry(OidcSessionRegistry sessionRegistry) {
Assert.notNull(sessionRegistry, "sessionRegistry cannot be null");
this.sessionRegistry = sessionRegistry;
}
}
private static final class OidcSessionRegistryAuthenticationStrategy implements SessionAuthenticationStrategy {
private final Log logger = LogFactory.getLog(getClass());
private OidcSessionRegistry sessionRegistry = new InMemoryOidcSessionRegistry();
/**
* {@inheritDoc}
*/
@Override
public void onAuthentication(Authentication authentication, HttpServletRequest request, HttpServletResponse response) throws SessionAuthenticationException {
HttpSession session = request.getSession(false);
if (session == null) {
return;
}
if (!(authentication.getPrincipal() instanceof OidcUser user)) {
return;
}
String sessionId = session.getId();
CsrfToken csrfToken = (CsrfToken) request.getAttribute(CsrfToken.class.getName());
Map<String, String> headers = (csrfToken != null) ? Map.of(csrfToken.getHeaderName(), csrfToken.getToken()) : Collections.emptyMap();
OidcSessionInformation registration = new OidcSessionInformation(sessionId, headers, user);
if (this.logger.isTraceEnabled()) {
this.logger.trace(String.format("Linking a provider [%s] session to this client's session", user.getIssuer()));
}
this.sessionRegistry.saveSessionInformation(registration);
}
/**
* The registration for linking OIDC Provider Session information to the Client's
* session. Defaults to in-memory storage.
* @param sessionRegistry the {@link OidcSessionRegistry} to use
*/
void setSessionRegistry(OidcSessionRegistry sessionRegistry) {
Assert.notNull(sessionRegistry, "sessionRegistry cannot be null");
this.sessionRegistry = sessionRegistry;
}
}
}
@@ -0,0 +1,66 @@
/*
* Copyright 2002-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.config.annotation.web.configurers.oauth2.client;
import java.util.Collections;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcLogoutToken;
/**
* An {@link org.springframework.security.core.Authentication} implementation that
* represents the result of authenticating an OIDC Logout token for the purposes of
* performing Back-Channel Logout.
*
* @author Josh Cummings
* @since 6.2
* @see OidcLogoutAuthenticationToken
* @see <a target="_blank" href=
* "https://openid.net/specs/openid-connect-backchannel-1_0.html">OIDC Back-Channel
* Logout</a>
*/
class OidcBackChannelLogoutAuthentication extends AbstractAuthenticationToken {
private final OidcLogoutToken logoutToken;
/**
* Construct an {@link OidcBackChannelLogoutAuthentication}
* @param logoutToken a deserialized, verified OIDC Logout Token
*/
OidcBackChannelLogoutAuthentication(OidcLogoutToken logoutToken) {
super(Collections.emptyList());
this.logoutToken = logoutToken;
setAuthenticated(true);
}
/**
* {@inheritDoc}
*/
@Override
public OidcLogoutToken getPrincipal() {
return this.logoutToken;
}
/**
* {@inheritDoc}
*/
@Override
public OidcLogoutToken getCredentials() {
return this.logoutToken;
}
}
@@ -0,0 +1,113 @@
/*
* Copyright 2002-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.config.annotation.web.configurers.oauth2.client;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.AuthenticationServiceException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.oauth2.client.oidc.authentication.OidcIdTokenDecoderFactory;
import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcLogoutToken;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
import org.springframework.security.oauth2.jwt.BadJwtException;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtDecoderFactory;
import org.springframework.util.Assert;
/**
* An {@link AuthenticationProvider} that authenticates an OIDC Logout Token; namely
* deserializing it, verifying its signature, and validating its claims.
*
* <p>
* Intended to be included in a
* {@link org.springframework.security.authentication.ProviderManager}
*
* @author Josh Cummings
* @since 6.2
* @see OidcLogoutAuthenticationToken
* @see org.springframework.security.authentication.ProviderManager
* @see <a target="_blank" href=
* "https://openid.net/specs/openid-connect-backchannel-1_0.html">OIDC Back-Channel
* Logout</a>
*/
final class OidcBackChannelLogoutAuthenticationProvider implements AuthenticationProvider {
private JwtDecoderFactory<ClientRegistration> logoutTokenDecoderFactory;
/**
* Construct an {@link OidcBackChannelLogoutAuthenticationProvider}
*/
OidcBackChannelLogoutAuthenticationProvider() {
OidcIdTokenDecoderFactory logoutTokenDecoderFactory = new OidcIdTokenDecoderFactory();
logoutTokenDecoderFactory.setJwtValidatorFactory(new DefaultOidcLogoutTokenValidatorFactory());
this.logoutTokenDecoderFactory = logoutTokenDecoderFactory;
}
/**
* {@inheritDoc}
*/
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
if (!(authentication instanceof OidcLogoutAuthenticationToken token)) {
return null;
}
String logoutToken = token.getLogoutToken();
ClientRegistration registration = token.getClientRegistration();
Jwt jwt = decode(registration, logoutToken);
OidcLogoutToken oidcLogoutToken = OidcLogoutToken.withTokenValue(logoutToken)
.claims((claims) -> claims.putAll(jwt.getClaims())).build();
return new OidcBackChannelLogoutAuthentication(oidcLogoutToken);
}
/**
* {@inheritDoc}
*/
@Override
public boolean supports(Class<?> authentication) {
return OidcLogoutAuthenticationToken.class.isAssignableFrom(authentication);
}
private Jwt decode(ClientRegistration registration, String token) {
JwtDecoder logoutTokenDecoder = this.logoutTokenDecoderFactory.createDecoder(registration);
try {
return logoutTokenDecoder.decode(token);
}
catch (BadJwtException failed) {
OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.INVALID_REQUEST, failed.getMessage(),
"https://openid.net/specs/openid-connect-backchannel-1_0.html#Validation");
throw new OAuth2AuthenticationException(error, failed);
}
catch (Exception failed) {
throw new AuthenticationServiceException(failed.getMessage(), failed);
}
}
/**
* Use this {@link JwtDecoderFactory} to generate {@link JwtDecoder}s that correspond
* to the {@link ClientRegistration} associated with the OIDC logout token.
* @param logoutTokenDecoderFactory the {@link JwtDecoderFactory} to use
*/
void setLogoutTokenDecoderFactory(JwtDecoderFactory<ClientRegistration> logoutTokenDecoderFactory) {
Assert.notNull(logoutTokenDecoderFactory, "logoutTokenDecoderFactory cannot be null");
this.logoutTokenDecoderFactory = logoutTokenDecoderFactory;
}
}
@@ -0,0 +1,139 @@
/*
* Copyright 2002-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.config.annotation.web.configurers.oauth2.client;
import java.io.IOException;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.http.server.ServletServerHttpResponse;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.AuthenticationServiceException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
import org.springframework.security.oauth2.core.http.converter.OAuth2ErrorHttpMessageConverter;
import org.springframework.security.web.authentication.AuthenticationConverter;
import org.springframework.security.web.authentication.logout.LogoutHandler;
import org.springframework.util.Assert;
import org.springframework.web.filter.OncePerRequestFilter;
/**
* A filter for the Client-side OIDC Back-Channel Logout endpoint
*
* @author Josh Cummings
* @since 6.2
* @see <a target="_blank" href=
* "https://openid.net/specs/openid-connect-backchannel-1_0.html">OIDC Back-Channel Logout
* Spec</a>
*/
class OidcBackChannelLogoutFilter extends OncePerRequestFilter {
private final Log logger = LogFactory.getLog(getClass());
private final AuthenticationConverter authenticationConverter;
private final AuthenticationManager authenticationManager;
private final OAuth2ErrorHttpMessageConverter errorHttpMessageConverter = new OAuth2ErrorHttpMessageConverter();
private LogoutHandler logoutHandler = new OidcBackChannelLogoutHandler();
/**
* Construct an {@link OidcBackChannelLogoutFilter}
* @param authenticationConverter the {@link AuthenticationConverter} for deriving
* Logout Token authentication
* @param authenticationManager the {@link AuthenticationManager} for authenticating
* Logout Tokens
*/
OidcBackChannelLogoutFilter(AuthenticationConverter authenticationConverter,
AuthenticationManager authenticationManager) {
Assert.notNull(authenticationConverter, "authenticationConverter cannot be null");
Assert.notNull(authenticationManager, "authenticationManager cannot be null");
this.authenticationConverter = authenticationConverter;
this.authenticationManager = authenticationManager;
}
/**
* {@inheritDoc}
*/
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
Authentication token;
try {
token = this.authenticationConverter.convert(request);
}
catch (AuthenticationServiceException ex) {
this.logger.debug("Failed to process OIDC Back-Channel Logout", ex);
throw ex;
}
catch (AuthenticationException ex) {
handleAuthenticationFailure(response, ex);
return;
}
if (token == null) {
chain.doFilter(request, response);
return;
}
Authentication authentication;
try {
authentication = this.authenticationManager.authenticate(token);
}
catch (AuthenticationServiceException ex) {
this.logger.debug("Failed to process OIDC Back-Channel Logout", ex);
throw ex;
}
catch (AuthenticationException ex) {
handleAuthenticationFailure(response, ex);
return;
}
this.logoutHandler.logout(request, response, authentication);
}
private void handleAuthenticationFailure(HttpServletResponse response, Exception ex) throws IOException {
this.logger.debug("Failed to process OIDC Back-Channel Logout", ex);
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
this.errorHttpMessageConverter.write(oauth2Error(ex), null, new ServletServerHttpResponse(response));
}
private OAuth2Error oauth2Error(Exception ex) {
if (ex instanceof OAuth2AuthenticationException oauth2) {
return oauth2.getError();
}
return new OAuth2Error(OAuth2ErrorCodes.INVALID_REQUEST, ex.getMessage(),
"https://openid.net/specs/openid-connect-backchannel-1_0.html#Validation");
}
/**
* The strategy for expiring all Client sessions indicated by the logout request.
* Defaults to {@link OidcBackChannelLogoutHandler}.
* @param logoutHandler the {@link LogoutHandler} to use
*/
void setLogoutHandler(LogoutHandler logoutHandler) {
Assert.notNull(logoutHandler, "logoutHandler cannot be null");
this.logoutHandler = logoutHandler;
}
}
@@ -0,0 +1,175 @@
/*
* Copyright 2002-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.config.annotation.web.configurers.oauth2.client;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Map;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.server.ServletServerHttpResponse;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcLogoutToken;
import org.springframework.security.oauth2.client.oidc.session.InMemoryOidcSessionRegistry;
import org.springframework.security.oauth2.client.oidc.session.OidcSessionInformation;
import org.springframework.security.oauth2.client.oidc.session.OidcSessionRegistry;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.http.converter.OAuth2ErrorHttpMessageConverter;
import org.springframework.security.web.authentication.logout.LogoutHandler;
import org.springframework.util.Assert;
import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestOperations;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;
/**
* A {@link LogoutHandler} that locates the sessions associated with a given OIDC
* Back-Channel Logout Token and invalidates each one.
*
* @author Josh Cummings
* @since 6.2
* @see <a target="_blank" href=
* "https://openid.net/specs/openid-connect-backchannel-1_0.html">OIDC Back-Channel Logout
* Spec</a>
*/
final class OidcBackChannelLogoutHandler implements LogoutHandler {
private final Log logger = LogFactory.getLog(getClass());
private OidcSessionRegistry sessionRegistry = new InMemoryOidcSessionRegistry();
private RestOperations restOperations = new RestTemplate();
private String logoutEndpointName = "/logout";
private String sessionCookieName = "JSESSIONID";
private final OAuth2ErrorHttpMessageConverter errorHttpMessageConverter = new OAuth2ErrorHttpMessageConverter();
@Override
public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
if (!(authentication instanceof OidcBackChannelLogoutAuthentication token)) {
if (this.logger.isDebugEnabled()) {
String message = "Did not perform OIDC Back-Channel Logout since authentication [%s] was of the wrong type";
this.logger.debug(String.format(message, authentication.getClass().getSimpleName()));
}
return;
}
Iterable<OidcSessionInformation> sessions = this.sessionRegistry.removeSessionInformation(token.getPrincipal());
Collection<String> errors = new ArrayList<>();
int totalCount = 0;
int invalidatedCount = 0;
for (OidcSessionInformation session : sessions) {
totalCount++;
try {
eachLogout(request, session);
invalidatedCount++;
}
catch (RestClientException ex) {
this.logger.debug("Failed to invalidate session", ex);
errors.add(ex.getMessage());
this.sessionRegistry.saveSessionInformation(session);
}
}
if (this.logger.isTraceEnabled()) {
this.logger.trace(String.format("Invalidated %d out of %d sessions", invalidatedCount, totalCount));
}
if (!errors.isEmpty()) {
handleLogoutFailure(response, oauth2Error(errors));
}
}
private void eachLogout(HttpServletRequest request, OidcSessionInformation session) {
HttpHeaders headers = new HttpHeaders();
headers.add(HttpHeaders.COOKIE, this.sessionCookieName + "=" + session.getSessionId());
for (Map.Entry<String, String> credential : session.getAuthorities().entrySet()) {
headers.add(credential.getKey(), credential.getValue());
}
String url = request.getRequestURL().toString();
String logout = UriComponentsBuilder.fromHttpUrl(url).replacePath(this.logoutEndpointName).build()
.toUriString();
HttpEntity<?> entity = new HttpEntity<>(null, headers);
this.restOperations.postForEntity(logout, entity, Object.class);
}
private OAuth2Error oauth2Error(Collection<String> errors) {
return new OAuth2Error("partial_logout", "not all sessions were terminated: " + errors,
"https://openid.net/specs/openid-connect-backchannel-1_0.html#Validation");
}
private void handleLogoutFailure(HttpServletResponse response, OAuth2Error error) {
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
try {
this.errorHttpMessageConverter.write(error, null, new ServletServerHttpResponse(response));
}
catch (IOException ex) {
throw new IllegalStateException(ex);
}
}
/**
* Use this {@link OidcSessionRegistry} to identify sessions to invalidate. Note that
* this class uses
* {@link OidcSessionRegistry#removeSessionInformation(OidcLogoutToken)} to identify
* sessions.
* @param sessionRegistry the {@link OidcSessionRegistry} to use
*/
void setSessionRegistry(OidcSessionRegistry sessionRegistry) {
Assert.notNull(sessionRegistry, "sessionRegistry cannot be null");
this.sessionRegistry = sessionRegistry;
}
/**
* Use this {@link RestOperations} to perform the per-session back-channel logout
* @param restOperations the {@link RestOperations} to use
*/
void setRestOperations(RestOperations restOperations) {
Assert.notNull(restOperations, "restOperations cannot be null");
this.restOperations = restOperations;
}
/**
* Use this logout URI for performing per-session logout. Defaults to {@code /logout}
* since that is the default URI for
* {@link org.springframework.security.web.authentication.logout.LogoutFilter}.
* @param logoutUri the URI to use
*/
void setLogoutUri(String logoutUri) {
Assert.hasText(logoutUri, "logoutUri cannot be empty");
this.logoutEndpointName = logoutUri;
}
/**
* Use this cookie name for the session identifier. Defaults to {@code JSESSIONID}.
*
* <p>
* Note that if you are using Spring Session, this likely needs to change to SESSION.
* @param sessionCookieName the cookie name to use
*/
void setSessionCookieName(String sessionCookieName) {
Assert.hasText(sessionCookieName, "clientSessionCookieName cannot be empty");
this.sessionCookieName = sessionCookieName;
}
}
@@ -0,0 +1,118 @@
/*
* Copyright 2002-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.config.annotation.web.configurers.oauth2.client;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import org.springframework.security.oauth2.client.oidc.authentication.logout.LogoutTokenClaimAccessor;
import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcLogoutToken;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
import org.springframework.security.oauth2.jwt.Jwt;
/**
* A {@link OAuth2TokenValidator} that validates OIDC Logout Token claims in conformance
* with the OIDC Back-Channel Logout Spec.
*
* @author Josh Cummings
* @since 6.2
* @see OidcLogoutToken
* @see <a target="_blank" href=
* "https://openid.net/specs/openid-connect-backchannel-1_0.html#LogoutToken">Logout
* Token</a>
* @see <a target="blank" href=
* "https://openid.net/specs/openid-connect-backchannel-1_0.html#Validation">the OIDC
* Back-Channel Logout spec</a>
*/
final class OidcBackChannelLogoutTokenValidator implements OAuth2TokenValidator<Jwt> {
private static final String LOGOUT_VALIDATION_URL = "https://openid.net/specs/openid-connect-backchannel-1_0.html#Validation";
private static final String BACK_CHANNEL_LOGOUT_EVENT = "http://schemas.openid.net/event/backchannel-logout";
private final String audience;
private final String issuer;
OidcBackChannelLogoutTokenValidator(ClientRegistration clientRegistration) {
this.audience = clientRegistration.getClientId();
this.issuer = clientRegistration.getProviderDetails().getIssuerUri();
}
@Override
public OAuth2TokenValidatorResult validate(Jwt jwt) {
Collection<OAuth2Error> errors = new ArrayList<>();
LogoutTokenClaimAccessor logoutClaims = jwt::getClaims;
Map<String, Object> events = logoutClaims.getEvents();
if (events == null) {
errors.add(invalidLogoutToken("events claim must not be null"));
}
else if (events.get(BACK_CHANNEL_LOGOUT_EVENT) == null) {
errors.add(invalidLogoutToken("events claim map must contain \"" + BACK_CHANNEL_LOGOUT_EVENT + "\" key"));
}
String issuer = logoutClaims.getIssuer().toExternalForm();
if (issuer == null) {
errors.add(invalidLogoutToken("iss claim must not be null"));
}
else if (!this.issuer.equals(issuer)) {
errors.add(invalidLogoutToken(
"iss claim value must match `ClientRegistration#getProviderDetails#getIssuerUri`"));
}
List<String> audience = logoutClaims.getAudience();
if (audience == null) {
errors.add(invalidLogoutToken("aud claim must not be null"));
}
else if (!audience.contains(this.audience)) {
errors.add(invalidLogoutToken("aud claim value must include `ClientRegistration#getClientId`"));
}
Instant issuedAt = logoutClaims.getIssuedAt();
if (issuedAt == null) {
errors.add(invalidLogoutToken("iat claim must not be null"));
}
String jwtId = logoutClaims.getId();
if (jwtId == null) {
errors.add(invalidLogoutToken("jti claim must not be null"));
}
if (logoutClaims.getSubject() == null && logoutClaims.getSessionId() == null) {
errors.add(invalidLogoutToken("sub and sid claims must not both be null"));
}
if (logoutClaims.getClaim("nonce") != null) {
errors.add(invalidLogoutToken("nonce claim must not be present"));
}
return OAuth2TokenValidatorResult.failure(errors);
}
private static OAuth2Error invalidLogoutToken(String description) {
return new OAuth2Error(OAuth2ErrorCodes.INVALID_TOKEN, description, LOGOUT_VALIDATION_URL);
}
}
@@ -0,0 +1,85 @@
/*
* Copyright 2002-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.config.annotation.web.configurers.oauth2.client;
import jakarta.servlet.http.HttpServletRequest;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
import org.springframework.security.web.authentication.AuthenticationConverter;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.security.web.util.matcher.RequestMatcher;
import org.springframework.util.Assert;
/**
* An {@link AuthenticationConverter} that extracts the OIDC Logout Token authentication
* request
*
* @author Josh Cummings
* @since 6.2
*/
final class OidcLogoutAuthenticationConverter implements AuthenticationConverter {
private static final String DEFAULT_LOGOUT_URI = "/logout/connect/back-channel/{registrationId}";
private final Log logger = LogFactory.getLog(getClass());
private final ClientRegistrationRepository clientRegistrationRepository;
private RequestMatcher requestMatcher = new AntPathRequestMatcher(DEFAULT_LOGOUT_URI, "POST");
OidcLogoutAuthenticationConverter(ClientRegistrationRepository clientRegistrationRepository) {
Assert.notNull(clientRegistrationRepository, "clientRegistrationRepository cannot be null");
this.clientRegistrationRepository = clientRegistrationRepository;
}
@Override
public Authentication convert(HttpServletRequest request) {
RequestMatcher.MatchResult result = this.requestMatcher.matcher(request);
if (!result.isMatch()) {
return null;
}
String registrationId = result.getVariables().get("registrationId");
ClientRegistration clientRegistration = this.clientRegistrationRepository.findByRegistrationId(registrationId);
if (clientRegistration == null) {
this.logger.debug("Did not process OIDC Back-Channel Logout since no ClientRegistration was found");
throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_REQUEST);
}
String logoutToken = request.getParameter("logout_token");
if (logoutToken == null) {
this.logger.debug("Failed to process OIDC Back-Channel Logout since no logout token was found");
throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_REQUEST);
}
return new OidcLogoutAuthenticationToken(logoutToken, clientRegistration);
}
/**
* The logout endpoint. Defaults to
* {@code /logout/connect/back-channel/{registrationId}}.
* @param requestMatcher the {@link RequestMatcher} to use
*/
void setRequestMatcher(RequestMatcher requestMatcher) {
Assert.notNull(requestMatcher, "requestMatcher cannot be null");
this.requestMatcher = requestMatcher;
}
}
@@ -0,0 +1,80 @@
/*
* Copyright 2002-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.config.annotation.web.configurers.oauth2.client;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
/**
* An {@link org.springframework.security.core.Authentication} instance that represents a
* request to authenticate an OIDC Logout Token.
*
* @author Josh Cummings
* @since 6.2
*/
class OidcLogoutAuthenticationToken extends AbstractAuthenticationToken {
private final String logoutToken;
private final ClientRegistration clientRegistration;
/**
* Construct an {@link OidcLogoutAuthenticationToken}
* @param logoutToken a signed, serialized OIDC Logout token
* @param clientRegistration the {@link ClientRegistration client} associated with
* this token; this is usually derived from material in the logout HTTP request
*/
OidcLogoutAuthenticationToken(String logoutToken, ClientRegistration clientRegistration) {
super(AuthorityUtils.NO_AUTHORITIES);
this.logoutToken = logoutToken;
this.clientRegistration = clientRegistration;
}
/**
* {@inheritDoc}
*/
@Override
public String getCredentials() {
return this.logoutToken;
}
/**
* {@inheritDoc}
*/
@Override
public String getPrincipal() {
return this.logoutToken;
}
/**
* Get the signed, serialized OIDC Logout token
* @return the logout token
*/
String getLogoutToken() {
return this.logoutToken;
}
/**
* Get the {@link ClientRegistration} associated with this logout token
* @return the {@link ClientRegistration}
*/
ClientRegistration getClientRegistration() {
return this.clientRegistration;
}
}
@@ -0,0 +1,159 @@
/*
* Copyright 2002-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.config.annotation.web.configurers.oauth2.client;
import java.util.function.Consumer;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.HttpSecurityBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.oauth2.client.oidc.session.OidcSessionRegistry;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.security.web.authentication.AuthenticationConverter;
import org.springframework.security.web.authentication.logout.LogoutHandler;
import org.springframework.security.web.csrf.CsrfFilter;
import org.springframework.util.Assert;
/**
* An {@link AbstractHttpConfigurer} for OIDC Logout flows
*
* <p>
* OIDC Logout provides an application with the capability to have users log out by using
* their existing account at an OAuth 2.0 or OpenID Connect 1.0 Provider.
*
*
* <h2>Security Filters</h2>
*
* The following {@code Filter} is populated:
*
* <ul>
* <li>{@link OidcBackChannelLogoutFilter}</li>
* </ul>
*
* <h2>Shared Objects Used</h2>
*
* The following shared objects are used:
*
* <ul>
* <li>{@link ClientRegistrationRepository}</li>
* </ul>
*
* @author Josh Cummings
* @since 6.2
* @see HttpSecurity#oidcLogout()
* @see OidcBackChannelLogoutFilter
* @see ClientRegistrationRepository
*/
public final class OidcLogoutConfigurer<B extends HttpSecurityBuilder<B>>
extends AbstractHttpConfigurer<OidcLogoutConfigurer<B>, B> {
private BackChannelLogoutConfigurer backChannel;
/**
* Sets the repository of client registrations.
* @param clientRegistrationRepository the repository of client registrations
* @return the {@link OAuth2LoginConfigurer} for further configuration
*/
public OidcLogoutConfigurer<B> clientRegistrationRepository(
ClientRegistrationRepository clientRegistrationRepository) {
Assert.notNull(clientRegistrationRepository, "clientRegistrationRepository cannot be null");
this.getBuilder().setSharedObject(ClientRegistrationRepository.class, clientRegistrationRepository);
return this;
}
/**
* Sets the registry for managing the OIDC client-provider session link
* @param oidcSessionRegistry the {@link OidcSessionRegistry} to use
* @return the {@link OAuth2LoginConfigurer} for further configuration
*/
public OidcLogoutConfigurer<B> oidcSessionRegistry(OidcSessionRegistry oidcSessionRegistry) {
Assert.notNull(oidcSessionRegistry, "oidcSessionRegistry cannot be null");
getBuilder().setSharedObject(OidcSessionRegistry.class, oidcSessionRegistry);
return this;
}
/**
* Configure OIDC Back-Channel Logout using the provided {@link Consumer}
* @return the {@link OidcLogoutConfigurer} for further configuration
*/
public OidcLogoutConfigurer<B> backChannel(Customizer<BackChannelLogoutConfigurer> backChannelLogoutConfigurer) {
if (this.backChannel == null) {
this.backChannel = new BackChannelLogoutConfigurer();
}
backChannelLogoutConfigurer.customize(this.backChannel);
return this;
}
@Deprecated(forRemoval = true, since = "6.2")
public B and() {
return getBuilder();
}
@Override
public void configure(B builder) throws Exception {
if (this.backChannel != null) {
this.backChannel.configure(builder);
}
}
/**
* A configurer for configuring OIDC Back-Channel Logout
*/
public final class BackChannelLogoutConfigurer {
private AuthenticationConverter authenticationConverter;
private final AuthenticationManager authenticationManager = new ProviderManager(
new OidcBackChannelLogoutAuthenticationProvider());
private LogoutHandler logoutHandler;
private AuthenticationConverter authenticationConverter(B http) {
if (this.authenticationConverter == null) {
ClientRegistrationRepository clientRegistrationRepository = OAuth2ClientConfigurerUtils
.getClientRegistrationRepository(http);
this.authenticationConverter = new OidcLogoutAuthenticationConverter(clientRegistrationRepository);
}
return this.authenticationConverter;
}
private AuthenticationManager authenticationManager() {
return this.authenticationManager;
}
private LogoutHandler logoutHandler(B http) {
if (this.logoutHandler == null) {
OidcBackChannelLogoutHandler logoutHandler = new OidcBackChannelLogoutHandler();
logoutHandler.setSessionRegistry(OAuth2ClientConfigurerUtils.getOidcSessionRegistry(http));
this.logoutHandler = logoutHandler;
}
return this.logoutHandler;
}
void configure(B http) {
OidcBackChannelLogoutFilter filter = new OidcBackChannelLogoutFilter(authenticationConverter(http),
authenticationManager());
filter.setLogoutHandler(logoutHandler(http));
http.addFilterBefore(filter, CsrfFilter.class);
}
}
}
@@ -0,0 +1,35 @@
/*
* Copyright 2002-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.config.web.server;
import java.util.function.Function;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtTimestampValidator;
final class DefaultOidcLogoutTokenValidatorFactory implements Function<ClientRegistration, OAuth2TokenValidator<Jwt>> {
@Override
public OAuth2TokenValidator<Jwt> apply(ClientRegistration clientRegistration) {
return new DelegatingOAuth2TokenValidator<>(new JwtTimestampValidator(),
new OidcBackChannelLogoutTokenValidator(clientRegistration));
}
}
@@ -0,0 +1,66 @@
/*
* Copyright 2002-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.config.web.server;
import java.util.Collections;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcLogoutToken;
/**
* An {@link org.springframework.security.core.Authentication} implementation that
* represents the result of authenticating an OIDC Logout token for the purposes of
* performing Back-Channel Logout.
*
* @author Josh Cummings
* @since 6.2
* @see OidcLogoutAuthenticationToken
* @see <a target="_blank" href=
* "https://openid.net/specs/openid-connect-backchannel-1_0.html">OIDC Back-Channel
* Logout</a>
*/
class OidcBackChannelLogoutAuthentication extends AbstractAuthenticationToken {
private final OidcLogoutToken logoutToken;
/**
* Construct an {@link OidcBackChannelLogoutAuthentication}
* @param logoutToken a deserialized, verified OIDC Logout Token
*/
OidcBackChannelLogoutAuthentication(OidcLogoutToken logoutToken) {
super(Collections.emptyList());
this.logoutToken = logoutToken;
setAuthenticated(true);
}
/**
* {@inheritDoc}
*/
@Override
public OidcLogoutToken getPrincipal() {
return this.logoutToken;
}
/**
* {@inheritDoc}
*/
@Override
public OidcLogoutToken getCredentials() {
return this.logoutToken;
}
}
@@ -0,0 +1,112 @@
/*
* Copyright 2002-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.config.web.server;
import reactor.core.publisher.Mono;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.AuthenticationServiceException;
import org.springframework.security.authentication.ReactiveAuthenticationManager;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.oauth2.client.oidc.authentication.ReactiveOidcIdTokenDecoderFactory;
import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcLogoutToken;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
import org.springframework.security.oauth2.jwt.BadJwtException;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtDecoderFactory;
import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder;
import org.springframework.security.oauth2.jwt.ReactiveJwtDecoderFactory;
import org.springframework.util.Assert;
/**
* An {@link AuthenticationProvider} that authenticates an OIDC Logout Token; namely
* deserializing it, verifying its signature, and validating its claims.
*
* <p>
* Intended to be included in a
* {@link org.springframework.security.authentication.ProviderManager}
*
* @author Josh Cummings
* @since 6.2
* @see OidcLogoutAuthenticationToken
* @see org.springframework.security.authentication.ProviderManager
* @see <a target="_blank" href=
* "https://openid.net/specs/openid-connect-backchannel-1_0.html">OIDC Back-Channel
* Logout</a>
*/
final class OidcBackChannelLogoutReactiveAuthenticationManager implements ReactiveAuthenticationManager {
private ReactiveJwtDecoderFactory<ClientRegistration> logoutTokenDecoderFactory;
/**
* Construct an {@link OidcBackChannelLogoutReactiveAuthenticationManager}
*/
OidcBackChannelLogoutReactiveAuthenticationManager() {
ReactiveOidcIdTokenDecoderFactory logoutTokenDecoderFactory = new ReactiveOidcIdTokenDecoderFactory();
logoutTokenDecoderFactory.setJwtValidatorFactory(new DefaultOidcLogoutTokenValidatorFactory());
this.logoutTokenDecoderFactory = logoutTokenDecoderFactory;
}
/**
* {@inheritDoc}
*/
@Override
public Mono<Authentication> authenticate(Authentication authentication) throws AuthenticationException {
if (!(authentication instanceof OidcLogoutAuthenticationToken token)) {
return Mono.empty();
}
String logoutToken = token.getLogoutToken();
ClientRegistration registration = token.getClientRegistration();
return decode(registration, logoutToken)
.map((jwt) -> OidcLogoutToken
.withTokenValue(logoutToken)
.claims((claims) -> claims.putAll(jwt.getClaims())).build()
)
.map(OidcBackChannelLogoutAuthentication::new);
}
private Mono<Jwt> decode(ClientRegistration registration, String token) {
ReactiveJwtDecoder logoutTokenDecoder = this.logoutTokenDecoderFactory.createDecoder(registration);
try {
return logoutTokenDecoder.decode(token);
}
catch (BadJwtException failed) {
OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.INVALID_REQUEST, failed.getMessage(),
"https://openid.net/specs/openid-connect-backchannel-1_0.html#Validation");
return Mono.error(new OAuth2AuthenticationException(error, failed));
}
catch (Exception failed) {
return Mono.error(new AuthenticationServiceException(failed.getMessage(), failed));
}
}
/**
* Use this {@link ReactiveJwtDecoderFactory} to generate {@link JwtDecoder}s that
* correspond to the {@link ClientRegistration} associated with the OIDC logout token.
* @param logoutTokenDecoderFactory the {@link JwtDecoderFactory} to use
*/
void setLogoutTokenDecoderFactory(ReactiveJwtDecoderFactory<ClientRegistration> logoutTokenDecoderFactory) {
Assert.notNull(logoutTokenDecoderFactory, "logoutTokenDecoderFactory cannot be null");
this.logoutTokenDecoderFactory = logoutTokenDecoderFactory;
}
}
@@ -0,0 +1,118 @@
/*
* Copyright 2002-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.config.web.server;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import org.springframework.security.oauth2.client.oidc.authentication.logout.LogoutTokenClaimAccessor;
import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcLogoutToken;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
import org.springframework.security.oauth2.jwt.Jwt;
/**
* A {@link OAuth2TokenValidator} that validates OIDC Logout Token claims in conformance
* with the OIDC Back-Channel Logout Spec.
*
* @author Josh Cummings
* @since 6.2
* @see OidcLogoutToken
* @see <a target="_blank" href=
* "https://openid.net/specs/openid-connect-backchannel-1_0.html#LogoutToken">Logout
* Token</a>
* @see <a target="blank" href=
* "https://openid.net/specs/openid-connect-backchannel-1_0.html#Validation">the OIDC
* Back-Channel Logout spec</a>
*/
final class OidcBackChannelLogoutTokenValidator implements OAuth2TokenValidator<Jwt> {
private static final String LOGOUT_VALIDATION_URL = "https://openid.net/specs/openid-connect-backchannel-1_0.html#Validation";
private static final String BACK_CHANNEL_LOGOUT_EVENT = "http://schemas.openid.net/event/backchannel-logout";
private final String audience;
private final String issuer;
OidcBackChannelLogoutTokenValidator(ClientRegistration clientRegistration) {
this.audience = clientRegistration.getClientId();
this.issuer = clientRegistration.getProviderDetails().getIssuerUri();
}
@Override
public OAuth2TokenValidatorResult validate(Jwt jwt) {
Collection<OAuth2Error> errors = new ArrayList<>();
LogoutTokenClaimAccessor logoutClaims = jwt::getClaims;
Map<String, Object> events = logoutClaims.getEvents();
if (events == null) {
errors.add(invalidLogoutToken("events claim must not be null"));
}
else if (events.get(BACK_CHANNEL_LOGOUT_EVENT) == null) {
errors.add(invalidLogoutToken("events claim map must contain \"" + BACK_CHANNEL_LOGOUT_EVENT + "\" key"));
}
String issuer = logoutClaims.getIssuer().toExternalForm();
if (issuer == null) {
errors.add(invalidLogoutToken("iss claim must not be null"));
}
else if (!this.issuer.equals(issuer)) {
errors.add(invalidLogoutToken(
"iss claim value must match `ClientRegistration#getProviderDetails#getIssuerUri`"));
}
List<String> audience = logoutClaims.getAudience();
if (audience == null) {
errors.add(invalidLogoutToken("aud claim must not be null"));
}
else if (!audience.contains(this.audience)) {
errors.add(invalidLogoutToken("aud claim value must include `ClientRegistration#getClientId`"));
}
Instant issuedAt = logoutClaims.getIssuedAt();
if (issuedAt == null) {
errors.add(invalidLogoutToken("iat claim must not be null"));
}
String jwtId = logoutClaims.getId();
if (jwtId == null) {
errors.add(invalidLogoutToken("jti claim must not be null"));
}
if (logoutClaims.getSubject() == null && logoutClaims.getSessionId() == null) {
errors.add(invalidLogoutToken("sub and sid claims must not both be null"));
}
if (logoutClaims.getClaim("nonce") != null) {
errors.add(invalidLogoutToken("nonce claim must not be present"));
}
return OAuth2TokenValidatorResult.failure(errors);
}
private static OAuth2Error invalidLogoutToken(String description) {
return new OAuth2Error(OAuth2ErrorCodes.INVALID_TOKEN, description, LOGOUT_VALIDATION_URL);
}
}
@@ -0,0 +1,135 @@
/*
* Copyright 2002-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.config.web.server;
import java.nio.charset.StandardCharsets;
import jakarta.servlet.http.HttpServletResponse;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.AuthenticationServiceException;
import org.springframework.security.authentication.ReactiveAuthenticationManager;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
import org.springframework.security.web.authentication.AuthenticationConverter;
import org.springframework.security.web.authentication.logout.LogoutHandler;
import org.springframework.security.web.server.WebFilterExchange;
import org.springframework.security.web.server.authentication.ServerAuthenticationConverter;
import org.springframework.security.web.server.authentication.logout.ServerLogoutHandler;
import org.springframework.util.Assert;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.WebFilter;
import org.springframework.web.server.WebFilterChain;
/**
* A filter for the Client-side OIDC Back-Channel Logout endpoint
*
* @author Josh Cummings
* @since 6.2
* @see <a target="_blank" href=
* "https://openid.net/specs/openid-connect-backchannel-1_0.html">OIDC Back-Channel Logout
* Spec</a>
*/
class OidcBackChannelLogoutWebFilter implements WebFilter {
private final Log logger = LogFactory.getLog(getClass());
private final ServerAuthenticationConverter authenticationConverter;
private final ReactiveAuthenticationManager authenticationManager;
private ServerLogoutHandler logoutHandler = new OidcBackChannelServerLogoutHandler();
/**
* Construct an {@link OidcBackChannelLogoutWebFilter}
* @param authenticationConverter the {@link AuthenticationConverter} for deriving
* Logout Token authentication
* @param authenticationManager the {@link AuthenticationManager} for authenticating
* Logout Tokens
*/
OidcBackChannelLogoutWebFilter(ServerAuthenticationConverter authenticationConverter,
ReactiveAuthenticationManager authenticationManager) {
Assert.notNull(authenticationConverter, "authenticationConverter cannot be null");
Assert.notNull(authenticationManager, "authenticationManager cannot be null");
this.authenticationConverter = authenticationConverter;
this.authenticationManager = authenticationManager;
}
@Override
public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
return this.authenticationConverter.convert(exchange).onErrorResume(AuthenticationException.class, (ex) -> {
this.logger.debug("Failed to process OIDC Back-Channel Logout", ex);
if (ex instanceof AuthenticationServiceException) {
return Mono.error(ex);
}
return handleAuthenticationFailure(exchange.getResponse(), ex).then(Mono.empty());
}).switchIfEmpty(chain.filter(exchange).then(Mono.empty())).flatMap(this.authenticationManager::authenticate)
.onErrorResume(AuthenticationException.class, (ex) -> {
this.logger.debug("Failed to process OIDC Back-Channel Logout", ex);
if (ex instanceof AuthenticationServiceException) {
return Mono.error(ex);
}
return handleAuthenticationFailure(exchange.getResponse(), ex).then(Mono.empty());
}).flatMap((authentication) -> {
WebFilterExchange webFilterExchange = new WebFilterExchange(exchange, chain);
return this.logoutHandler.logout(webFilterExchange, authentication);
});
}
private Mono<Void> handleAuthenticationFailure(ServerHttpResponse response, Exception ex) {
this.logger.debug("Failed to process OIDC Back-Channel Logout", ex);
response.setRawStatusCode(HttpServletResponse.SC_BAD_REQUEST);
OAuth2Error error = oauth2Error(ex);
byte[] bytes = String.format("""
{
"error_code": "%s",
"error_description": "%s",
"error_uri: "%s"
}
""", error.getErrorCode(), error.getDescription(), error.getUri())
.getBytes(StandardCharsets.UTF_8);
DataBuffer buffer = response.bufferFactory().wrap(bytes);
return response.writeWith(Flux.just(buffer));
}
private OAuth2Error oauth2Error(Exception ex) {
if (ex instanceof OAuth2AuthenticationException oauth2) {
return oauth2.getError();
}
return new OAuth2Error(OAuth2ErrorCodes.INVALID_REQUEST, ex.getMessage(),
"https://openid.net/specs/openid-connect-backchannel-1_0.html#Validation");
}
/**
* The strategy for expiring all Client sessions indicated by the logout request.
* Defaults to {@link OidcBackChannelServerLogoutHandler}.
* @param logoutHandler the {@link LogoutHandler} to use
*/
void setLogoutHandler(ServerLogoutHandler logoutHandler) {
Assert.notNull(logoutHandler, "logoutHandler cannot be null");
this.logoutHandler = logoutHandler;
}
}
@@ -0,0 +1,183 @@
/*
* Copyright 2002-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.config.web.server;
import java.nio.charset.StandardCharsets;
import java.util.Collection;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import jakarta.servlet.http.HttpServletResponse;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcLogoutToken;
import org.springframework.security.oauth2.client.oidc.server.session.InMemoryReactiveOidcSessionRegistry;
import org.springframework.security.oauth2.client.oidc.server.session.ReactiveOidcSessionRegistry;
import org.springframework.security.oauth2.client.oidc.session.OidcSessionInformation;
import org.springframework.security.oauth2.client.oidc.session.OidcSessionRegistry;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.web.server.WebFilterExchange;
import org.springframework.security.web.server.authentication.logout.ServerLogoutHandler;
import org.springframework.util.Assert;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.util.UriComponentsBuilder;
/**
* A {@link ServerLogoutHandler} that locates the sessions associated with a given OIDC
* Back-Channel Logout Token and invalidates each one.
*
* @author Josh Cummings
* @since 6.2
* @see <a target="_blank" href=
* "https://openid.net/specs/openid-connect-backchannel-1_0.html">OIDC Back-Channel Logout
* Spec</a>
*/
final class OidcBackChannelServerLogoutHandler implements ServerLogoutHandler {
private final Log logger = LogFactory.getLog(getClass());
private ReactiveOidcSessionRegistry sessionRegistry = new InMemoryReactiveOidcSessionRegistry();
private WebClient web = WebClient.create();
private String logoutEndpointName = "/logout";
private String sessionCookieName = "SESSION";
@Override
public Mono<Void> logout(WebFilterExchange exchange, Authentication authentication) {
if (!(authentication instanceof OidcBackChannelLogoutAuthentication token)) {
return Mono.defer(() -> {
if (this.logger.isDebugEnabled()) {
String message = "Did not perform OIDC Back-Channel Logout since authentication [%s] was of the wrong type";
this.logger.debug(String.format(message, authentication.getClass().getSimpleName()));
}
return Mono.empty();
});
}
AtomicInteger totalCount = new AtomicInteger(0);
AtomicInteger invalidatedCount = new AtomicInteger(0);
return this.sessionRegistry.removeSessionInformation(token.getPrincipal())
.concatMap((session) -> {
totalCount.incrementAndGet();
return eachLogout(exchange, session)
.flatMap((response) -> {
invalidatedCount.incrementAndGet();
return Mono.empty();
})
.onErrorResume((ex) -> {
this.logger.debug("Failed to invalidate session", ex);
return this.sessionRegistry.saveSessionInformation(session)
.then(Mono.just(ex.getMessage()));
});
}).collectList().flatMap((list) -> {
if (this.logger.isTraceEnabled()) {
this.logger.trace(String.format("Invalidated %d out of %d sessions", invalidatedCount.intValue(), totalCount.intValue()));
}
if (!list.isEmpty()) {
return handleLogoutFailure(exchange.getExchange().getResponse(), oauth2Error(list));
}
else {
return Mono.empty();
}
});
}
private Mono<ResponseEntity<Void>> eachLogout(WebFilterExchange exchange, OidcSessionInformation session) {
HttpHeaders headers = new HttpHeaders();
headers.add(HttpHeaders.COOKIE, this.sessionCookieName + "=" + session.getSessionId());
for (Map.Entry<String, String> credential : session.getAuthorities().entrySet()) {
headers.add(credential.getKey(), credential.getValue());
}
String url = exchange.getExchange().getRequest().getURI().toString();
String logout = UriComponentsBuilder.fromHttpUrl(url).replacePath(this.logoutEndpointName).build()
.toUriString();
return this.web.post().uri(logout).headers((h) -> h.putAll(headers)).retrieve().toBodilessEntity();
}
private OAuth2Error oauth2Error(Collection<?> errors) {
return new OAuth2Error("partial_logout", "not all sessions were terminated: " + errors,
"https://openid.net/specs/openid-connect-backchannel-1_0.html#Validation");
}
private Mono<Void> handleLogoutFailure(ServerHttpResponse response, OAuth2Error error) {
response.setRawStatusCode(HttpServletResponse.SC_BAD_REQUEST);
byte[] bytes = String.format("""
{
"error_code": "%s",
"error_description": "%s",
"error_uri: "%s"
}
""", error.getErrorCode(), error.getDescription(), error.getUri())
.getBytes(StandardCharsets.UTF_8);
DataBuffer buffer = response.bufferFactory().wrap(bytes);
return response.writeWith(Flux.just(buffer));
}
/**
* Use this {@link OidcSessionRegistry} to identify sessions to invalidate. Note that
* this class uses
* {@link OidcSessionRegistry#removeSessionInformation(OidcLogoutToken)} to identify
* sessions.
* @param sessionRegistry the {@link OidcSessionRegistry} to use
*/
void setSessionRegistry(ReactiveOidcSessionRegistry sessionRegistry) {
Assert.notNull(sessionRegistry, "sessionRegistry cannot be null");
this.sessionRegistry = sessionRegistry;
}
/**
* Use this {@link WebClient} to perform the per-session back-channel logout
* @param web the {@link WebClient} to use
*/
void setWebClient(WebClient web) {
Assert.notNull(web, "web cannot be null");
this.web = web;
}
/**
* Use this logout URI for performing per-session logout. Defaults to {@code /logout}
* since that is the default URI for
* {@link org.springframework.security.web.authentication.logout.LogoutFilter}.
* @param logoutUri the URI to use
*/
void setLogoutUri(String logoutUri) {
Assert.hasText(logoutUri, "logoutUri cannot be empty");
this.logoutEndpointName = logoutUri;
}
/**
* Use this cookie name for the session identifier. Defaults to {@code JSESSIONID}.
*
* <p>
* Note that if you are using Spring Session, this likely needs to change to SESSION.
* @param sessionCookieName the cookie name to use
*/
void setSessionCookieName(String sessionCookieName) {
Assert.hasText(sessionCookieName, "clientSessionCookieName cannot be empty");
this.sessionCookieName = sessionCookieName;
}
}
@@ -0,0 +1,80 @@
/*
* Copyright 2002-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.config.web.server;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
/**
* An {@link org.springframework.security.core.Authentication} instance that represents a
* request to authenticate an OIDC Logout Token.
*
* @author Josh Cummings
* @since 6.2
*/
class OidcLogoutAuthenticationToken extends AbstractAuthenticationToken {
private final String logoutToken;
private final ClientRegistration clientRegistration;
/**
* Construct an {@link OidcLogoutAuthenticationToken}
* @param logoutToken a signed, serialized OIDC Logout token
* @param clientRegistration the {@link ClientRegistration client} associated with
* this token; this is usually derived from material in the logout HTTP request
*/
OidcLogoutAuthenticationToken(String logoutToken, ClientRegistration clientRegistration) {
super(AuthorityUtils.NO_AUTHORITIES);
this.logoutToken = logoutToken;
this.clientRegistration = clientRegistration;
}
/**
* {@inheritDoc}
*/
@Override
public String getCredentials() {
return this.logoutToken;
}
/**
* {@inheritDoc}
*/
@Override
public String getPrincipal() {
return this.logoutToken;
}
/**
* Get the signed, serialized OIDC Logout token
* @return the logout token
*/
String getLogoutToken() {
return this.logoutToken;
}
/**
* Get the {@link ClientRegistration} associated with this logout token
* @return the {@link ClientRegistration}
*/
ClientRegistration getClientRegistration() {
return this.clientRegistration;
}
}
@@ -0,0 +1,88 @@
/*
* Copyright 2002-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.config.web.server;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import reactor.core.publisher.Mono;
import org.springframework.http.HttpMethod;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.client.registration.ReactiveClientRegistrationRepository;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
import org.springframework.security.web.authentication.AuthenticationConverter;
import org.springframework.security.web.server.authentication.ServerAuthenticationConverter;
import org.springframework.security.web.server.util.matcher.PathPatternParserServerWebExchangeMatcher;
import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher;
import org.springframework.util.Assert;
import org.springframework.web.server.ServerWebExchange;
/**
* An {@link AuthenticationConverter} that extracts the OIDC Logout Token authentication
* request
*
* @author Josh Cummings
* @since 6.2
*/
final class OidcLogoutServerAuthenticationConverter implements ServerAuthenticationConverter {
private static final String DEFAULT_LOGOUT_URI = "/logout/connect/back-channel/{registrationId}";
private final Log logger = LogFactory.getLog(getClass());
private final ReactiveClientRegistrationRepository clientRegistrationRepository;
private ServerWebExchangeMatcher exchangeMatcher = new PathPatternParserServerWebExchangeMatcher(DEFAULT_LOGOUT_URI,
HttpMethod.POST);
OidcLogoutServerAuthenticationConverter(ReactiveClientRegistrationRepository clientRegistrationRepository) {
Assert.notNull(clientRegistrationRepository, "clientRegistrationRepository cannot be null");
this.clientRegistrationRepository = clientRegistrationRepository;
}
@Override
public Mono<Authentication> convert(ServerWebExchange exchange) {
return this.exchangeMatcher.matches(exchange).filter(ServerWebExchangeMatcher.MatchResult::isMatch)
.flatMap((match) -> {
String registrationId = (String) match.getVariables().get("registrationId");
return this.clientRegistrationRepository.findByRegistrationId(registrationId)
.switchIfEmpty(Mono.error(() -> {
this.logger.debug(
"Did not process OIDC Back-Channel Logout since no ClientRegistration was found");
return new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_REQUEST);
}));
}).flatMap((clientRegistration) -> exchange.getFormData().map((data) -> {
String logoutToken = data.getFirst("logout_token");
return new OidcLogoutAuthenticationToken(logoutToken, clientRegistration);
}).switchIfEmpty(Mono.error(() -> {
this.logger.debug("Failed to process OIDC Back-Channel Logout since no logout token was found");
return new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_REQUEST);
})));
}
/**
* The logout endpoint. Defaults to
* {@code /logout/connect/back-channel/{registrationId}}.
* @param exchangeMatcher the {@link ServerWebExchangeMatcher} to use
*/
void setExchangeMatcher(ServerWebExchangeMatcher exchangeMatcher) {
Assert.notNull(exchangeMatcher, "exchangeMatcher cannot be null");
this.exchangeMatcher = exchangeMatcher;
}
}
@@ -21,6 +21,7 @@ import java.io.PrintWriter;
import java.io.StringWriter;
import java.security.interfaces.RSAPublicKey;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
@@ -28,10 +29,13 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Supplier;
import io.micrometer.observation.ObservationRegistry;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import reactor.core.publisher.Mono;
import reactor.util.context.Context;
@@ -67,6 +71,9 @@ import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCo
import org.springframework.security.oauth2.client.endpoint.ReactiveOAuth2AccessTokenResponseClient;
import org.springframework.security.oauth2.client.endpoint.WebClientReactiveAuthorizationCodeTokenResponseClient;
import org.springframework.security.oauth2.client.oidc.authentication.OidcAuthorizationCodeReactiveAuthenticationManager;
import org.springframework.security.oauth2.client.oidc.server.session.InMemoryReactiveOidcSessionRegistry;
import org.springframework.security.oauth2.client.oidc.server.session.ReactiveOidcSessionRegistry;
import org.springframework.security.oauth2.client.oidc.session.OidcSessionInformation;
import org.springframework.security.oauth2.client.oidc.userinfo.OidcReactiveOAuth2UserService;
import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
@@ -113,6 +120,7 @@ import org.springframework.security.web.server.MatcherSecurityWebFilterChain;
import org.springframework.security.web.server.SecurityWebFilterChain;
import org.springframework.security.web.server.ServerAuthenticationEntryPoint;
import org.springframework.security.web.server.ServerRedirectStrategy;
import org.springframework.security.web.server.WebFilterExchange;
import org.springframework.security.web.server.authentication.AnonymousAuthenticationWebFilter;
import org.springframework.security.web.server.authentication.AuthenticationConverterServerWebExchangeMatcher;
import org.springframework.security.web.server.authentication.AuthenticationWebFilter;
@@ -147,6 +155,7 @@ import org.springframework.security.web.server.context.SecurityContextServerWebE
import org.springframework.security.web.server.context.ServerSecurityContextRepository;
import org.springframework.security.web.server.context.WebSessionServerSecurityContextRepository;
import org.springframework.security.web.server.csrf.CsrfServerLogoutHandler;
import org.springframework.security.web.server.csrf.CsrfToken;
import org.springframework.security.web.server.csrf.CsrfWebFilter;
import org.springframework.security.web.server.csrf.ServerCsrfTokenRepository;
import org.springframework.security.web.server.csrf.ServerCsrfTokenRequestHandler;
@@ -193,8 +202,10 @@ import org.springframework.web.cors.reactive.CorsWebFilter;
import org.springframework.web.cors.reactive.DefaultCorsProcessor;
import org.springframework.web.reactive.result.method.annotation.RequestMappingHandlerMapping;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.ServerWebExchangeDecorator;
import org.springframework.web.server.WebFilter;
import org.springframework.web.server.WebFilterChain;
import org.springframework.web.server.WebSession;
import org.springframework.web.util.pattern.PathPatternParser;
/**
@@ -295,6 +306,8 @@ public class ServerHttpSecurity {
private OAuth2ClientSpec client;
private OidcLogoutSpec oidcLogout;
private LogoutSpec logout = new LogoutSpec();
private LoginPageSpec loginPage = new LoginPageSpec();
@@ -1093,6 +1106,33 @@ public class ServerHttpSecurity {
return this;
}
/**
* Configures OIDC Connect 1.0 Logout support.
*
* <pre class="code">
* &#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
*
@@ -868,6 +868,38 @@ class HttpSecurityDsl(private val http: HttpSecurity, private val init: HttpSecu
this.http.oauth2ResourceServer(oauth2ResourceServerCustomizer)
}
/**
* Configures OIDC 1.0 logout support.
*
* Example:
*
* ```
* @Configuration
* @EnableWebSecurity
* class SecurityConfig {
*
* @Bean
* fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
* http {
* oauth2Login { }
* oidcLogout {
* backChannel { }
* }
* }
* return http.build()
* }
* }
* ```
*
* @param oidcLogoutConfiguration custom configuration to configure the
* OIDC 1.0 logout support
* @see [OidcLogoutDsl]
*/
fun oidcLogout(oidcLogoutConfiguration: OidcLogoutDsl.() -> Unit) {
val oidcLogoutCustomizer = OidcLogoutDsl().apply(oidcLogoutConfiguration).get()
this.http.oidcLogout(oidcLogoutCustomizer)
}
/**
* Configures Remember Me authentication.
*
@@ -0,0 +1,75 @@
/*
* Copyright 2002-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.config.annotation.web
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configurers.oauth2.client.OidcLogoutConfigurer
import org.springframework.security.config.annotation.web.oauth2.login.OidcBackChannelLogoutDsl
import org.springframework.security.oauth2.client.oidc.session.OidcSessionRegistry
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository
/**
* A Kotlin DSL to configure [HttpSecurity] OAuth 1.0 Logout using idiomatic Kotlin code.
*
* @author Josh Cummings
* @since 6.2
*/
@SecurityMarker
class OidcLogoutDsl {
var clientRegistrationRepository: ClientRegistrationRepository? = null
var oidcSessionRegistry: OidcSessionRegistry? = null
private var backChannel: ((OidcLogoutConfigurer<HttpSecurity>.BackChannelLogoutConfigurer) -> Unit)? = null
/**
* Configures the OIDC 1.0 Back-Channel endpoint.
*
* Example:
*
* ```
* @Configuration
* @EnableWebSecurity
* class SecurityConfig {
*
* @Bean
* fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
* http {
* oauth2Login { }
* oidcLogout {
* backChannel { }
* }
* }
* return http.build()
* }
* }
* ```
*
* @param backChannelConfig custom configurations to configure the back-channel endpoint
* @see [OidcBackChannelLogoutDsl]
*/
fun backChannel(backChannelConfig: OidcBackChannelLogoutDsl.() -> Unit) {
this.backChannel = OidcBackChannelLogoutDsl().apply(backChannelConfig).get()
}
internal fun get(): (OidcLogoutConfigurer<HttpSecurity>) -> Unit {
return { oidcLogout ->
clientRegistrationRepository?.also { oidcLogout.clientRegistrationRepository(clientRegistrationRepository) }
oidcSessionRegistry?.also { oidcLogout.oidcSessionRegistry(oidcSessionRegistry) }
backChannel?.also { oidcLogout.backChannel(backChannel) }
}
}
}
@@ -0,0 +1,34 @@
/*
* Copyright 2002-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.config.annotation.web.oauth2.login
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configurers.oauth2.client.OidcLogoutConfigurer
/**
* A Kotlin DSL to configure the OIDC 1.0 Back-Channel configuration using
* idiomatic Kotlin code.
*
* @author Josh Cummings
* @since 6.2
*/
@OAuth2LoginSecurityMarker
class OidcBackChannelLogoutDsl {
internal fun get(): (OidcLogoutConfigurer<HttpSecurity>.BackChannelLogoutConfigurer) -> Unit {
return { backChannel -> }
}
}
@@ -1,5 +1,5 @@
/*
* Copyright 2002-2021 the original author or authors.
* Copyright 2002-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -650,6 +650,38 @@ class ServerHttpSecurityDsl(private val http: ServerHttpSecurity, private val in
this.http.oauth2ResourceServer(oauth2ResourceServerCustomizer)
}
/**
* Configures logout support using an OpenID Connect 1.0 Provider.
* A [ReactiveClientRegistrationRepository] is required and must be registered as a Bean or
* configured via [ServerOidcLogoutDsl.clientRegistrationRepository].
*
* Example:
*
* ```
* @Configuration
* @EnableWebFluxSecurity
* class SecurityConfig {
*
* @Bean
* fun springWebFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain {
* return http {
* oauth2Login { }
* oidcLogout {
* backChannel { }
* }
* }
* }
* }
* ```
*
* @param oidcLogoutConfiguration custom configuration to configure the OIDC 1.0 Logout
* @see [ServerOidcLogoutDsl]
*/
fun oidcLogout(oidcLogoutConfiguration: ServerOidcLogoutDsl.() -> Unit) {
val oidcLogoutCustomizer = ServerOidcLogoutDsl().apply(oidcLogoutConfiguration).get()
this.http.oidcLogout(oidcLogoutCustomizer)
}
/**
* Apply all configurations to the provided [ServerHttpSecurity]
*/
@@ -0,0 +1,30 @@
/*
* Copyright 2002-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.config.web.server
/**
* A Kotlin DSL to configure [ServerHttpSecurity] OIDC 1.0 Back-Channel Logout support using idiomatic Kotlin code.
*
* @author Josh Cummings
* @since 6.2
*/
@ServerSecurityMarker
class ServerOidcBackChannelLogoutDsl {
internal fun get(): (ServerHttpSecurity.OidcLogoutSpec.BackChannelLogoutConfigurer) -> Unit {
return { backChannel -> }
}
}
@@ -0,0 +1,71 @@
/*
* Copyright 2002-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.config.web.server
import org.springframework.security.oauth2.client.oidc.server.session.ReactiveOidcSessionRegistry
import org.springframework.security.oauth2.client.registration.ReactiveClientRegistrationRepository
/**
* A Kotlin DSL to configure [ServerHttpSecurity] OIDC 1.0 login using idiomatic Kotlin code.
*
* @author Josh Cummings
* @since 6.2
*/
@ServerSecurityMarker
class ServerOidcLogoutDsl {
var clientRegistrationRepository: ReactiveClientRegistrationRepository? = null
var oidcSessionRegistry: ReactiveOidcSessionRegistry? = null
private var backChannel: ((ServerHttpSecurity.OidcLogoutSpec.BackChannelLogoutConfigurer) -> Unit)? = null
/**
* Enables OIDC 1.0 Back-Channel Logout support.
*
* Example:
*
* ```
* @Configuration
* @EnableWebFluxSecurity
* class SecurityConfig {
*
* @Bean
* fun springWebFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain {
* return http {
* oauth2Login { }
* oidcLogout {
* backChannel { }
* }
* }
* }
* }
* ```
*
* @param backChannelConfig custom configurations to configure OIDC 1.0 Back-Channel Logout support
* @see [ServerOidcBackChannelLogoutDsl]
*/
fun backChannel(backChannelConfig: ServerOidcBackChannelLogoutDsl.() -> Unit) {
this.backChannel = ServerOidcBackChannelLogoutDsl().apply(backChannelConfig).get()
}
internal fun get(): (ServerHttpSecurity.OidcLogoutSpec) -> Unit {
return { oidcLogout ->
clientRegistrationRepository?.also { oidcLogout.clientRegistrationRepository(clientRegistrationRepository) }
oidcSessionRegistry?.also { oidcLogout.oidcSessionRegistry(oidcSessionRegistry) }
backChannel?.also { oidcLogout.backChannel(backChannel) }
}
}
}
@@ -0,0 +1,550 @@
/*
* Copyright 2002-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.config.annotation.web.configurers.oauth2.client;
import java.io.IOException;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.interfaces.RSAPublicKey;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import com.gargoylesoftware.htmlunit.util.UrlUtils;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
import com.nimbusds.jose.jwk.source.JWKSource;
import com.nimbusds.jose.proc.SecurityContext;
import com.nimbusds.oauth2.sdk.Scope;
import com.nimbusds.oauth2.sdk.token.BearerAccessToken;
import com.nimbusds.openid.connect.sdk.token.OIDCTokens;
import jakarta.annotation.PreDestroy;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
import okhttp3.mockwebserver.Dispatcher;
import okhttp3.mockwebserver.MockResponse;
import okhttp3.mockwebserver.MockWebServer;
import okhttp3.mockwebserver.RecordedRequest;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.core.annotation.Order;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.mock.web.MockHttpSession;
import org.springframework.mock.web.MockServletContext;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.test.SpringTestContext;
import org.springframework.security.config.test.SpringTestContextExtension;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.oauth2.client.oidc.authentication.logout.LogoutTokenClaimNames;
import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcLogoutToken;
import org.springframework.security.oauth2.client.oidc.authentication.logout.TestOidcLogoutTokens;
import org.springframework.security.oauth2.client.oidc.session.InMemoryOidcSessionRegistry;
import org.springframework.security.oauth2.client.oidc.session.OidcSessionRegistry;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository;
import org.springframework.security.oauth2.client.registration.TestClientRegistrations;
import org.springframework.security.oauth2.core.oidc.OidcIdToken;
import org.springframework.security.oauth2.core.oidc.TestOidcIdTokens;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.logout.LogoutHandler;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import static org.hamcrest.Matchers.containsString;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.willThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.verify;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* Tests for {@link OidcLogoutConfigurer}
*/
@ExtendWith(SpringTestContextExtension.class)
public class OidcLogoutConfigurerTests {
@Autowired
private MockMvc mvc;
@Autowired(required = false)
private MockWebServer web;
@Autowired
private ClientRegistration clientRegistration;
public final SpringTestContext spring = new SpringTestContext(this);
@Test
void logoutWhenDefaultsThenRemotelyInvalidatesSessions() throws Exception {
this.spring.register(WebServerConfig.class, OidcProviderConfig.class, DefaultConfig.class).autowire();
String registrationId = this.clientRegistration.getRegistrationId();
MockHttpSession session = login();
String logoutToken = this.mvc.perform(get("/token/logout").session(session)).andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
this.mvc.perform(post(this.web.url("/logout/connect/back-channel/" + registrationId).toString())
.param("logout_token", logoutToken)).andExpect(status().isOk());
this.mvc.perform(get("/token/logout").session(session)).andExpect(status().isUnauthorized());
}
@Test
void logoutWhenInvalidLogoutTokenThenBadRequest() throws Exception {
this.spring.register(WebServerConfig.class, OidcProviderConfig.class, DefaultConfig.class).autowire();
this.mvc.perform(get("/token/logout")).andExpect(status().isUnauthorized());
String registrationId = this.clientRegistration.getRegistrationId();
MvcResult result = this.mvc.perform(get("/oauth2/authorization/" + registrationId))
.andExpect(status().isFound()).andReturn();
MockHttpSession session = (MockHttpSession) result.getRequest().getSession();
String redirectUrl = UrlUtils.decode(result.getResponse().getRedirectedUrl());
String state = this.mvc
.perform(get(redirectUrl).with(
httpBasic(this.clientRegistration.getClientId(), this.clientRegistration.getClientSecret())))
.andReturn().getResponse().getContentAsString();
result = this.mvc.perform(get("/login/oauth2/code/" + registrationId).param("code", "code")
.param("state", state).session(session)).andExpect(status().isFound()).andReturn();
session = (MockHttpSession) result.getRequest().getSession();
this.mvc.perform(post(this.web.url("/logout/connect/back-channel/" + registrationId).toString())
.param("logout_token", "invalid")).andExpect(status().isBadRequest());
this.mvc.perform(get("/token/logout").session(session)).andExpect(status().isOk());
}
@Test
void logoutWhenLogoutTokenSpecifiesOneSessionThenRemotelyInvalidatesOnlyThatSession() throws Exception {
this.spring.register(WebServerConfig.class, OidcProviderConfig.class, DefaultConfig.class).autowire();
String registrationId = this.clientRegistration.getRegistrationId();
MockHttpSession one = login();
MockHttpSession two = login();
MockHttpSession three = login();
String logoutToken = this.mvc.perform(get("/token/logout").session(one)).andExpect(status().isOk()).andReturn()
.getResponse().getContentAsString();
this.mvc.perform(post(this.web.url("/logout/connect/back-channel/" + registrationId).toString())
.param("logout_token", logoutToken)).andExpect(status().isOk());
this.mvc.perform(get("/token/logout").session(one)).andExpect(status().isUnauthorized());
this.mvc.perform(get("/token/logout").session(two)).andExpect(status().isOk());
logoutToken = this.mvc.perform(get("/token/logout/all").session(three)).andExpect(status().isOk()).andReturn()
.getResponse().getContentAsString();
this.mvc.perform(post(this.web.url("/logout/connect/back-channel/" + registrationId).toString())
.param("logout_token", logoutToken)).andExpect(status().isOk());
this.mvc.perform(get("/token/logout").session(two)).andExpect(status().isUnauthorized());
this.mvc.perform(get("/token/logout").session(three)).andExpect(status().isUnauthorized());
}
@Test
void logoutWhenRemoteLogoutFailsThenReportsPartialLogout() throws Exception {
this.spring.register(WebServerConfig.class, OidcProviderConfig.class, WithBrokenLogoutConfig.class).autowire();
LogoutHandler logoutHandler = this.spring.getContext().getBean(LogoutHandler.class);
willThrow(IllegalStateException.class).given(logoutHandler).logout(any(), any(), any());
String registrationId = this.clientRegistration.getRegistrationId();
MockHttpSession one = login();
String logoutToken = this.mvc.perform(get("/token/logout/all").session(one)).andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
this.mvc.perform(post(this.web.url("/logout/connect/back-channel/" + registrationId).toString())
.param("logout_token", logoutToken)).andExpect(status().isBadRequest())
.andExpect(content().string(containsString("partial_logout")));
this.mvc.perform(get("/token/logout").session(one)).andExpect(status().isOk());
}
@Test
void logoutWhenCustomComponentsThenUses() throws Exception {
this.spring.register(WebServerConfig.class, OidcProviderConfig.class, WithCustomComponentsConfig.class)
.autowire();
String registrationId = this.clientRegistration.getRegistrationId();
MockHttpSession session = login();
String logoutToken = this.mvc.perform(get("/token/logout").session(session)).andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
this.mvc.perform(post(this.web.url("/logout/connect/back-channel/" + registrationId).toString())
.param("logout_token", logoutToken)).andExpect(status().isOk());
this.mvc.perform(get("/token/logout").session(session)).andExpect(status().isUnauthorized());
OidcSessionRegistry sessionRegistry = this.spring.getContext().getBean(OidcSessionRegistry.class);
verify(sessionRegistry).saveSessionInformation(any());
verify(sessionRegistry).removeSessionInformation(any(OidcLogoutToken.class));
}
private MockHttpSession login() throws Exception {
MockMvcDispatcher dispatcher = (MockMvcDispatcher) this.web.getDispatcher();
this.mvc.perform(get("/token/logout")).andExpect(status().isUnauthorized());
String registrationId = this.clientRegistration.getRegistrationId();
MvcResult result = this.mvc.perform(get("/oauth2/authorization/" + registrationId))
.andExpect(status().isFound()).andReturn();
MockHttpSession session = (MockHttpSession) result.getRequest().getSession();
String redirectUrl = UrlUtils.decode(result.getResponse().getRedirectedUrl());
String state = this.mvc
.perform(get(redirectUrl).with(
httpBasic(this.clientRegistration.getClientId(), this.clientRegistration.getClientSecret())))
.andReturn().getResponse().getContentAsString();
result = this.mvc.perform(get("/login/oauth2/code/" + registrationId).param("code", "code")
.param("state", state).session(session)).andExpect(status().isFound()).andReturn();
session = (MockHttpSession) result.getRequest().getSession();
dispatcher.registerSession(session);
return session;
}
@Configuration
static class RegistrationConfig {
@Autowired(required = false)
MockWebServer web;
@Bean
ClientRegistration clientRegistration() {
if (this.web == null) {
return TestClientRegistrations.clientRegistration().build();
}
String issuer = this.web.url("/").toString();
return TestClientRegistrations.clientRegistration().issuerUri(issuer).jwkSetUri(issuer + "jwks")
.tokenUri(issuer + "token").userInfoUri(issuer + "user").scope("openid").build();
}
@Bean
ClientRegistrationRepository clientRegistrationRepository(ClientRegistration clientRegistration) {
return new InMemoryClientRegistrationRepository(clientRegistration);
}
}
@Configuration
@EnableWebSecurity
@Import(RegistrationConfig.class)
static class DefaultConfig {
@Bean
@Order(1)
SecurityFilterChain filters(HttpSecurity http) throws Exception {
// @formatter:off
http
.authorizeHttpRequests((authorize) -> authorize.anyRequest().authenticated())
.oauth2Login(Customizer.withDefaults())
.oidcLogout((oidc) -> oidc.backChannel(Customizer.withDefaults()));
// @formatter:on
return http.build();
}
}
@Configuration
@EnableWebSecurity
@Import(RegistrationConfig.class)
static class WithCustomComponentsConfig {
OidcSessionRegistry sessionRegistry = spy(new InMemoryOidcSessionRegistry());
@Bean
@Order(1)
SecurityFilterChain filters(HttpSecurity http) throws Exception {
// @formatter:off
http
.authorizeHttpRequests((authorize) -> authorize.anyRequest().authenticated())
.oauth2Login((oauth2) -> oauth2.oidcSessionRegistry(this.sessionRegistry))
.oidcLogout((oidc) -> oidc.backChannel(Customizer.withDefaults()));
// @formatter:on
return http.build();
}
@Bean
OidcSessionRegistry sessionRegistry() {
return this.sessionRegistry;
}
}
@Configuration
@EnableWebSecurity
@Import(RegistrationConfig.class)
static class WithBrokenLogoutConfig {
private final LogoutHandler logoutHandler = mock(LogoutHandler.class);
@Bean
@Order(1)
SecurityFilterChain filters(HttpSecurity http) throws Exception {
// @formatter:off
http
.authorizeHttpRequests((authorize) -> authorize.anyRequest().authenticated())
.logout((logout) -> logout.addLogoutHandler(this.logoutHandler))
.oauth2Login(Customizer.withDefaults())
.oidcLogout((oidc) -> oidc.backChannel(Customizer.withDefaults()));
// @formatter:on
return http.build();
}
@Bean
LogoutHandler logoutHandler() {
return this.logoutHandler;
}
}
@Configuration
@EnableWebSecurity
@EnableWebMvc
@RestController
static class OidcProviderConfig {
private static final RSAKey key = key();
private static final JWKSource<SecurityContext> jwks = jwks(key);
private static RSAKey key() {
try {
KeyPair pair = KeyPairGenerator.getInstance("RSA").generateKeyPair();
return new RSAKey.Builder((RSAPublicKey) pair.getPublic()).privateKey(pair.getPrivate()).build();
}
catch (Exception ex) {
throw new RuntimeException(ex);
}
}
private static JWKSource<SecurityContext> jwks(RSAKey key) {
try {
return new ImmutableJWKSet<>(new JWKSet(key));
}
catch (Exception ex) {
throw new RuntimeException(ex);
}
}
private final String username = "user";
private final JwtEncoder encoder = new NimbusJwtEncoder(jwks);
private String nonce;
@Autowired
ClientRegistration registration;
@Bean
@Order(0)
SecurityFilterChain authorizationServer(HttpSecurity http, ClientRegistration registration) throws Exception {
// @formatter:off
http
.securityMatcher("/jwks", "/login/oauth/authorize", "/nonce", "/token", "/token/logout", "/user")
.authorizeHttpRequests((authorize) -> authorize
.requestMatchers("/jwks").permitAll()
.anyRequest().authenticated()
)
.httpBasic(Customizer.withDefaults())
.oauth2ResourceServer((oauth2) -> oauth2
.jwt((jwt) -> jwt.jwkSetUri(registration.getProviderDetails().getJwkSetUri()))
);
// @formatter:off
return http.build();
}
@Bean
UserDetailsService users(ClientRegistration registration) {
return new InMemoryUserDetailsManager(User.withUsername(registration.getClientId())
.password("{noop}" + registration.getClientSecret()).authorities("APP").build());
}
@GetMapping("/login/oauth/authorize")
String nonce(@RequestParam("nonce") String nonce, @RequestParam("state") String state) {
this.nonce = nonce;
return state;
}
@PostMapping("/token")
Map<String, Object> accessToken(HttpServletRequest request) {
HttpSession session = request.getSession();
JwtEncoderParameters parameters = JwtEncoderParameters
.from(JwtClaimsSet.builder().id("id").subject(this.username)
.issuer(this.registration.getProviderDetails().getIssuerUri()).issuedAt(Instant.now())
.expiresAt(Instant.now().plusSeconds(86400)).claim("scope", "openid").build());
String token = this.encoder.encode(parameters).getTokenValue();
return new OIDCTokens(idToken(session.getId()), new BearerAccessToken(token, 86400, new Scope("openid")), null)
.toJSONObject();
}
String idToken(String sessionId) {
OidcIdToken token = TestOidcIdTokens.idToken().issuer(this.registration.getProviderDetails().getIssuerUri())
.subject(this.username).expiresAt(Instant.now().plusSeconds(86400))
.audience(List.of(this.registration.getClientId())).nonce(this.nonce)
.claim(LogoutTokenClaimNames.SID, sessionId).build();
JwtEncoderParameters parameters = JwtEncoderParameters
.from(JwtClaimsSet.builder().claims((claims) -> claims.putAll(token.getClaims())).build());
return this.encoder.encode(parameters).getTokenValue();
}
@GetMapping("/user")
Map<String, Object> userinfo() {
return Map.of("sub", this.username, "id", this.username);
}
@GetMapping("/jwks")
String jwks() {
return new JWKSet(key).toString();
}
@GetMapping("/token/logout")
String logoutToken(@AuthenticationPrincipal OidcUser user) {
OidcLogoutToken token = TestOidcLogoutTokens.withUser(user)
.audience(List.of(this.registration.getClientId())).build();
JwtEncoderParameters parameters = JwtEncoderParameters
.from(JwtClaimsSet.builder().claims((claims) -> claims.putAll(token.getClaims())).build());
return this.encoder.encode(parameters).getTokenValue();
}
@GetMapping("/token/logout/all")
String logoutTokenAll(@AuthenticationPrincipal OidcUser user) {
OidcLogoutToken token = TestOidcLogoutTokens.withUser(user)
.audience(List.of(this.registration.getClientId()))
.claims((claims) -> claims.remove(LogoutTokenClaimNames.SID)).build();
JwtEncoderParameters parameters = JwtEncoderParameters
.from(JwtClaimsSet.builder().claims((claims) -> claims.putAll(token.getClaims())).build());
return this.encoder.encode(parameters).getTokenValue();
}
}
@Configuration
static class WebServerConfig {
private final MockWebServer server = new MockWebServer();
@Bean
MockWebServer web(ObjectProvider<MockMvc> mvc) {
this.server.setDispatcher(new MockMvcDispatcher(mvc));
return this.server;
}
@PreDestroy
void shutdown() throws IOException {
this.server.shutdown();
}
}
private static class MockMvcDispatcher extends Dispatcher {
private final Map<String, MockHttpSession> session = new ConcurrentHashMap<>();
private final ObjectProvider<MockMvc> mvcProvider;
private MockMvc mvc;
MockMvcDispatcher(ObjectProvider<MockMvc> mvc) {
this.mvcProvider = mvc;
}
@Override
public MockResponse dispatch(RecordedRequest request) throws InterruptedException {
this.mvc = this.mvcProvider.getObject();
String method = request.getMethod();
String path = request.getPath();
String csrf = request.getHeader("X-CSRF-TOKEN");
MockHttpSession session = session(request);
MockHttpServletRequestBuilder builder;
if ("GET".equals(method)) {
builder = get(path);
}
else {
builder = post(path).content(request.getBody().readUtf8());
if (csrf != null) {
builder.header("X-CSRF-TOKEN", csrf);
}
else {
builder.with(csrf());
}
}
for (Map.Entry<String, List<String>> header : request.getHeaders().toMultimap().entrySet()) {
builder.header(header.getKey(), header.getValue().iterator().next());
}
try {
MockHttpServletResponse mvcResponse = this.mvc.perform(builder.session(session)).andReturn().getResponse();
return toMockResponse(mvcResponse);
}
catch (Exception ex) {
MockResponse response = new MockResponse();
response.setResponseCode(500);
return response;
}
}
void registerSession(MockHttpSession session) {
this.session.put(session.getId(), session);
}
private MockHttpSession session(RecordedRequest request) {
String cookieHeaderValue = request.getHeader("Cookie");
if (cookieHeaderValue == null) {
return new MockHttpSession();
}
String[] cookies = cookieHeaderValue.split(";");
for (String cookie : cookies) {
String[] parts = cookie.split("=");
if ("JSESSIONID".equals(parts[0])) {
return this.session.computeIfAbsent(parts[1],
(k) -> new MockHttpSession(new MockServletContext(), parts[1]));
}
}
return new MockHttpSession();
}
private MockResponse toMockResponse(MockHttpServletResponse mvcResponse) {
MockResponse response = new MockResponse();
response.setResponseCode(mvcResponse.getStatus());
for (String name : mvcResponse.getHeaderNames()) {
response.addHeader(name, mvcResponse.getHeaderValue(name));
}
response.setBody(getContentAsString(mvcResponse));
return response;
}
private String getContentAsString(MockHttpServletResponse response) {
try {
return response.getContentAsString();
}
catch (Exception ex) {
throw new RuntimeException(ex);
}
}
}
}
@@ -0,0 +1,595 @@
/*
* Copyright 2002-2021 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.config.web.server;
import java.io.IOException;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.interfaces.RSAPublicKey;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import com.gargoylesoftware.htmlunit.util.UrlUtils;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
import com.nimbusds.jose.jwk.source.JWKSource;
import com.nimbusds.jose.proc.SecurityContext;
import com.nimbusds.oauth2.sdk.Scope;
import com.nimbusds.oauth2.sdk.token.BearerAccessToken;
import com.nimbusds.openid.connect.sdk.token.OIDCTokens;
import jakarta.annotation.PreDestroy;
import okhttp3.mockwebserver.Dispatcher;
import okhttp3.mockwebserver.MockResponse;
import okhttp3.mockwebserver.MockWebServer;
import okhttp3.mockwebserver.RecordedRequest;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import reactor.core.publisher.Mono;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.core.annotation.Order;
import org.springframework.http.ResponseCookie;
import org.springframework.http.client.reactive.ClientHttpConnector;
import org.springframework.security.authentication.TestingAuthenticationToken;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
import org.springframework.security.config.test.SpringTestContext;
import org.springframework.security.config.test.SpringTestContextExtension;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.MapReactiveUserDetailsService;
import org.springframework.security.core.userdetails.ReactiveUserDetailsService;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.oauth2.client.oidc.authentication.logout.LogoutTokenClaimNames;
import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcLogoutToken;
import org.springframework.security.oauth2.client.oidc.authentication.logout.TestOidcLogoutTokens;
import org.springframework.security.oauth2.client.oidc.server.session.InMemoryReactiveOidcSessionRegistry;
import org.springframework.security.oauth2.client.oidc.server.session.ReactiveOidcSessionRegistry;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.client.registration.InMemoryReactiveClientRegistrationRepository;
import org.springframework.security.oauth2.client.registration.ReactiveClientRegistrationRepository;
import org.springframework.security.oauth2.client.registration.TestClientRegistrations;
import org.springframework.security.oauth2.core.oidc.OidcIdToken;
import org.springframework.security.oauth2.core.oidc.TestOidcIdTokens;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
import org.springframework.security.web.server.SecurityWebFilterChain;
import org.springframework.security.web.server.authentication.logout.ServerLogoutHandler;
import org.springframework.security.web.server.util.matcher.OrServerWebExchangeMatcher;
import org.springframework.security.web.server.util.matcher.PathPatternParserServerWebExchangeMatcher;
import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher;
import org.springframework.test.web.reactive.server.FluxExchangeResult;
import org.springframework.test.web.reactive.server.WebTestClient;
import org.springframework.test.web.reactive.server.WebTestClientConfigurer;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.context.ConfigurableWebApplicationContext;
import org.springframework.web.reactive.config.EnableWebFlux;
import org.springframework.web.reactive.function.BodyInserters;
import org.springframework.web.server.WebSession;
import org.springframework.web.server.adapter.WebHttpHandlerBuilder;
import static org.hamcrest.Matchers.containsString;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.verify;
import static org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers.csrf;
import static org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers.mockAuthentication;
import static org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers.springSecurity;
/**
* Tests for
* {@link ServerHttpSecurity.OAuth2ResourceServerSpec}
*/
@ExtendWith({ SpringTestContextExtension.class })
public class OidcLogoutSpecTests {
private static final String SESSION_COOKIE_NAME = "SESSION";
private WebTestClient test;
@Autowired(required = false)
private MockWebServer web;
@Autowired
private ClientRegistration clientRegistration;
public final SpringTestContext spring = new SpringTestContext(this);
@Autowired
public void setApplicationContext(ApplicationContext context) {
this.test = WebTestClient.bindToApplicationContext(context)
.apply(springSecurity())
.configureClient().responseTimeout(Duration.ofDays(1))
.build();
if (context instanceof ConfigurableWebApplicationContext configurable) {
configurable.getBeanFactory().registerResolvableDependency(WebTestClient.class, this.test);
}
}
@Test
void logoutWhenDefaultsThenRemotelyInvalidatesSessions() {
this.spring.register(WebServerConfig.class, OidcProviderConfig.class, DefaultConfig.class).autowire();
String registrationId = this.clientRegistration.getRegistrationId();
String session = login();
String logoutToken = this.test.mutateWith(session(session)).get().uri("/token/logout").exchange().expectStatus()
.isOk().returnResult(String.class).getResponseBody().blockFirst();
this.test.post().uri(this.web.url("/logout/connect/back-channel/" + registrationId).toString())
.body(BodyInserters.fromFormData("logout_token", logoutToken)).exchange().expectStatus().isOk();
this.test.mutateWith(session(session)).get().uri("/token/logout").exchange().expectStatus().isUnauthorized();
}
@Test
void logoutWhenInvalidLogoutTokenThenBadRequest() {
this.spring.register(WebServerConfig.class, OidcProviderConfig.class, DefaultConfig.class).autowire();
this.test.get().uri("/token/logout").exchange().expectStatus().isUnauthorized();
String registrationId = this.clientRegistration.getRegistrationId();
FluxExchangeResult<String> result = this.test.get().uri("/oauth2/authorization/" + registrationId).exchange()
.expectStatus().isFound().returnResult(String.class);
String session = sessionId(result);
String redirectUrl = UrlUtils.decode(result.getResponseHeaders().getLocation().toString());
String state = this.test
.mutateWith(mockAuthentication(new TestingAuthenticationToken(this.clientRegistration.getClientId(),
this.clientRegistration.getClientSecret(), "APP")))
.get().uri(redirectUrl).exchange().returnResult(String.class).getResponseBody().blockFirst();
result = this.test.get().uri("/login/oauth2/code/" + registrationId + "?code=code&state=" + state)
.cookie("SESSION", session).exchange().expectStatus().isFound().returnResult(String.class);
session = sessionId(result);
this.test.post().uri(this.web.url("/logout/connect/back-channel/" + registrationId).toString())
.body(BodyInserters.fromFormData("logout_token", "invalid")).exchange().expectStatus().isBadRequest();
this.test.get().uri("/token/logout").cookie("SESSION", session).exchange().expectStatus().isOk();
}
@Test
void logoutWhenLogoutTokenSpecifiesOneSessionThenRemotelyInvalidatesOnlyThatSession() throws Exception {
this.spring.register(WebServerConfig.class, OidcProviderConfig.class, DefaultConfig.class).autowire();
String registrationId = this.clientRegistration.getRegistrationId();
String one = login();
String two = login();
String three = login();
String logoutToken = this.test.get().uri("/token/logout").cookie("SESSION", one).exchange().expectStatus()
.isOk().returnResult(String.class).getResponseBody().blockFirst();
this.test.post().uri(this.web.url("/logout/connect/back-channel/" + registrationId).toString())
.body(BodyInserters.fromFormData("logout_token", logoutToken)).exchange().expectStatus().isOk();
this.test.get().uri("/token/logout").cookie("SESSION", one).exchange().expectStatus().isUnauthorized();
this.test.get().uri("/token/logout").cookie("SESSION", two).exchange().expectStatus().isOk();
logoutToken = this.test.get().uri("/token/logout/all").cookie("SESSION", three).exchange().expectStatus().isOk()
.returnResult(String.class).getResponseBody().blockFirst();
this.test.post().uri(this.web.url("/logout/connect/back-channel/" + registrationId).toString())
.body(BodyInserters.fromFormData("logout_token", logoutToken)).exchange().expectStatus().isOk();
this.test.get().uri("/token/logout").cookie("SESSION", two).exchange().expectStatus().isUnauthorized();
this.test.get().uri("/token/logout").cookie("SESSION", three).exchange().expectStatus().isUnauthorized();
}
@Test
void logoutWhenRemoteLogoutFailsThenReportsPartialLogout() {
this.spring.register(WebServerConfig.class, OidcProviderConfig.class, WithBrokenLogoutConfig.class).autowire();
ServerLogoutHandler logoutHandler = this.spring.getContext().getBean(ServerLogoutHandler.class);
given(logoutHandler.logout(any(), any())).willReturn(Mono.error(() -> new IllegalStateException("illegal")));
String registrationId = this.clientRegistration.getRegistrationId();
String one = login();
String logoutToken = this.test.get().uri("/token/logout/all").cookie("SESSION", one).exchange().expectStatus()
.isOk().returnResult(String.class).getResponseBody().blockFirst();
this.test.post().uri(this.web.url("/logout/connect/back-channel/" + registrationId).toString())
.body(BodyInserters.fromFormData("logout_token", logoutToken)).exchange().expectStatus().isBadRequest()
.expectBody(String.class).value(containsString("partial_logout"));
this.test.get().uri("/token/logout").cookie("SESSION", one).exchange().expectStatus().isOk();
}
@Test
void logoutWhenCustomComponentsThenUses() {
this.spring.register(WebServerConfig.class, OidcProviderConfig.class, WithCustomComponentsConfig.class)
.autowire();
String registrationId = this.clientRegistration.getRegistrationId();
String sessionId = login();
String logoutToken = this.test.get().uri("/token/logout").cookie("SESSION", sessionId).exchange().expectStatus()
.isOk().returnResult(String.class).getResponseBody().blockFirst();
this.test.post().uri(this.web.url("/logout/connect/back-channel/" + registrationId).toString())
.body(BodyInserters.fromFormData("logout_token", logoutToken)).exchange().expectStatus().isOk();
this.test.get().uri("/token/logout").cookie("SESSION", sessionId).exchange().expectStatus().isUnauthorized();
ReactiveOidcSessionRegistry sessionRegistry = this.spring.getContext()
.getBean(ReactiveOidcSessionRegistry.class);
verify(sessionRegistry, atLeastOnce()).saveSessionInformation(any());
verify(sessionRegistry, atLeastOnce()).removeSessionInformation(any(OidcLogoutToken.class));
}
private String login() {
this.test.get().uri("/token/logout").exchange().expectStatus().isUnauthorized();
String registrationId = this.clientRegistration.getRegistrationId();
FluxExchangeResult<String> result = this.test.get().uri("/oauth2/authorization/" + registrationId).exchange()
.expectStatus().isFound().returnResult(String.class);
String sessionId = sessionId(result);
String redirectUrl = UrlUtils.decode(result.getResponseHeaders().getLocation().toString());
result = this.test
.mutateWith(mockAuthentication(new TestingAuthenticationToken(this.clientRegistration.getClientId(),
this.clientRegistration.getClientSecret(), "APP")))
.get().uri(redirectUrl).exchange().returnResult(String.class);
String state = result.getResponseBody().blockFirst();
result = this.test.mutateWith(session(sessionId)).get()
.uri("/login/oauth2/code/" + registrationId + "?code=code&state=" + state).exchange().expectStatus()
.isFound().returnResult(String.class);
return sessionId(result);
}
private String sessionId(FluxExchangeResult<?> result) {
List<ResponseCookie> cookies = result.getResponseCookies().get(SESSION_COOKIE_NAME);
if (cookies == null || cookies.isEmpty()) {
return null;
}
return cookies.get(0).getValue();
}
static SessionMutator session(String session) {
return new SessionMutator(session);
}
private record SessionMutator(String session) implements WebTestClientConfigurer {
@Override
public void afterConfigurerAdded(WebTestClient.Builder builder, WebHttpHandlerBuilder httpHandlerBuilder,
ClientHttpConnector connector) {
builder.defaultCookie(SESSION_COOKIE_NAME, this.session);
}
}
@Configuration
static class RegistrationConfig {
@Autowired(required = false)
MockWebServer web;
@Bean
ClientRegistration clientRegistration() {
if (this.web == null) {
return TestClientRegistrations.clientRegistration().build();
}
String issuer = this.web.url("/").toString();
return TestClientRegistrations.clientRegistration().issuerUri(issuer).jwkSetUri(issuer + "jwks")
.tokenUri(issuer + "token").userInfoUri(issuer + "user").scope("openid").build();
}
@Bean
ReactiveClientRegistrationRepository clientRegistrationRepository(ClientRegistration clientRegistration) {
return new InMemoryReactiveClientRegistrationRepository(clientRegistration);
}
}
@Configuration
@EnableWebFluxSecurity
@Import(RegistrationConfig.class)
static class DefaultConfig {
@Bean
@Order(1)
SecurityWebFilterChain filters(ServerHttpSecurity http) throws Exception {
// @formatter:off
http
.authorizeExchange((authorize) -> authorize.anyExchange().authenticated())
.oauth2Login(Customizer.withDefaults())
.oidcLogout((oidc) -> oidc.backChannel(Customizer.withDefaults()));
// @formatter:on
return http.build();
}
}
@Configuration
@EnableWebFluxSecurity
@Import(RegistrationConfig.class)
static class WithCustomComponentsConfig {
ReactiveOidcSessionRegistry sessionRegistry = spy(new InMemoryReactiveOidcSessionRegistry());
@Bean
@Order(1)
SecurityWebFilterChain filters(ServerHttpSecurity http) throws Exception {
// @formatter:off
http
.authorizeExchange((authorize) -> authorize.anyExchange().authenticated())
.oauth2Login((oauth2) -> oauth2.oidcSessionRegistry(this.sessionRegistry))
.oidcLogout((oidc) -> oidc.backChannel(Customizer.withDefaults()));
// @formatter:on
return http.build();
}
@Bean
ReactiveOidcSessionRegistry sessionRegistry() {
return this.sessionRegistry;
}
}
@Configuration
@EnableWebFluxSecurity
@Import(RegistrationConfig.class)
static class WithBrokenLogoutConfig {
private final ServerLogoutHandler logoutHandler = mock(ServerLogoutHandler.class);
@Bean
@Order(1)
SecurityWebFilterChain filters(ServerHttpSecurity http) throws Exception {
// @formatter:off
http
.authorizeExchange((authorize) -> authorize.anyExchange().authenticated())
.logout((logout) -> logout.logoutHandler(this.logoutHandler))
.oauth2Login(Customizer.withDefaults())
.oidcLogout((oidc) -> oidc.backChannel(Customizer.withDefaults()));
// @formatter:on
return http.build();
}
@Bean
ServerLogoutHandler logoutHandler() {
return this.logoutHandler;
}
}
@Configuration
@EnableWebFluxSecurity
@EnableWebFlux
@RestController
static class OidcProviderConfig {
private static final RSAKey key = key();
private static final JWKSource<SecurityContext> jwks = jwks(key);
private static RSAKey key() {
try {
KeyPair pair = KeyPairGenerator.getInstance("RSA").generateKeyPair();
return new RSAKey.Builder((RSAPublicKey) pair.getPublic()).privateKey(pair.getPrivate()).build();
}
catch (Exception ex) {
throw new RuntimeException(ex);
}
}
private static JWKSource<SecurityContext> jwks(RSAKey key) {
try {
return new ImmutableJWKSet<>(new JWKSet(key));
}
catch (Exception ex) {
throw new RuntimeException(ex);
}
}
private final String username = "user";
private final JwtEncoder encoder = new NimbusJwtEncoder(jwks);
private String nonce;
@Autowired
ClientRegistration registration;
static ServerWebExchangeMatcher or(String... patterns) {
List<ServerWebExchangeMatcher> matchers = new ArrayList<>();
for (String pattern : patterns) {
matchers.add(new PathPatternParserServerWebExchangeMatcher(pattern));
}
return new OrServerWebExchangeMatcher(matchers);
}
@Bean
@Order(0)
SecurityWebFilterChain authorizationServer(ServerHttpSecurity http, ClientRegistration registration)
throws Exception {
// @formatter:off
http
.securityMatcher(or("/jwks", "/login/oauth/authorize", "/nonce", "/token", "/token/logout", "/user"))
.authorizeExchange((authorize) -> authorize
.pathMatchers("/jwks").permitAll()
.anyExchange().authenticated()
)
.httpBasic(Customizer.withDefaults())
.oauth2ResourceServer((oauth2) -> oauth2
.jwt((jwt) -> jwt.jwkSetUri(registration.getProviderDetails().getJwkSetUri()))
);
// @formatter:off
return http.build();
}
@Bean
ReactiveUserDetailsService users(ClientRegistration registration) {
return new MapReactiveUserDetailsService(User.withUsername(registration.getClientId())
.password("{noop}" + registration.getClientSecret()).authorities("APP").build());
}
@GetMapping("/login/oauth/authorize")
String nonce(@RequestParam("nonce") String nonce, @RequestParam("state") String state) {
this.nonce = nonce;
return state;
}
@PostMapping("/token")
Map<String, Object> accessToken(WebSession session) {
JwtEncoderParameters parameters = JwtEncoderParameters
.from(JwtClaimsSet.builder().id("id").subject(this.username)
.issuer(this.registration.getProviderDetails().getIssuerUri()).issuedAt(Instant.now())
.expiresAt(Instant.now().plusSeconds(86400)).claim("scope", "openid").build());
String token = this.encoder.encode(parameters).getTokenValue();
return new OIDCTokens(idToken(session.getId()), new BearerAccessToken(token, 86400, new Scope("openid")), null)
.toJSONObject();
}
String idToken(String sessionId) {
OidcIdToken token = TestOidcIdTokens.idToken().issuer(this.registration.getProviderDetails().getIssuerUri())
.subject(this.username).expiresAt(Instant.now().plusSeconds(86400))
.audience(List.of(this.registration.getClientId())).nonce(this.nonce)
.claim(LogoutTokenClaimNames.SID, sessionId).build();
JwtEncoderParameters parameters = JwtEncoderParameters
.from(JwtClaimsSet.builder().claims((claims) -> claims.putAll(token.getClaims())).build());
return this.encoder.encode(parameters).getTokenValue();
}
@GetMapping("/user")
Map<String, Object> userinfo() {
return Map.of("sub", this.username, "id", this.username);
}
@GetMapping("/jwks")
String jwks() {
return new JWKSet(key).toString();
}
@GetMapping("/token/logout")
String logoutToken(@AuthenticationPrincipal OidcUser user) {
OidcLogoutToken token = TestOidcLogoutTokens.withUser(user)
.audience(List.of(this.registration.getClientId())).build();
JwtEncoderParameters parameters = JwtEncoderParameters
.from(JwtClaimsSet.builder().claims((claims) -> claims.putAll(token.getClaims())).build());
return this.encoder.encode(parameters).getTokenValue();
}
@GetMapping("/token/logout/all")
String logoutTokenAll(@AuthenticationPrincipal OidcUser user) {
OidcLogoutToken token = TestOidcLogoutTokens.withUser(user)
.audience(List.of(this.registration.getClientId()))
.claims((claims) -> claims.remove(LogoutTokenClaimNames.SID)).build();
JwtEncoderParameters parameters = JwtEncoderParameters
.from(JwtClaimsSet.builder().claims((claims) -> claims.putAll(token.getClaims())).build());
return this.encoder.encode(parameters).getTokenValue();
}
}
@Configuration
static class WebServerConfig {
private final MockWebServer server = new MockWebServer();
@Bean
MockWebServer web(ObjectProvider<WebTestClient> web) {
this.server.setDispatcher(new WebTestClientDispatcher(web));
return this.server;
}
@PreDestroy
void shutdown() throws IOException {
this.server.shutdown();
}
}
private static class WebTestClientDispatcher extends Dispatcher {
private final ObjectProvider<WebTestClient> webProvider;
private WebTestClient web;
WebTestClientDispatcher(ObjectProvider<WebTestClient> web) {
this.webProvider = web;
}
@Override
public MockResponse dispatch(RecordedRequest request) throws InterruptedException {
this.web = this.webProvider.getObject();
String method = request.getMethod();
String path = request.getPath();
String csrf = request.getHeader("X-CSRF-TOKEN");
String sessionId = session(request);
WebTestClient.RequestHeadersSpec<?> r;
if ("GET".equals(method)) {
r = this.web.get().uri(path);
}
else {
WebTestClient.RequestBodySpec body;
if (csrf == null) {
body = this.web.mutateWith(csrf()).post().uri(path);
}
else {
body = this.web.post().uri(path).header("X-CSRF-TOKEN", csrf);
}
body.body(BodyInserters.fromValue(request.getBody().readUtf8()));
r = body;
}
for (Map.Entry<String, List<String>> header : request.getHeaders().toMultimap().entrySet()) {
if (header.getKey().equalsIgnoreCase("Cookie")) {
continue;
}
r.header(header.getKey(), header.getValue().iterator().next());
}
if (sessionId != null) {
r.cookie(SESSION_COOKIE_NAME, sessionId);
}
try {
FluxExchangeResult<String> result = r.exchange().returnResult(String.class);
return toMockResponse(result);
}
catch (Exception ex) {
MockResponse response = new MockResponse();
response.setResponseCode(500);
response.setBody(ex.getMessage());
return response;
}
}
private String session(RecordedRequest request) {
String cookieHeaderValue = request.getHeader("Cookie");
if (cookieHeaderValue == null) {
return null;
}
String[] cookies = cookieHeaderValue.split(";");
for (String cookie : cookies) {
String[] parts = cookie.split("=");
if (SESSION_COOKIE_NAME.equals(parts[0])) {
return parts[1];
}
}
return null;
}
private MockResponse toMockResponse(FluxExchangeResult<String> result) {
MockResponse response = new MockResponse();
response.setResponseCode(result.getStatus().value());
for (String name : result.getResponseHeaders().keySet()) {
response.addHeader(name, result.getResponseHeaders().getFirst(name));
}
String body = result.getResponseBody().blockFirst();
if (body != null) {
response.setBody(body);
}
return response;
}
}
}
@@ -717,7 +717,7 @@ public class ServerHttpSecurityTests {
private <T extends WebFilter> Optional<T> getWebFilter(SecurityWebFilterChain filterChain, Class<T> filterClass) {
return (Optional<T>) filterChain.getWebFilters().filter(Objects::nonNull)
.filter((filter) -> filter.getClass().isAssignableFrom(filterClass)).singleOrEmpty().blockOptional();
.filter((filter) -> filterClass.isAssignableFrom(filter.getClass())).singleOrEmpty().blockOptional();
}
private WebTestClient buildClient() {
@@ -0,0 +1,87 @@
/*
* Copyright 2002-2022 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.config.annotation.web
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.security.config.test.SpringTestContext
import org.springframework.security.config.test.SpringTestContextExtension
import org.springframework.security.oauth2.client.registration.ClientRegistration
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository
import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository
import org.springframework.security.oauth2.client.registration.TestClientRegistrations
import org.springframework.security.web.SecurityFilterChain
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.post
/**
* Tests for [OAuth2ClientDsl]
*
* @author Eleftheria Stein
*/
@ExtendWith(SpringTestContextExtension::class)
class OidcLogoutDslTests {
@JvmField
val spring = SpringTestContext(this)
@Autowired
lateinit var mockMvc: MockMvc
@Test
fun `oidcLogout when invalid token then errors`() {
this.spring.register(ClientRepositoryConfig::class.java).autowire()
val clientRegistration = this.spring.context.getBean(ClientRegistration::class.java)
this.mockMvc.post("/logout/connect/back-channel/" + clientRegistration.registrationId) {
param("logout_token", "token")
}.andExpect { status { isBadRequest() } }
}
@Configuration
@EnableWebSecurity
open class ClientRepositoryConfig {
@Bean
open fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
http {
oauth2Login { }
oidcLogout {
backChannel { }
}
authorizeHttpRequests {
authorize(anyRequest, authenticated)
}
}
return http.build()
}
@Bean
open fun clientRegistration(): ClientRegistration {
return TestClientRegistrations.clientRegistration().build()
}
@Bean
open fun clientRegistrationRepository(clientRegistration: ClientRegistration): ClientRegistrationRepository {
return InMemoryClientRegistrationRepository(clientRegistration)
}
}
}
@@ -0,0 +1,97 @@
/*
* Copyright 2002-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.config.web.server
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.context.ApplicationContext
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity
import org.springframework.security.config.test.SpringTestContext
import org.springframework.security.config.test.SpringTestContextExtension
import org.springframework.security.oauth2.client.registration.ClientRegistration
import org.springframework.security.oauth2.client.registration.InMemoryReactiveClientRegistrationRepository
import org.springframework.security.oauth2.client.registration.ReactiveClientRegistrationRepository
import org.springframework.security.oauth2.client.registration.TestClientRegistrations
import org.springframework.security.web.server.SecurityWebFilterChain
import org.springframework.test.web.reactive.server.WebTestClient
import org.springframework.web.reactive.config.EnableWebFlux
import org.springframework.web.reactive.function.BodyInserters
/**
* Tests for [ServerOidcLogoutDsl]
*
* @author Josh Cummings
*/
@ExtendWith(SpringTestContextExtension::class)
class ServerOidcLogoutDslTests {
@JvmField
val spring = SpringTestContext(this)
private lateinit var client: WebTestClient
@Autowired
fun setup(context: ApplicationContext) {
this.client = WebTestClient
.bindToApplicationContext(context)
.configureClient()
.build()
}
@Test
fun `oidcLogout when invalid token then errors`() {
this.spring.register(ClientRepositoryConfig::class.java).autowire()
val clientRegistration = this.spring.context.getBean(ClientRegistration::class.java)
this.client.post()
.uri("/logout/connect/back-channel/" + clientRegistration.registrationId)
.body(BodyInserters.fromFormData("logout_token", "token"))
.exchange()
.expectStatus().isBadRequest
}
@Configuration
@EnableWebFlux
@EnableWebFluxSecurity
open class ClientRepositoryConfig {
@Bean
open fun securityFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain {
return http {
oauth2Login { }
oidcLogout {
backChannel { }
}
authorizeExchange {
authorize(anyExchange, authenticated)
}
}
}
@Bean
open fun clientRegistration(): ClientRegistration {
return TestClientRegistrations.clientRegistration().build()
}
@Bean
open fun clientRegistrationRepository(clientRegistration: ClientRegistration): ReactiveClientRegistrationRepository {
return InMemoryReactiveClientRegistrationRepository(clientRegistration)
}
}
}