parent
1461c0f648
commit
cb33fd7850
|
@ -70,6 +70,7 @@ import org.springframework.security.config.annotation.web.configurers.SessionMan
|
|||
import org.springframework.security.config.annotation.web.configurers.X509Configurer;
|
||||
import org.springframework.security.config.annotation.web.configurers.oauth2.client.OAuth2ClientConfigurer;
|
||||
import org.springframework.security.config.annotation.web.configurers.oauth2.client.OAuth2LoginConfigurer;
|
||||
import org.springframework.security.config.annotation.web.configurers.oauth2.client.OidcLogoutConfigurer;
|
||||
import org.springframework.security.config.annotation.web.configurers.oauth2.server.resource.OAuth2ResourceServerConfigurer;
|
||||
import org.springframework.security.config.annotation.web.configurers.saml2.Saml2LoginConfigurer;
|
||||
import org.springframework.security.config.annotation.web.configurers.saml2.Saml2LogoutConfigurer;
|
||||
|
@ -2835,6 +2836,16 @@ public final class HttpSecurity extends AbstractConfiguredSecurityBuilder<Defaul
|
|||
return HttpSecurity.this;
|
||||
}
|
||||
|
||||
public OidcLogoutConfigurer<HttpSecurity> oidcLogout() throws Exception {
|
||||
return getOrApply(new OidcLogoutConfigurer<>());
|
||||
}
|
||||
|
||||
public HttpSecurity oidcLogout(Customizer<OidcLogoutConfigurer<HttpSecurity>> oidcLogoutCustomizer)
|
||||
throws Exception {
|
||||
oidcLogoutCustomizer.customize(getOrApply(new OidcLogoutConfigurer<>()));
|
||||
return HttpSecurity.this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures OAuth 2.0 Client support.
|
||||
* @return the {@link OAuth2ClientConfigurer} for further customizations
|
||||
|
|
|
@ -296,7 +296,7 @@ public final class SessionManagementConfigurer<H extends HttpSecurityBuilder<H>>
|
|||
* @param sessionAuthenticationStrategy
|
||||
* @return the {@link SessionManagementConfigurer} for further customizations
|
||||
*/
|
||||
SessionManagementConfigurer<H> addSessionAuthenticationStrategy(
|
||||
public SessionManagementConfigurer<H> addSessionAuthenticationStrategy(
|
||||
SessionAuthenticationStrategy sessionAuthenticationStrategy) {
|
||||
this.sessionAuthenticationStrategies.add(sessionAuthenticationStrategy);
|
||||
return this;
|
||||
|
|
|
@ -0,0 +1,35 @@
|
|||
/*
|
||||
* Copyright 2002-2023 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.security.config.annotation.web.configurers.oauth2.client;
|
||||
|
||||
import java.util.function.Function;
|
||||
|
||||
import org.springframework.security.oauth2.client.registration.ClientRegistration;
|
||||
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
|
||||
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.security.oauth2.jwt.JwtTimestampValidator;
|
||||
|
||||
final class DefaultOidcLogoutTokenValidatorFactory implements Function<ClientRegistration, OAuth2TokenValidator<Jwt>> {
|
||||
|
||||
@Override
|
||||
public OAuth2TokenValidator<Jwt> apply(ClientRegistration clientRegistration) {
|
||||
return new DelegatingOAuth2TokenValidator<>(new JwtTimestampValidator(),
|
||||
new OidcBackChannelLogoutTokenValidator(clientRegistration));
|
||||
}
|
||||
|
||||
}
|
|
@ -25,6 +25,8 @@ import org.springframework.security.config.annotation.web.HttpSecurityBuilder;
|
|||
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||
import org.springframework.security.oauth2.client.InMemoryOAuth2AuthorizedClientService;
|
||||
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService;
|
||||
import org.springframework.security.oauth2.client.oidc.session.InMemoryOidcSessionRegistry;
|
||||
import org.springframework.security.oauth2.client.oidc.session.OidcSessionRegistry;
|
||||
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
|
||||
import org.springframework.security.oauth2.client.web.AuthenticatedPrincipalOAuth2AuthorizedClientRepository;
|
||||
import org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepository;
|
||||
|
@ -112,4 +114,13 @@ final class OAuth2ClientConfigurerUtils {
|
|||
return (!authorizedClientServiceMap.isEmpty() ? authorizedClientServiceMap.values().iterator().next() : null);
|
||||
}
|
||||
|
||||
static <B extends HttpSecurityBuilder<B>> OidcSessionRegistry getOidcSessionRegistry(B builder) {
|
||||
OidcSessionRegistry sessionRegistry = builder.getSharedObject(OidcSessionRegistry.class);
|
||||
if (sessionRegistry == null) {
|
||||
sessionRegistry = new InMemoryOidcSessionRegistry();
|
||||
builder.setSharedObject(OidcSessionRegistry.class, sessionRegistry);
|
||||
}
|
||||
return sessionRegistry;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -22,9 +22,18 @@ import java.util.HashMap;
|
|||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import jakarta.servlet.http.HttpSession;
|
||||
import org.apache.commons.logging.Log;
|
||||
import org.apache.commons.logging.LogFactory;
|
||||
|
||||
import org.springframework.beans.factory.BeanFactoryUtils;
|
||||
import org.springframework.beans.factory.NoUniqueBeanDefinitionException;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.context.ApplicationListener;
|
||||
import org.springframework.context.event.GenericApplicationListenerAdapter;
|
||||
import org.springframework.context.event.SmartApplicationListener;
|
||||
import org.springframework.core.ResolvableType;
|
||||
import org.springframework.security.authentication.AuthenticationProvider;
|
||||
import org.springframework.security.config.Customizer;
|
||||
|
@ -32,9 +41,14 @@ import org.springframework.security.config.annotation.web.HttpSecurityBuilder;
|
|||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configurers.AbstractAuthenticationFilterConfigurer;
|
||||
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||
import org.springframework.security.config.annotation.web.configurers.SessionManagementConfigurer;
|
||||
import org.springframework.security.context.DelegatingApplicationListener;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper;
|
||||
import org.springframework.security.core.session.AbstractSessionEvent;
|
||||
import org.springframework.security.core.session.SessionDestroyedEvent;
|
||||
import org.springframework.security.core.session.SessionIdChangedEvent;
|
||||
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService;
|
||||
import org.springframework.security.oauth2.client.authentication.OAuth2LoginAuthenticationProvider;
|
||||
import org.springframework.security.oauth2.client.authentication.OAuth2LoginAuthenticationToken;
|
||||
|
@ -42,6 +56,9 @@ import org.springframework.security.oauth2.client.endpoint.DefaultAuthorizationC
|
|||
import org.springframework.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient;
|
||||
import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest;
|
||||
import org.springframework.security.oauth2.client.oidc.authentication.OidcAuthorizationCodeAuthenticationProvider;
|
||||
import org.springframework.security.oauth2.client.oidc.session.InMemoryOidcSessionRegistry;
|
||||
import org.springframework.security.oauth2.client.oidc.session.OidcSessionInformation;
|
||||
import org.springframework.security.oauth2.client.oidc.session.OidcSessionRegistry;
|
||||
import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest;
|
||||
import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserService;
|
||||
import org.springframework.security.oauth2.client.registration.ClientRegistration;
|
||||
|
@ -67,7 +84,10 @@ import org.springframework.security.web.AuthenticationEntryPoint;
|
|||
import org.springframework.security.web.RedirectStrategy;
|
||||
import org.springframework.security.web.authentication.DelegatingAuthenticationEntryPoint;
|
||||
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
|
||||
import org.springframework.security.web.authentication.session.SessionAuthenticationException;
|
||||
import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy;
|
||||
import org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter;
|
||||
import org.springframework.security.web.csrf.CsrfToken;
|
||||
import org.springframework.security.web.savedrequest.RequestCache;
|
||||
import org.springframework.security.web.util.matcher.AndRequestMatcher;
|
||||
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
|
||||
|
@ -124,6 +144,7 @@ import org.springframework.util.ReflectionUtils;
|
|||
* <li>{@link DefaultLoginPageGeneratingFilter} - if {@link #loginPage(String)} is not
|
||||
* configured and {@code DefaultLoginPageGeneratingFilter} is available, then a default
|
||||
* login page will be made available</li>
|
||||
* <li>{@link OidcSessionRegistry}</li>
|
||||
* </ul>
|
||||
*
|
||||
* @author Joe Grandja
|
||||
|
@ -202,6 +223,18 @@ public final class OAuth2LoginConfigurer<B extends HttpSecurityBuilder<B>>
|
|||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the registry for managing the OIDC client-provider session link
|
||||
* @param oidcSessionRegistry the {@link OidcSessionRegistry} to use
|
||||
* @return the {@link OAuth2LoginConfigurer} for further configuration
|
||||
* @since 6.2
|
||||
*/
|
||||
public OAuth2LoginConfigurer<B> oidcSessionRegistry(OidcSessionRegistry oidcSessionRegistry) {
|
||||
Assert.notNull(oidcSessionRegistry, "oidcSessionRegistry cannot be null");
|
||||
getBuilder().setSharedObject(OidcSessionRegistry.class, oidcSessionRegistry);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the {@link AuthorizationEndpointConfig} for configuring the Authorization
|
||||
* Server's Authorization Endpoint.
|
||||
|
@ -397,6 +430,7 @@ public final class OAuth2LoginConfigurer<B extends HttpSecurityBuilder<B>>
|
|||
authenticationFilter
|
||||
.setAuthorizationRequestRepository(this.authorizationEndpointConfig.authorizationRequestRepository);
|
||||
}
|
||||
configureOidcSessionRegistry(http);
|
||||
super.configure(http);
|
||||
}
|
||||
|
||||
|
@ -546,6 +580,29 @@ public final class OAuth2LoginConfigurer<B extends HttpSecurityBuilder<B>>
|
|||
return AnyRequestMatcher.INSTANCE;
|
||||
}
|
||||
|
||||
private void configureOidcSessionRegistry(B http) {
|
||||
OidcSessionRegistry sessionRegistry = OAuth2ClientConfigurerUtils.getOidcSessionRegistry(http);
|
||||
SessionManagementConfigurer<B> sessionConfigurer = http.getConfigurer(SessionManagementConfigurer.class);
|
||||
if (sessionConfigurer != null) {
|
||||
OidcSessionRegistryAuthenticationStrategy sessionAuthenticationStrategy = new OidcSessionRegistryAuthenticationStrategy();
|
||||
sessionAuthenticationStrategy.setSessionRegistry(sessionRegistry);
|
||||
sessionConfigurer.addSessionAuthenticationStrategy(sessionAuthenticationStrategy);
|
||||
}
|
||||
OidcClientSessionEventListener listener = new OidcClientSessionEventListener();
|
||||
listener.setSessionRegistry(sessionRegistry);
|
||||
registerDelegateApplicationListener(listener);
|
||||
}
|
||||
|
||||
private void registerDelegateApplicationListener(ApplicationListener<?> delegate) {
|
||||
DelegatingApplicationListener delegating = getBeanOrNull(
|
||||
ResolvableType.forType(DelegatingApplicationListener.class));
|
||||
if (delegating == null) {
|
||||
return;
|
||||
}
|
||||
SmartApplicationListener smartListener = new GenericApplicationListenerAdapter(delegate);
|
||||
delegating.addListener(smartListener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration options for the Authorization Server's Authorization Endpoint.
|
||||
*/
|
||||
|
@ -793,4 +850,83 @@ public final class OAuth2LoginConfigurer<B extends HttpSecurityBuilder<B>>
|
|||
|
||||
}
|
||||
|
||||
private static final class OidcClientSessionEventListener implements ApplicationListener<AbstractSessionEvent> {
|
||||
|
||||
private final Log logger = LogFactory.getLog(OidcClientSessionEventListener.class);
|
||||
|
||||
private OidcSessionRegistry sessionRegistry = new InMemoryOidcSessionRegistry();
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
public void onApplicationEvent(AbstractSessionEvent event) {
|
||||
if (event instanceof SessionDestroyedEvent destroyed) {
|
||||
this.logger.debug("Received SessionDestroyedEvent");
|
||||
this.sessionRegistry.removeSessionInformation(destroyed.getId());
|
||||
return;
|
||||
}
|
||||
if (event instanceof SessionIdChangedEvent changed) {
|
||||
this.logger.debug("Received SessionIdChangedEvent");
|
||||
OidcSessionInformation information = this.sessionRegistry.removeSessionInformation(changed.getOldSessionId());
|
||||
if (information == null) {
|
||||
this.logger.debug("Failed to register new session id since old session id was not found in registry");
|
||||
return;
|
||||
}
|
||||
this.sessionRegistry.saveSessionInformation(information.withSessionId(changed.getNewSessionId()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The registry where OIDC Provider sessions are linked to the Client session.
|
||||
* Defaults to in-memory storage.
|
||||
* @param sessionRegistry the {@link OidcSessionRegistry} to use
|
||||
*/
|
||||
void setSessionRegistry(OidcSessionRegistry sessionRegistry) {
|
||||
Assert.notNull(sessionRegistry, "sessionRegistry cannot be null");
|
||||
this.sessionRegistry = sessionRegistry;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private static final class OidcSessionRegistryAuthenticationStrategy implements SessionAuthenticationStrategy {
|
||||
|
||||
private final Log logger = LogFactory.getLog(getClass());
|
||||
|
||||
private OidcSessionRegistry sessionRegistry = new InMemoryOidcSessionRegistry();
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
public void onAuthentication(Authentication authentication, HttpServletRequest request, HttpServletResponse response) throws SessionAuthenticationException {
|
||||
HttpSession session = request.getSession(false);
|
||||
if (session == null) {
|
||||
return;
|
||||
}
|
||||
if (!(authentication.getPrincipal() instanceof OidcUser user)) {
|
||||
return;
|
||||
}
|
||||
String sessionId = session.getId();
|
||||
CsrfToken csrfToken = (CsrfToken) request.getAttribute(CsrfToken.class.getName());
|
||||
Map<String, String> headers = (csrfToken != null) ? Map.of(csrfToken.getHeaderName(), csrfToken.getToken()) : Collections.emptyMap();
|
||||
OidcSessionInformation registration = new OidcSessionInformation(sessionId, headers, user);
|
||||
if (this.logger.isTraceEnabled()) {
|
||||
this.logger.trace(String.format("Linking a provider [%s] session to this client's session", user.getIssuer()));
|
||||
}
|
||||
this.sessionRegistry.saveSessionInformation(registration);
|
||||
}
|
||||
|
||||
/**
|
||||
* The registration for linking OIDC Provider Session information to the Client's
|
||||
* session. Defaults to in-memory storage.
|
||||
* @param sessionRegistry the {@link OidcSessionRegistry} to use
|
||||
*/
|
||||
void setSessionRegistry(OidcSessionRegistry sessionRegistry) {
|
||||
Assert.notNull(sessionRegistry, "sessionRegistry cannot be null");
|
||||
this.sessionRegistry = sessionRegistry;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -0,0 +1,66 @@
|
|||
/*
|
||||
* Copyright 2002-2023 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.security.config.annotation.web.configurers.oauth2.client;
|
||||
|
||||
import java.util.Collections;
|
||||
|
||||
import org.springframework.security.authentication.AbstractAuthenticationToken;
|
||||
import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcLogoutToken;
|
||||
|
||||
/**
|
||||
* An {@link org.springframework.security.core.Authentication} implementation that
|
||||
* represents the result of authenticating an OIDC Logout token for the purposes of
|
||||
* performing Back-Channel Logout.
|
||||
*
|
||||
* @author Josh Cummings
|
||||
* @since 6.2
|
||||
* @see OidcLogoutAuthenticationToken
|
||||
* @see <a target="_blank" href=
|
||||
* "https://openid.net/specs/openid-connect-backchannel-1_0.html">OIDC Back-Channel
|
||||
* Logout</a>
|
||||
*/
|
||||
class OidcBackChannelLogoutAuthentication extends AbstractAuthenticationToken {
|
||||
|
||||
private final OidcLogoutToken logoutToken;
|
||||
|
||||
/**
|
||||
* Construct an {@link OidcBackChannelLogoutAuthentication}
|
||||
* @param logoutToken a deserialized, verified OIDC Logout Token
|
||||
*/
|
||||
OidcBackChannelLogoutAuthentication(OidcLogoutToken logoutToken) {
|
||||
super(Collections.emptyList());
|
||||
this.logoutToken = logoutToken;
|
||||
setAuthenticated(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
public OidcLogoutToken getPrincipal() {
|
||||
return this.logoutToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
public OidcLogoutToken getCredentials() {
|
||||
return this.logoutToken;
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,113 @@
|
|||
/*
|
||||
* Copyright 2002-2023 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.security.config.annotation.web.configurers.oauth2.client;
|
||||
|
||||
import org.springframework.security.authentication.AuthenticationProvider;
|
||||
import org.springframework.security.authentication.AuthenticationServiceException;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
import org.springframework.security.oauth2.client.oidc.authentication.OidcIdTokenDecoderFactory;
|
||||
import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcLogoutToken;
|
||||
import org.springframework.security.oauth2.client.registration.ClientRegistration;
|
||||
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
|
||||
import org.springframework.security.oauth2.core.OAuth2Error;
|
||||
import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
|
||||
import org.springframework.security.oauth2.jwt.BadJwtException;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.security.oauth2.jwt.JwtDecoder;
|
||||
import org.springframework.security.oauth2.jwt.JwtDecoderFactory;
|
||||
import org.springframework.util.Assert;
|
||||
|
||||
/**
|
||||
* An {@link AuthenticationProvider} that authenticates an OIDC Logout Token; namely
|
||||
* deserializing it, verifying its signature, and validating its claims.
|
||||
*
|
||||
* <p>
|
||||
* Intended to be included in a
|
||||
* {@link org.springframework.security.authentication.ProviderManager}
|
||||
*
|
||||
* @author Josh Cummings
|
||||
* @since 6.2
|
||||
* @see OidcLogoutAuthenticationToken
|
||||
* @see org.springframework.security.authentication.ProviderManager
|
||||
* @see <a target="_blank" href=
|
||||
* "https://openid.net/specs/openid-connect-backchannel-1_0.html">OIDC Back-Channel
|
||||
* Logout</a>
|
||||
*/
|
||||
final class OidcBackChannelLogoutAuthenticationProvider implements AuthenticationProvider {
|
||||
|
||||
private JwtDecoderFactory<ClientRegistration> logoutTokenDecoderFactory;
|
||||
|
||||
/**
|
||||
* Construct an {@link OidcBackChannelLogoutAuthenticationProvider}
|
||||
*/
|
||||
OidcBackChannelLogoutAuthenticationProvider() {
|
||||
OidcIdTokenDecoderFactory logoutTokenDecoderFactory = new OidcIdTokenDecoderFactory();
|
||||
logoutTokenDecoderFactory.setJwtValidatorFactory(new DefaultOidcLogoutTokenValidatorFactory());
|
||||
this.logoutTokenDecoderFactory = logoutTokenDecoderFactory;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
|
||||
if (!(authentication instanceof OidcLogoutAuthenticationToken token)) {
|
||||
return null;
|
||||
}
|
||||
String logoutToken = token.getLogoutToken();
|
||||
ClientRegistration registration = token.getClientRegistration();
|
||||
Jwt jwt = decode(registration, logoutToken);
|
||||
OidcLogoutToken oidcLogoutToken = OidcLogoutToken.withTokenValue(logoutToken)
|
||||
.claims((claims) -> claims.putAll(jwt.getClaims())).build();
|
||||
return new OidcBackChannelLogoutAuthentication(oidcLogoutToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
public boolean supports(Class<?> authentication) {
|
||||
return OidcLogoutAuthenticationToken.class.isAssignableFrom(authentication);
|
||||
}
|
||||
|
||||
private Jwt decode(ClientRegistration registration, String token) {
|
||||
JwtDecoder logoutTokenDecoder = this.logoutTokenDecoderFactory.createDecoder(registration);
|
||||
try {
|
||||
return logoutTokenDecoder.decode(token);
|
||||
}
|
||||
catch (BadJwtException failed) {
|
||||
OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.INVALID_REQUEST, failed.getMessage(),
|
||||
"https://openid.net/specs/openid-connect-backchannel-1_0.html#Validation");
|
||||
throw new OAuth2AuthenticationException(error, failed);
|
||||
}
|
||||
catch (Exception failed) {
|
||||
throw new AuthenticationServiceException(failed.getMessage(), failed);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Use this {@link JwtDecoderFactory} to generate {@link JwtDecoder}s that correspond
|
||||
* to the {@link ClientRegistration} associated with the OIDC logout token.
|
||||
* @param logoutTokenDecoderFactory the {@link JwtDecoderFactory} to use
|
||||
*/
|
||||
void setLogoutTokenDecoderFactory(JwtDecoderFactory<ClientRegistration> logoutTokenDecoderFactory) {
|
||||
Assert.notNull(logoutTokenDecoderFactory, "logoutTokenDecoderFactory cannot be null");
|
||||
this.logoutTokenDecoderFactory = logoutTokenDecoderFactory;
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,139 @@
|
|||
/*
|
||||
* Copyright 2002-2023 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.security.config.annotation.web.configurers.oauth2.client;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.apache.commons.logging.Log;
|
||||
import org.apache.commons.logging.LogFactory;
|
||||
|
||||
import org.springframework.http.server.ServletServerHttpResponse;
|
||||
import org.springframework.security.authentication.AuthenticationManager;
|
||||
import org.springframework.security.authentication.AuthenticationServiceException;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
|
||||
import org.springframework.security.oauth2.core.OAuth2Error;
|
||||
import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
|
||||
import org.springframework.security.oauth2.core.http.converter.OAuth2ErrorHttpMessageConverter;
|
||||
import org.springframework.security.web.authentication.AuthenticationConverter;
|
||||
import org.springframework.security.web.authentication.logout.LogoutHandler;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
/**
|
||||
* A filter for the Client-side OIDC Back-Channel Logout endpoint
|
||||
*
|
||||
* @author Josh Cummings
|
||||
* @since 6.2
|
||||
* @see <a target="_blank" href=
|
||||
* "https://openid.net/specs/openid-connect-backchannel-1_0.html">OIDC Back-Channel Logout
|
||||
* Spec</a>
|
||||
*/
|
||||
class OidcBackChannelLogoutFilter extends OncePerRequestFilter {
|
||||
|
||||
private final Log logger = LogFactory.getLog(getClass());
|
||||
|
||||
private final AuthenticationConverter authenticationConverter;
|
||||
|
||||
private final AuthenticationManager authenticationManager;
|
||||
|
||||
private final OAuth2ErrorHttpMessageConverter errorHttpMessageConverter = new OAuth2ErrorHttpMessageConverter();
|
||||
|
||||
private LogoutHandler logoutHandler = new OidcBackChannelLogoutHandler();
|
||||
|
||||
/**
|
||||
* Construct an {@link OidcBackChannelLogoutFilter}
|
||||
* @param authenticationConverter the {@link AuthenticationConverter} for deriving
|
||||
* Logout Token authentication
|
||||
* @param authenticationManager the {@link AuthenticationManager} for authenticating
|
||||
* Logout Tokens
|
||||
*/
|
||||
OidcBackChannelLogoutFilter(AuthenticationConverter authenticationConverter,
|
||||
AuthenticationManager authenticationManager) {
|
||||
Assert.notNull(authenticationConverter, "authenticationConverter cannot be null");
|
||||
Assert.notNull(authenticationManager, "authenticationManager cannot be null");
|
||||
this.authenticationConverter = authenticationConverter;
|
||||
this.authenticationManager = authenticationManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
|
||||
throws ServletException, IOException {
|
||||
Authentication token;
|
||||
try {
|
||||
token = this.authenticationConverter.convert(request);
|
||||
}
|
||||
catch (AuthenticationServiceException ex) {
|
||||
this.logger.debug("Failed to process OIDC Back-Channel Logout", ex);
|
||||
throw ex;
|
||||
}
|
||||
catch (AuthenticationException ex) {
|
||||
handleAuthenticationFailure(response, ex);
|
||||
return;
|
||||
}
|
||||
if (token == null) {
|
||||
chain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
Authentication authentication;
|
||||
try {
|
||||
authentication = this.authenticationManager.authenticate(token);
|
||||
}
|
||||
catch (AuthenticationServiceException ex) {
|
||||
this.logger.debug("Failed to process OIDC Back-Channel Logout", ex);
|
||||
throw ex;
|
||||
}
|
||||
catch (AuthenticationException ex) {
|
||||
handleAuthenticationFailure(response, ex);
|
||||
return;
|
||||
}
|
||||
this.logoutHandler.logout(request, response, authentication);
|
||||
}
|
||||
|
||||
private void handleAuthenticationFailure(HttpServletResponse response, Exception ex) throws IOException {
|
||||
this.logger.debug("Failed to process OIDC Back-Channel Logout", ex);
|
||||
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
|
||||
this.errorHttpMessageConverter.write(oauth2Error(ex), null, new ServletServerHttpResponse(response));
|
||||
}
|
||||
|
||||
private OAuth2Error oauth2Error(Exception ex) {
|
||||
if (ex instanceof OAuth2AuthenticationException oauth2) {
|
||||
return oauth2.getError();
|
||||
}
|
||||
return new OAuth2Error(OAuth2ErrorCodes.INVALID_REQUEST, ex.getMessage(),
|
||||
"https://openid.net/specs/openid-connect-backchannel-1_0.html#Validation");
|
||||
}
|
||||
|
||||
/**
|
||||
* The strategy for expiring all Client sessions indicated by the logout request.
|
||||
* Defaults to {@link OidcBackChannelLogoutHandler}.
|
||||
* @param logoutHandler the {@link LogoutHandler} to use
|
||||
*/
|
||||
void setLogoutHandler(LogoutHandler logoutHandler) {
|
||||
Assert.notNull(logoutHandler, "logoutHandler cannot be null");
|
||||
this.logoutHandler = logoutHandler;
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,175 @@
|
|||
/*
|
||||
* Copyright 2002-2023 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.security.config.annotation.web.configurers.oauth2.client;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Map;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.apache.commons.logging.Log;
|
||||
import org.apache.commons.logging.LogFactory;
|
||||
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.server.ServletServerHttpResponse;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcLogoutToken;
|
||||
import org.springframework.security.oauth2.client.oidc.session.InMemoryOidcSessionRegistry;
|
||||
import org.springframework.security.oauth2.client.oidc.session.OidcSessionInformation;
|
||||
import org.springframework.security.oauth2.client.oidc.session.OidcSessionRegistry;
|
||||
import org.springframework.security.oauth2.core.OAuth2Error;
|
||||
import org.springframework.security.oauth2.core.http.converter.OAuth2ErrorHttpMessageConverter;
|
||||
import org.springframework.security.web.authentication.logout.LogoutHandler;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.web.client.RestClientException;
|
||||
import org.springframework.web.client.RestOperations;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
import org.springframework.web.util.UriComponentsBuilder;
|
||||
|
||||
/**
|
||||
* A {@link LogoutHandler} that locates the sessions associated with a given OIDC
|
||||
* Back-Channel Logout Token and invalidates each one.
|
||||
*
|
||||
* @author Josh Cummings
|
||||
* @since 6.2
|
||||
* @see <a target="_blank" href=
|
||||
* "https://openid.net/specs/openid-connect-backchannel-1_0.html">OIDC Back-Channel Logout
|
||||
* Spec</a>
|
||||
*/
|
||||
final class OidcBackChannelLogoutHandler implements LogoutHandler {
|
||||
|
||||
private final Log logger = LogFactory.getLog(getClass());
|
||||
|
||||
private OidcSessionRegistry sessionRegistry = new InMemoryOidcSessionRegistry();
|
||||
|
||||
private RestOperations restOperations = new RestTemplate();
|
||||
|
||||
private String logoutEndpointName = "/logout";
|
||||
|
||||
private String sessionCookieName = "JSESSIONID";
|
||||
|
||||
private final OAuth2ErrorHttpMessageConverter errorHttpMessageConverter = new OAuth2ErrorHttpMessageConverter();
|
||||
|
||||
@Override
|
||||
public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
|
||||
if (!(authentication instanceof OidcBackChannelLogoutAuthentication token)) {
|
||||
if (this.logger.isDebugEnabled()) {
|
||||
String message = "Did not perform OIDC Back-Channel Logout since authentication [%s] was of the wrong type";
|
||||
this.logger.debug(String.format(message, authentication.getClass().getSimpleName()));
|
||||
}
|
||||
return;
|
||||
}
|
||||
Iterable<OidcSessionInformation> sessions = this.sessionRegistry.removeSessionInformation(token.getPrincipal());
|
||||
Collection<String> errors = new ArrayList<>();
|
||||
int totalCount = 0;
|
||||
int invalidatedCount = 0;
|
||||
for (OidcSessionInformation session : sessions) {
|
||||
totalCount++;
|
||||
try {
|
||||
eachLogout(request, session);
|
||||
invalidatedCount++;
|
||||
}
|
||||
catch (RestClientException ex) {
|
||||
this.logger.debug("Failed to invalidate session", ex);
|
||||
errors.add(ex.getMessage());
|
||||
this.sessionRegistry.saveSessionInformation(session);
|
||||
}
|
||||
}
|
||||
if (this.logger.isTraceEnabled()) {
|
||||
this.logger.trace(String.format("Invalidated %d out of %d sessions", invalidatedCount, totalCount));
|
||||
}
|
||||
if (!errors.isEmpty()) {
|
||||
handleLogoutFailure(response, oauth2Error(errors));
|
||||
}
|
||||
}
|
||||
|
||||
private void eachLogout(HttpServletRequest request, OidcSessionInformation session) {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.add(HttpHeaders.COOKIE, this.sessionCookieName + "=" + session.getSessionId());
|
||||
for (Map.Entry<String, String> credential : session.getAuthorities().entrySet()) {
|
||||
headers.add(credential.getKey(), credential.getValue());
|
||||
}
|
||||
String url = request.getRequestURL().toString();
|
||||
String logout = UriComponentsBuilder.fromHttpUrl(url).replacePath(this.logoutEndpointName).build()
|
||||
.toUriString();
|
||||
HttpEntity<?> entity = new HttpEntity<>(null, headers);
|
||||
this.restOperations.postForEntity(logout, entity, Object.class);
|
||||
}
|
||||
|
||||
private OAuth2Error oauth2Error(Collection<String> errors) {
|
||||
return new OAuth2Error("partial_logout", "not all sessions were terminated: " + errors,
|
||||
"https://openid.net/specs/openid-connect-backchannel-1_0.html#Validation");
|
||||
}
|
||||
|
||||
private void handleLogoutFailure(HttpServletResponse response, OAuth2Error error) {
|
||||
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
|
||||
try {
|
||||
this.errorHttpMessageConverter.write(error, null, new ServletServerHttpResponse(response));
|
||||
}
|
||||
catch (IOException ex) {
|
||||
throw new IllegalStateException(ex);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Use this {@link OidcSessionRegistry} to identify sessions to invalidate. Note that
|
||||
* this class uses
|
||||
* {@link OidcSessionRegistry#removeSessionInformation(OidcLogoutToken)} to identify
|
||||
* sessions.
|
||||
* @param sessionRegistry the {@link OidcSessionRegistry} to use
|
||||
*/
|
||||
void setSessionRegistry(OidcSessionRegistry sessionRegistry) {
|
||||
Assert.notNull(sessionRegistry, "sessionRegistry cannot be null");
|
||||
this.sessionRegistry = sessionRegistry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Use this {@link RestOperations} to perform the per-session back-channel logout
|
||||
* @param restOperations the {@link RestOperations} to use
|
||||
*/
|
||||
void setRestOperations(RestOperations restOperations) {
|
||||
Assert.notNull(restOperations, "restOperations cannot be null");
|
||||
this.restOperations = restOperations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Use this logout URI for performing per-session logout. Defaults to {@code /logout}
|
||||
* since that is the default URI for
|
||||
* {@link org.springframework.security.web.authentication.logout.LogoutFilter}.
|
||||
* @param logoutUri the URI to use
|
||||
*/
|
||||
void setLogoutUri(String logoutUri) {
|
||||
Assert.hasText(logoutUri, "logoutUri cannot be empty");
|
||||
this.logoutEndpointName = logoutUri;
|
||||
}
|
||||
|
||||
/**
|
||||
* Use this cookie name for the session identifier. Defaults to {@code JSESSIONID}.
|
||||
*
|
||||
* <p>
|
||||
* Note that if you are using Spring Session, this likely needs to change to SESSION.
|
||||
* @param sessionCookieName the cookie name to use
|
||||
*/
|
||||
void setSessionCookieName(String sessionCookieName) {
|
||||
Assert.hasText(sessionCookieName, "clientSessionCookieName cannot be empty");
|
||||
this.sessionCookieName = sessionCookieName;
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,118 @@
|
|||
/*
|
||||
* Copyright 2002-2023 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.security.config.annotation.web.configurers.oauth2.client;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.security.oauth2.client.oidc.authentication.logout.LogoutTokenClaimAccessor;
|
||||
import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcLogoutToken;
|
||||
import org.springframework.security.oauth2.client.registration.ClientRegistration;
|
||||
import org.springframework.security.oauth2.core.OAuth2Error;
|
||||
import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
|
||||
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
|
||||
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
|
||||
/**
|
||||
* A {@link OAuth2TokenValidator} that validates OIDC Logout Token claims in conformance
|
||||
* with the OIDC Back-Channel Logout Spec.
|
||||
*
|
||||
* @author Josh Cummings
|
||||
* @since 6.2
|
||||
* @see OidcLogoutToken
|
||||
* @see <a target="_blank" href=
|
||||
* "https://openid.net/specs/openid-connect-backchannel-1_0.html#LogoutToken">Logout
|
||||
* Token</a>
|
||||
* @see <a target="blank" href=
|
||||
* "https://openid.net/specs/openid-connect-backchannel-1_0.html#Validation">the OIDC
|
||||
* Back-Channel Logout spec</a>
|
||||
*/
|
||||
final class OidcBackChannelLogoutTokenValidator implements OAuth2TokenValidator<Jwt> {
|
||||
|
||||
private static final String LOGOUT_VALIDATION_URL = "https://openid.net/specs/openid-connect-backchannel-1_0.html#Validation";
|
||||
|
||||
private static final String BACK_CHANNEL_LOGOUT_EVENT = "http://schemas.openid.net/event/backchannel-logout";
|
||||
|
||||
private final String audience;
|
||||
|
||||
private final String issuer;
|
||||
|
||||
OidcBackChannelLogoutTokenValidator(ClientRegistration clientRegistration) {
|
||||
this.audience = clientRegistration.getClientId();
|
||||
this.issuer = clientRegistration.getProviderDetails().getIssuerUri();
|
||||
}
|
||||
|
||||
@Override
|
||||
public OAuth2TokenValidatorResult validate(Jwt jwt) {
|
||||
Collection<OAuth2Error> errors = new ArrayList<>();
|
||||
|
||||
LogoutTokenClaimAccessor logoutClaims = jwt::getClaims;
|
||||
Map<String, Object> events = logoutClaims.getEvents();
|
||||
if (events == null) {
|
||||
errors.add(invalidLogoutToken("events claim must not be null"));
|
||||
}
|
||||
else if (events.get(BACK_CHANNEL_LOGOUT_EVENT) == null) {
|
||||
errors.add(invalidLogoutToken("events claim map must contain \"" + BACK_CHANNEL_LOGOUT_EVENT + "\" key"));
|
||||
}
|
||||
|
||||
String issuer = logoutClaims.getIssuer().toExternalForm();
|
||||
if (issuer == null) {
|
||||
errors.add(invalidLogoutToken("iss claim must not be null"));
|
||||
}
|
||||
else if (!this.issuer.equals(issuer)) {
|
||||
errors.add(invalidLogoutToken(
|
||||
"iss claim value must match `ClientRegistration#getProviderDetails#getIssuerUri`"));
|
||||
}
|
||||
|
||||
List<String> audience = logoutClaims.getAudience();
|
||||
if (audience == null) {
|
||||
errors.add(invalidLogoutToken("aud claim must not be null"));
|
||||
}
|
||||
else if (!audience.contains(this.audience)) {
|
||||
errors.add(invalidLogoutToken("aud claim value must include `ClientRegistration#getClientId`"));
|
||||
}
|
||||
|
||||
Instant issuedAt = logoutClaims.getIssuedAt();
|
||||
if (issuedAt == null) {
|
||||
errors.add(invalidLogoutToken("iat claim must not be null"));
|
||||
}
|
||||
|
||||
String jwtId = logoutClaims.getId();
|
||||
if (jwtId == null) {
|
||||
errors.add(invalidLogoutToken("jti claim must not be null"));
|
||||
}
|
||||
|
||||
if (logoutClaims.getSubject() == null && logoutClaims.getSessionId() == null) {
|
||||
errors.add(invalidLogoutToken("sub and sid claims must not both be null"));
|
||||
}
|
||||
|
||||
if (logoutClaims.getClaim("nonce") != null) {
|
||||
errors.add(invalidLogoutToken("nonce claim must not be present"));
|
||||
}
|
||||
|
||||
return OAuth2TokenValidatorResult.failure(errors);
|
||||
}
|
||||
|
||||
private static OAuth2Error invalidLogoutToken(String description) {
|
||||
return new OAuth2Error(OAuth2ErrorCodes.INVALID_TOKEN, description, LOGOUT_VALIDATION_URL);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,85 @@
|
|||
/*
|
||||
* Copyright 2002-2023 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.security.config.annotation.web.configurers.oauth2.client;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.apache.commons.logging.Log;
|
||||
import org.apache.commons.logging.LogFactory;
|
||||
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.oauth2.client.registration.ClientRegistration;
|
||||
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
|
||||
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
|
||||
import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
|
||||
import org.springframework.security.web.authentication.AuthenticationConverter;
|
||||
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
|
||||
import org.springframework.security.web.util.matcher.RequestMatcher;
|
||||
import org.springframework.util.Assert;
|
||||
|
||||
/**
|
||||
* An {@link AuthenticationConverter} that extracts the OIDC Logout Token authentication
|
||||
* request
|
||||
*
|
||||
* @author Josh Cummings
|
||||
* @since 6.2
|
||||
*/
|
||||
final class OidcLogoutAuthenticationConverter implements AuthenticationConverter {
|
||||
|
||||
private static final String DEFAULT_LOGOUT_URI = "/logout/connect/back-channel/{registrationId}";
|
||||
|
||||
private final Log logger = LogFactory.getLog(getClass());
|
||||
|
||||
private final ClientRegistrationRepository clientRegistrationRepository;
|
||||
|
||||
private RequestMatcher requestMatcher = new AntPathRequestMatcher(DEFAULT_LOGOUT_URI, "POST");
|
||||
|
||||
OidcLogoutAuthenticationConverter(ClientRegistrationRepository clientRegistrationRepository) {
|
||||
Assert.notNull(clientRegistrationRepository, "clientRegistrationRepository cannot be null");
|
||||
this.clientRegistrationRepository = clientRegistrationRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Authentication convert(HttpServletRequest request) {
|
||||
RequestMatcher.MatchResult result = this.requestMatcher.matcher(request);
|
||||
if (!result.isMatch()) {
|
||||
return null;
|
||||
}
|
||||
String registrationId = result.getVariables().get("registrationId");
|
||||
ClientRegistration clientRegistration = this.clientRegistrationRepository.findByRegistrationId(registrationId);
|
||||
if (clientRegistration == null) {
|
||||
this.logger.debug("Did not process OIDC Back-Channel Logout since no ClientRegistration was found");
|
||||
throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_REQUEST);
|
||||
}
|
||||
String logoutToken = request.getParameter("logout_token");
|
||||
if (logoutToken == null) {
|
||||
this.logger.debug("Failed to process OIDC Back-Channel Logout since no logout token was found");
|
||||
throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_REQUEST);
|
||||
}
|
||||
return new OidcLogoutAuthenticationToken(logoutToken, clientRegistration);
|
||||
}
|
||||
|
||||
/**
|
||||
* The logout endpoint. Defaults to
|
||||
* {@code /logout/connect/back-channel/{registrationId}}.
|
||||
* @param requestMatcher the {@link RequestMatcher} to use
|
||||
*/
|
||||
void setRequestMatcher(RequestMatcher requestMatcher) {
|
||||
Assert.notNull(requestMatcher, "requestMatcher cannot be null");
|
||||
this.requestMatcher = requestMatcher;
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,80 @@
|
|||
/*
|
||||
* Copyright 2002-2023 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.security.config.annotation.web.configurers.oauth2.client;
|
||||
|
||||
import org.springframework.security.authentication.AbstractAuthenticationToken;
|
||||
import org.springframework.security.core.authority.AuthorityUtils;
|
||||
import org.springframework.security.oauth2.client.registration.ClientRegistration;
|
||||
|
||||
/**
|
||||
* An {@link org.springframework.security.core.Authentication} instance that represents a
|
||||
* request to authenticate an OIDC Logout Token.
|
||||
*
|
||||
* @author Josh Cummings
|
||||
* @since 6.2
|
||||
*/
|
||||
class OidcLogoutAuthenticationToken extends AbstractAuthenticationToken {
|
||||
|
||||
private final String logoutToken;
|
||||
|
||||
private final ClientRegistration clientRegistration;
|
||||
|
||||
/**
|
||||
* Construct an {@link OidcLogoutAuthenticationToken}
|
||||
* @param logoutToken a signed, serialized OIDC Logout token
|
||||
* @param clientRegistration the {@link ClientRegistration client} associated with
|
||||
* this token; this is usually derived from material in the logout HTTP request
|
||||
*/
|
||||
OidcLogoutAuthenticationToken(String logoutToken, ClientRegistration clientRegistration) {
|
||||
super(AuthorityUtils.NO_AUTHORITIES);
|
||||
this.logoutToken = logoutToken;
|
||||
this.clientRegistration = clientRegistration;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
public String getCredentials() {
|
||||
return this.logoutToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
public String getPrincipal() {
|
||||
return this.logoutToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the signed, serialized OIDC Logout token
|
||||
* @return the logout token
|
||||
*/
|
||||
String getLogoutToken() {
|
||||
return this.logoutToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the {@link ClientRegistration} associated with this logout token
|
||||
* @return the {@link ClientRegistration}
|
||||
*/
|
||||
ClientRegistration getClientRegistration() {
|
||||
return this.clientRegistration;
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,159 @@
|
|||
/*
|
||||
* Copyright 2002-2023 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.security.config.annotation.web.configurers.oauth2.client;
|
||||
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import org.springframework.security.authentication.AuthenticationManager;
|
||||
import org.springframework.security.authentication.ProviderManager;
|
||||
import org.springframework.security.config.Customizer;
|
||||
import org.springframework.security.config.annotation.web.HttpSecurityBuilder;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||
import org.springframework.security.oauth2.client.oidc.session.OidcSessionRegistry;
|
||||
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
|
||||
import org.springframework.security.web.authentication.AuthenticationConverter;
|
||||
import org.springframework.security.web.authentication.logout.LogoutHandler;
|
||||
import org.springframework.security.web.csrf.CsrfFilter;
|
||||
import org.springframework.util.Assert;
|
||||
|
||||
/**
|
||||
* An {@link AbstractHttpConfigurer} for OIDC Logout flows
|
||||
*
|
||||
* <p>
|
||||
* OIDC Logout provides an application with the capability to have users log out by using
|
||||
* their existing account at an OAuth 2.0 or OpenID Connect 1.0 Provider.
|
||||
*
|
||||
*
|
||||
* <h2>Security Filters</h2>
|
||||
*
|
||||
* The following {@code Filter} is populated:
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@link OidcBackChannelLogoutFilter}</li>
|
||||
* </ul>
|
||||
*
|
||||
* <h2>Shared Objects Used</h2>
|
||||
*
|
||||
* The following shared objects are used:
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@link ClientRegistrationRepository}</li>
|
||||
* </ul>
|
||||
*
|
||||
* @author Josh Cummings
|
||||
* @since 6.2
|
||||
* @see HttpSecurity#oidcLogout()
|
||||
* @see OidcBackChannelLogoutFilter
|
||||
* @see ClientRegistrationRepository
|
||||
*/
|
||||
public final class OidcLogoutConfigurer<B extends HttpSecurityBuilder<B>>
|
||||
extends AbstractHttpConfigurer<OidcLogoutConfigurer<B>, B> {
|
||||
|
||||
private BackChannelLogoutConfigurer backChannel;
|
||||
|
||||
/**
|
||||
* Sets the repository of client registrations.
|
||||
* @param clientRegistrationRepository the repository of client registrations
|
||||
* @return the {@link OAuth2LoginConfigurer} for further configuration
|
||||
*/
|
||||
public OidcLogoutConfigurer<B> clientRegistrationRepository(
|
||||
ClientRegistrationRepository clientRegistrationRepository) {
|
||||
Assert.notNull(clientRegistrationRepository, "clientRegistrationRepository cannot be null");
|
||||
this.getBuilder().setSharedObject(ClientRegistrationRepository.class, clientRegistrationRepository);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the registry for managing the OIDC client-provider session link
|
||||
* @param oidcSessionRegistry the {@link OidcSessionRegistry} to use
|
||||
* @return the {@link OAuth2LoginConfigurer} for further configuration
|
||||
*/
|
||||
public OidcLogoutConfigurer<B> oidcSessionRegistry(OidcSessionRegistry oidcSessionRegistry) {
|
||||
Assert.notNull(oidcSessionRegistry, "oidcSessionRegistry cannot be null");
|
||||
getBuilder().setSharedObject(OidcSessionRegistry.class, oidcSessionRegistry);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure OIDC Back-Channel Logout using the provided {@link Consumer}
|
||||
* @return the {@link OidcLogoutConfigurer} for further configuration
|
||||
*/
|
||||
public OidcLogoutConfigurer<B> backChannel(Customizer<BackChannelLogoutConfigurer> backChannelLogoutConfigurer) {
|
||||
if (this.backChannel == null) {
|
||||
this.backChannel = new BackChannelLogoutConfigurer();
|
||||
}
|
||||
backChannelLogoutConfigurer.customize(this.backChannel);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Deprecated(forRemoval = true, since = "6.2")
|
||||
public B and() {
|
||||
return getBuilder();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void configure(B builder) throws Exception {
|
||||
if (this.backChannel != null) {
|
||||
this.backChannel.configure(builder);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A configurer for configuring OIDC Back-Channel Logout
|
||||
*/
|
||||
public final class BackChannelLogoutConfigurer {
|
||||
|
||||
private AuthenticationConverter authenticationConverter;
|
||||
|
||||
private final AuthenticationManager authenticationManager = new ProviderManager(
|
||||
new OidcBackChannelLogoutAuthenticationProvider());
|
||||
|
||||
private LogoutHandler logoutHandler;
|
||||
|
||||
private AuthenticationConverter authenticationConverter(B http) {
|
||||
if (this.authenticationConverter == null) {
|
||||
ClientRegistrationRepository clientRegistrationRepository = OAuth2ClientConfigurerUtils
|
||||
.getClientRegistrationRepository(http);
|
||||
this.authenticationConverter = new OidcLogoutAuthenticationConverter(clientRegistrationRepository);
|
||||
}
|
||||
return this.authenticationConverter;
|
||||
}
|
||||
|
||||
private AuthenticationManager authenticationManager() {
|
||||
return this.authenticationManager;
|
||||
}
|
||||
|
||||
private LogoutHandler logoutHandler(B http) {
|
||||
if (this.logoutHandler == null) {
|
||||
OidcBackChannelLogoutHandler logoutHandler = new OidcBackChannelLogoutHandler();
|
||||
logoutHandler.setSessionRegistry(OAuth2ClientConfigurerUtils.getOidcSessionRegistry(http));
|
||||
this.logoutHandler = logoutHandler;
|
||||
}
|
||||
return this.logoutHandler;
|
||||
}
|
||||
|
||||
void configure(B http) {
|
||||
OidcBackChannelLogoutFilter filter = new OidcBackChannelLogoutFilter(authenticationConverter(http),
|
||||
authenticationManager());
|
||||
filter.setLogoutHandler(logoutHandler(http));
|
||||
http.addFilterBefore(filter, CsrfFilter.class);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
/*
|
||||
* Copyright 2002-2023 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.security.config.web.server;
|
||||
|
||||
import java.util.function.Function;
|
||||
|
||||
import org.springframework.security.oauth2.client.registration.ClientRegistration;
|
||||
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
|
||||
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.security.oauth2.jwt.JwtTimestampValidator;
|
||||
|
||||
final class DefaultOidcLogoutTokenValidatorFactory implements Function<ClientRegistration, OAuth2TokenValidator<Jwt>> {
|
||||
|
||||
@Override
|
||||
public OAuth2TokenValidator<Jwt> apply(ClientRegistration clientRegistration) {
|
||||
return new DelegatingOAuth2TokenValidator<>(new JwtTimestampValidator(),
|
||||
new OidcBackChannelLogoutTokenValidator(clientRegistration));
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,66 @@
|
|||
/*
|
||||
* Copyright 2002-2023 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.security.config.web.server;
|
||||
|
||||
import java.util.Collections;
|
||||
|
||||
import org.springframework.security.authentication.AbstractAuthenticationToken;
|
||||
import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcLogoutToken;
|
||||
|
||||
/**
|
||||
* An {@link org.springframework.security.core.Authentication} implementation that
|
||||
* represents the result of authenticating an OIDC Logout token for the purposes of
|
||||
* performing Back-Channel Logout.
|
||||
*
|
||||
* @author Josh Cummings
|
||||
* @since 6.2
|
||||
* @see OidcLogoutAuthenticationToken
|
||||
* @see <a target="_blank" href=
|
||||
* "https://openid.net/specs/openid-connect-backchannel-1_0.html">OIDC Back-Channel
|
||||
* Logout</a>
|
||||
*/
|
||||
class OidcBackChannelLogoutAuthentication extends AbstractAuthenticationToken {
|
||||
|
||||
private final OidcLogoutToken logoutToken;
|
||||
|
||||
/**
|
||||
* Construct an {@link OidcBackChannelLogoutAuthentication}
|
||||
* @param logoutToken a deserialized, verified OIDC Logout Token
|
||||
*/
|
||||
OidcBackChannelLogoutAuthentication(OidcLogoutToken logoutToken) {
|
||||
super(Collections.emptyList());
|
||||
this.logoutToken = logoutToken;
|
||||
setAuthenticated(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
public OidcLogoutToken getPrincipal() {
|
||||
return this.logoutToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
public OidcLogoutToken getCredentials() {
|
||||
return this.logoutToken;
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,112 @@
|
|||
/*
|
||||
* Copyright 2002-2023 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.security.config.web.server;
|
||||
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import org.springframework.security.authentication.AuthenticationProvider;
|
||||
import org.springframework.security.authentication.AuthenticationServiceException;
|
||||
import org.springframework.security.authentication.ReactiveAuthenticationManager;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
import org.springframework.security.oauth2.client.oidc.authentication.ReactiveOidcIdTokenDecoderFactory;
|
||||
import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcLogoutToken;
|
||||
import org.springframework.security.oauth2.client.registration.ClientRegistration;
|
||||
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
|
||||
import org.springframework.security.oauth2.core.OAuth2Error;
|
||||
import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
|
||||
import org.springframework.security.oauth2.jwt.BadJwtException;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.security.oauth2.jwt.JwtDecoder;
|
||||
import org.springframework.security.oauth2.jwt.JwtDecoderFactory;
|
||||
import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder;
|
||||
import org.springframework.security.oauth2.jwt.ReactiveJwtDecoderFactory;
|
||||
import org.springframework.util.Assert;
|
||||
|
||||
/**
|
||||
* An {@link AuthenticationProvider} that authenticates an OIDC Logout Token; namely
|
||||
* deserializing it, verifying its signature, and validating its claims.
|
||||
*
|
||||
* <p>
|
||||
* Intended to be included in a
|
||||
* {@link org.springframework.security.authentication.ProviderManager}
|
||||
*
|
||||
* @author Josh Cummings
|
||||
* @since 6.2
|
||||
* @see OidcLogoutAuthenticationToken
|
||||
* @see org.springframework.security.authentication.ProviderManager
|
||||
* @see <a target="_blank" href=
|
||||
* "https://openid.net/specs/openid-connect-backchannel-1_0.html">OIDC Back-Channel
|
||||
* Logout</a>
|
||||
*/
|
||||
final class OidcBackChannelLogoutReactiveAuthenticationManager implements ReactiveAuthenticationManager {
|
||||
|
||||
private ReactiveJwtDecoderFactory<ClientRegistration> logoutTokenDecoderFactory;
|
||||
|
||||
/**
|
||||
* Construct an {@link OidcBackChannelLogoutReactiveAuthenticationManager}
|
||||
*/
|
||||
OidcBackChannelLogoutReactiveAuthenticationManager() {
|
||||
ReactiveOidcIdTokenDecoderFactory logoutTokenDecoderFactory = new ReactiveOidcIdTokenDecoderFactory();
|
||||
logoutTokenDecoderFactory.setJwtValidatorFactory(new DefaultOidcLogoutTokenValidatorFactory());
|
||||
this.logoutTokenDecoderFactory = logoutTokenDecoderFactory;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
public Mono<Authentication> authenticate(Authentication authentication) throws AuthenticationException {
|
||||
if (!(authentication instanceof OidcLogoutAuthenticationToken token)) {
|
||||
return Mono.empty();
|
||||
}
|
||||
String logoutToken = token.getLogoutToken();
|
||||
ClientRegistration registration = token.getClientRegistration();
|
||||
return decode(registration, logoutToken)
|
||||
.map((jwt) -> OidcLogoutToken
|
||||
.withTokenValue(logoutToken)
|
||||
.claims((claims) -> claims.putAll(jwt.getClaims())).build()
|
||||
)
|
||||
.map(OidcBackChannelLogoutAuthentication::new);
|
||||
}
|
||||
|
||||
private Mono<Jwt> decode(ClientRegistration registration, String token) {
|
||||
ReactiveJwtDecoder logoutTokenDecoder = this.logoutTokenDecoderFactory.createDecoder(registration);
|
||||
try {
|
||||
return logoutTokenDecoder.decode(token);
|
||||
}
|
||||
catch (BadJwtException failed) {
|
||||
OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.INVALID_REQUEST, failed.getMessage(),
|
||||
"https://openid.net/specs/openid-connect-backchannel-1_0.html#Validation");
|
||||
return Mono.error(new OAuth2AuthenticationException(error, failed));
|
||||
}
|
||||
catch (Exception failed) {
|
||||
return Mono.error(new AuthenticationServiceException(failed.getMessage(), failed));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Use this {@link ReactiveJwtDecoderFactory} to generate {@link JwtDecoder}s that
|
||||
* correspond to the {@link ClientRegistration} associated with the OIDC logout token.
|
||||
* @param logoutTokenDecoderFactory the {@link JwtDecoderFactory} to use
|
||||
*/
|
||||
void setLogoutTokenDecoderFactory(ReactiveJwtDecoderFactory<ClientRegistration> logoutTokenDecoderFactory) {
|
||||
Assert.notNull(logoutTokenDecoderFactory, "logoutTokenDecoderFactory cannot be null");
|
||||
this.logoutTokenDecoderFactory = logoutTokenDecoderFactory;
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,118 @@
|
|||
/*
|
||||
* Copyright 2002-2023 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.security.config.web.server;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.security.oauth2.client.oidc.authentication.logout.LogoutTokenClaimAccessor;
|
||||
import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcLogoutToken;
|
||||
import org.springframework.security.oauth2.client.registration.ClientRegistration;
|
||||
import org.springframework.security.oauth2.core.OAuth2Error;
|
||||
import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
|
||||
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
|
||||
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
|
||||
/**
|
||||
* A {@link OAuth2TokenValidator} that validates OIDC Logout Token claims in conformance
|
||||
* with the OIDC Back-Channel Logout Spec.
|
||||
*
|
||||
* @author Josh Cummings
|
||||
* @since 6.2
|
||||
* @see OidcLogoutToken
|
||||
* @see <a target="_blank" href=
|
||||
* "https://openid.net/specs/openid-connect-backchannel-1_0.html#LogoutToken">Logout
|
||||
* Token</a>
|
||||
* @see <a target="blank" href=
|
||||
* "https://openid.net/specs/openid-connect-backchannel-1_0.html#Validation">the OIDC
|
||||
* Back-Channel Logout spec</a>
|
||||
*/
|
||||
final class OidcBackChannelLogoutTokenValidator implements OAuth2TokenValidator<Jwt> {
|
||||
|
||||
private static final String LOGOUT_VALIDATION_URL = "https://openid.net/specs/openid-connect-backchannel-1_0.html#Validation";
|
||||
|
||||
private static final String BACK_CHANNEL_LOGOUT_EVENT = "http://schemas.openid.net/event/backchannel-logout";
|
||||
|
||||
private final String audience;
|
||||
|
||||
private final String issuer;
|
||||
|
||||
OidcBackChannelLogoutTokenValidator(ClientRegistration clientRegistration) {
|
||||
this.audience = clientRegistration.getClientId();
|
||||
this.issuer = clientRegistration.getProviderDetails().getIssuerUri();
|
||||
}
|
||||
|
||||
@Override
|
||||
public OAuth2TokenValidatorResult validate(Jwt jwt) {
|
||||
Collection<OAuth2Error> errors = new ArrayList<>();
|
||||
|
||||
LogoutTokenClaimAccessor logoutClaims = jwt::getClaims;
|
||||
Map<String, Object> events = logoutClaims.getEvents();
|
||||
if (events == null) {
|
||||
errors.add(invalidLogoutToken("events claim must not be null"));
|
||||
}
|
||||
else if (events.get(BACK_CHANNEL_LOGOUT_EVENT) == null) {
|
||||
errors.add(invalidLogoutToken("events claim map must contain \"" + BACK_CHANNEL_LOGOUT_EVENT + "\" key"));
|
||||
}
|
||||
|
||||
String issuer = logoutClaims.getIssuer().toExternalForm();
|
||||
if (issuer == null) {
|
||||
errors.add(invalidLogoutToken("iss claim must not be null"));
|
||||
}
|
||||
else if (!this.issuer.equals(issuer)) {
|
||||
errors.add(invalidLogoutToken(
|
||||
"iss claim value must match `ClientRegistration#getProviderDetails#getIssuerUri`"));
|
||||
}
|
||||
|
||||
List<String> audience = logoutClaims.getAudience();
|
||||
if (audience == null) {
|
||||
errors.add(invalidLogoutToken("aud claim must not be null"));
|
||||
}
|
||||
else if (!audience.contains(this.audience)) {
|
||||
errors.add(invalidLogoutToken("aud claim value must include `ClientRegistration#getClientId`"));
|
||||
}
|
||||
|
||||
Instant issuedAt = logoutClaims.getIssuedAt();
|
||||
if (issuedAt == null) {
|
||||
errors.add(invalidLogoutToken("iat claim must not be null"));
|
||||
}
|
||||
|
||||
String jwtId = logoutClaims.getId();
|
||||
if (jwtId == null) {
|
||||
errors.add(invalidLogoutToken("jti claim must not be null"));
|
||||
}
|
||||
|
||||
if (logoutClaims.getSubject() == null && logoutClaims.getSessionId() == null) {
|
||||
errors.add(invalidLogoutToken("sub and sid claims must not both be null"));
|
||||
}
|
||||
|
||||
if (logoutClaims.getClaim("nonce") != null) {
|
||||
errors.add(invalidLogoutToken("nonce claim must not be present"));
|
||||
}
|
||||
|
||||
return OAuth2TokenValidatorResult.failure(errors);
|
||||
}
|
||||
|
||||
private static OAuth2Error invalidLogoutToken(String description) {
|
||||
return new OAuth2Error(OAuth2ErrorCodes.INVALID_TOKEN, description, LOGOUT_VALIDATION_URL);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,135 @@
|
|||
/*
|
||||
* Copyright 2002-2023 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.security.config.web.server;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.apache.commons.logging.Log;
|
||||
import org.apache.commons.logging.LogFactory;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import org.springframework.core.io.buffer.DataBuffer;
|
||||
import org.springframework.http.server.reactive.ServerHttpResponse;
|
||||
import org.springframework.security.authentication.AuthenticationManager;
|
||||
import org.springframework.security.authentication.AuthenticationServiceException;
|
||||
import org.springframework.security.authentication.ReactiveAuthenticationManager;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
|
||||
import org.springframework.security.oauth2.core.OAuth2Error;
|
||||
import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
|
||||
import org.springframework.security.web.authentication.AuthenticationConverter;
|
||||
import org.springframework.security.web.authentication.logout.LogoutHandler;
|
||||
import org.springframework.security.web.server.WebFilterExchange;
|
||||
import org.springframework.security.web.server.authentication.ServerAuthenticationConverter;
|
||||
import org.springframework.security.web.server.authentication.logout.ServerLogoutHandler;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.web.server.ServerWebExchange;
|
||||
import org.springframework.web.server.WebFilter;
|
||||
import org.springframework.web.server.WebFilterChain;
|
||||
|
||||
/**
|
||||
* A filter for the Client-side OIDC Back-Channel Logout endpoint
|
||||
*
|
||||
* @author Josh Cummings
|
||||
* @since 6.2
|
||||
* @see <a target="_blank" href=
|
||||
* "https://openid.net/specs/openid-connect-backchannel-1_0.html">OIDC Back-Channel Logout
|
||||
* Spec</a>
|
||||
*/
|
||||
class OidcBackChannelLogoutWebFilter implements WebFilter {
|
||||
|
||||
private final Log logger = LogFactory.getLog(getClass());
|
||||
|
||||
private final ServerAuthenticationConverter authenticationConverter;
|
||||
|
||||
private final ReactiveAuthenticationManager authenticationManager;
|
||||
|
||||
private ServerLogoutHandler logoutHandler = new OidcBackChannelServerLogoutHandler();
|
||||
|
||||
/**
|
||||
* Construct an {@link OidcBackChannelLogoutWebFilter}
|
||||
* @param authenticationConverter the {@link AuthenticationConverter} for deriving
|
||||
* Logout Token authentication
|
||||
* @param authenticationManager the {@link AuthenticationManager} for authenticating
|
||||
* Logout Tokens
|
||||
*/
|
||||
OidcBackChannelLogoutWebFilter(ServerAuthenticationConverter authenticationConverter,
|
||||
ReactiveAuthenticationManager authenticationManager) {
|
||||
Assert.notNull(authenticationConverter, "authenticationConverter cannot be null");
|
||||
Assert.notNull(authenticationManager, "authenticationManager cannot be null");
|
||||
this.authenticationConverter = authenticationConverter;
|
||||
this.authenticationManager = authenticationManager;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
|
||||
return this.authenticationConverter.convert(exchange).onErrorResume(AuthenticationException.class, (ex) -> {
|
||||
this.logger.debug("Failed to process OIDC Back-Channel Logout", ex);
|
||||
if (ex instanceof AuthenticationServiceException) {
|
||||
return Mono.error(ex);
|
||||
}
|
||||
return handleAuthenticationFailure(exchange.getResponse(), ex).then(Mono.empty());
|
||||
}).switchIfEmpty(chain.filter(exchange).then(Mono.empty())).flatMap(this.authenticationManager::authenticate)
|
||||
.onErrorResume(AuthenticationException.class, (ex) -> {
|
||||
this.logger.debug("Failed to process OIDC Back-Channel Logout", ex);
|
||||
if (ex instanceof AuthenticationServiceException) {
|
||||
return Mono.error(ex);
|
||||
}
|
||||
return handleAuthenticationFailure(exchange.getResponse(), ex).then(Mono.empty());
|
||||
}).flatMap((authentication) -> {
|
||||
WebFilterExchange webFilterExchange = new WebFilterExchange(exchange, chain);
|
||||
return this.logoutHandler.logout(webFilterExchange, authentication);
|
||||
});
|
||||
}
|
||||
|
||||
private Mono<Void> handleAuthenticationFailure(ServerHttpResponse response, Exception ex) {
|
||||
this.logger.debug("Failed to process OIDC Back-Channel Logout", ex);
|
||||
response.setRawStatusCode(HttpServletResponse.SC_BAD_REQUEST);
|
||||
OAuth2Error error = oauth2Error(ex);
|
||||
byte[] bytes = String.format("""
|
||||
{
|
||||
"error_code": "%s",
|
||||
"error_description": "%s",
|
||||
"error_uri: "%s"
|
||||
}
|
||||
""", error.getErrorCode(), error.getDescription(), error.getUri())
|
||||
.getBytes(StandardCharsets.UTF_8);
|
||||
DataBuffer buffer = response.bufferFactory().wrap(bytes);
|
||||
return response.writeWith(Flux.just(buffer));
|
||||
}
|
||||
|
||||
private OAuth2Error oauth2Error(Exception ex) {
|
||||
if (ex instanceof OAuth2AuthenticationException oauth2) {
|
||||
return oauth2.getError();
|
||||
}
|
||||
return new OAuth2Error(OAuth2ErrorCodes.INVALID_REQUEST, ex.getMessage(),
|
||||
"https://openid.net/specs/openid-connect-backchannel-1_0.html#Validation");
|
||||
}
|
||||
|
||||
/**
|
||||
* The strategy for expiring all Client sessions indicated by the logout request.
|
||||
* Defaults to {@link OidcBackChannelServerLogoutHandler}.
|
||||
* @param logoutHandler the {@link LogoutHandler} to use
|
||||
*/
|
||||
void setLogoutHandler(ServerLogoutHandler logoutHandler) {
|
||||
Assert.notNull(logoutHandler, "logoutHandler cannot be null");
|
||||
this.logoutHandler = logoutHandler;
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,183 @@
|
|||
/*
|
||||
* Copyright 2002-2023 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.security.config.web.server;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Collection;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.apache.commons.logging.Log;
|
||||
import org.apache.commons.logging.LogFactory;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import org.springframework.core.io.buffer.DataBuffer;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.http.server.reactive.ServerHttpResponse;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcLogoutToken;
|
||||
import org.springframework.security.oauth2.client.oidc.server.session.InMemoryReactiveOidcSessionRegistry;
|
||||
import org.springframework.security.oauth2.client.oidc.server.session.ReactiveOidcSessionRegistry;
|
||||
import org.springframework.security.oauth2.client.oidc.session.OidcSessionInformation;
|
||||
import org.springframework.security.oauth2.client.oidc.session.OidcSessionRegistry;
|
||||
import org.springframework.security.oauth2.core.OAuth2Error;
|
||||
import org.springframework.security.web.server.WebFilterExchange;
|
||||
import org.springframework.security.web.server.authentication.logout.ServerLogoutHandler;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
import org.springframework.web.util.UriComponentsBuilder;
|
||||
|
||||
/**
|
||||
* A {@link ServerLogoutHandler} that locates the sessions associated with a given OIDC
|
||||
* Back-Channel Logout Token and invalidates each one.
|
||||
*
|
||||
* @author Josh Cummings
|
||||
* @since 6.2
|
||||
* @see <a target="_blank" href=
|
||||
* "https://openid.net/specs/openid-connect-backchannel-1_0.html">OIDC Back-Channel Logout
|
||||
* Spec</a>
|
||||
*/
|
||||
final class OidcBackChannelServerLogoutHandler implements ServerLogoutHandler {
|
||||
|
||||
private final Log logger = LogFactory.getLog(getClass());
|
||||
|
||||
private ReactiveOidcSessionRegistry sessionRegistry = new InMemoryReactiveOidcSessionRegistry();
|
||||
|
||||
private WebClient web = WebClient.create();
|
||||
|
||||
private String logoutEndpointName = "/logout";
|
||||
|
||||
private String sessionCookieName = "SESSION";
|
||||
|
||||
@Override
|
||||
public Mono<Void> logout(WebFilterExchange exchange, Authentication authentication) {
|
||||
if (!(authentication instanceof OidcBackChannelLogoutAuthentication token)) {
|
||||
return Mono.defer(() -> {
|
||||
if (this.logger.isDebugEnabled()) {
|
||||
String message = "Did not perform OIDC Back-Channel Logout since authentication [%s] was of the wrong type";
|
||||
this.logger.debug(String.format(message, authentication.getClass().getSimpleName()));
|
||||
}
|
||||
return Mono.empty();
|
||||
});
|
||||
}
|
||||
AtomicInteger totalCount = new AtomicInteger(0);
|
||||
AtomicInteger invalidatedCount = new AtomicInteger(0);
|
||||
return this.sessionRegistry.removeSessionInformation(token.getPrincipal())
|
||||
.concatMap((session) -> {
|
||||
totalCount.incrementAndGet();
|
||||
return eachLogout(exchange, session)
|
||||
.flatMap((response) -> {
|
||||
invalidatedCount.incrementAndGet();
|
||||
return Mono.empty();
|
||||
})
|
||||
.onErrorResume((ex) -> {
|
||||
this.logger.debug("Failed to invalidate session", ex);
|
||||
return this.sessionRegistry.saveSessionInformation(session)
|
||||
.then(Mono.just(ex.getMessage()));
|
||||
});
|
||||
}).collectList().flatMap((list) -> {
|
||||
if (this.logger.isTraceEnabled()) {
|
||||
this.logger.trace(String.format("Invalidated %d out of %d sessions", invalidatedCount.intValue(), totalCount.intValue()));
|
||||
}
|
||||
if (!list.isEmpty()) {
|
||||
return handleLogoutFailure(exchange.getExchange().getResponse(), oauth2Error(list));
|
||||
}
|
||||
else {
|
||||
return Mono.empty();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private Mono<ResponseEntity<Void>> eachLogout(WebFilterExchange exchange, OidcSessionInformation session) {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.add(HttpHeaders.COOKIE, this.sessionCookieName + "=" + session.getSessionId());
|
||||
for (Map.Entry<String, String> credential : session.getAuthorities().entrySet()) {
|
||||
headers.add(credential.getKey(), credential.getValue());
|
||||
}
|
||||
String url = exchange.getExchange().getRequest().getURI().toString();
|
||||
String logout = UriComponentsBuilder.fromHttpUrl(url).replacePath(this.logoutEndpointName).build()
|
||||
.toUriString();
|
||||
return this.web.post().uri(logout).headers((h) -> h.putAll(headers)).retrieve().toBodilessEntity();
|
||||
}
|
||||
|
||||
private OAuth2Error oauth2Error(Collection<?> errors) {
|
||||
return new OAuth2Error("partial_logout", "not all sessions were terminated: " + errors,
|
||||
"https://openid.net/specs/openid-connect-backchannel-1_0.html#Validation");
|
||||
}
|
||||
|
||||
private Mono<Void> handleLogoutFailure(ServerHttpResponse response, OAuth2Error error) {
|
||||
response.setRawStatusCode(HttpServletResponse.SC_BAD_REQUEST);
|
||||
byte[] bytes = String.format("""
|
||||
{
|
||||
"error_code": "%s",
|
||||
"error_description": "%s",
|
||||
"error_uri: "%s"
|
||||
}
|
||||
""", error.getErrorCode(), error.getDescription(), error.getUri())
|
||||
.getBytes(StandardCharsets.UTF_8);
|
||||
DataBuffer buffer = response.bufferFactory().wrap(bytes);
|
||||
return response.writeWith(Flux.just(buffer));
|
||||
}
|
||||
|
||||
/**
|
||||
* Use this {@link OidcSessionRegistry} to identify sessions to invalidate. Note that
|
||||
* this class uses
|
||||
* {@link OidcSessionRegistry#removeSessionInformation(OidcLogoutToken)} to identify
|
||||
* sessions.
|
||||
* @param sessionRegistry the {@link OidcSessionRegistry} to use
|
||||
*/
|
||||
void setSessionRegistry(ReactiveOidcSessionRegistry sessionRegistry) {
|
||||
Assert.notNull(sessionRegistry, "sessionRegistry cannot be null");
|
||||
this.sessionRegistry = sessionRegistry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Use this {@link WebClient} to perform the per-session back-channel logout
|
||||
* @param web the {@link WebClient} to use
|
||||
*/
|
||||
void setWebClient(WebClient web) {
|
||||
Assert.notNull(web, "web cannot be null");
|
||||
this.web = web;
|
||||
}
|
||||
|
||||
/**
|
||||
* Use this logout URI for performing per-session logout. Defaults to {@code /logout}
|
||||
* since that is the default URI for
|
||||
* {@link org.springframework.security.web.authentication.logout.LogoutFilter}.
|
||||
* @param logoutUri the URI to use
|
||||
*/
|
||||
void setLogoutUri(String logoutUri) {
|
||||
Assert.hasText(logoutUri, "logoutUri cannot be empty");
|
||||
this.logoutEndpointName = logoutUri;
|
||||
}
|
||||
|
||||
/**
|
||||
* Use this cookie name for the session identifier. Defaults to {@code JSESSIONID}.
|
||||
*
|
||||
* <p>
|
||||
* Note that if you are using Spring Session, this likely needs to change to SESSION.
|
||||
* @param sessionCookieName the cookie name to use
|
||||
*/
|
||||
void setSessionCookieName(String sessionCookieName) {
|
||||
Assert.hasText(sessionCookieName, "clientSessionCookieName cannot be empty");
|
||||
this.sessionCookieName = sessionCookieName;
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,80 @@
|
|||
/*
|
||||
* Copyright 2002-2023 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.security.config.web.server;
|
||||
|
||||
import org.springframework.security.authentication.AbstractAuthenticationToken;
|
||||
import org.springframework.security.core.authority.AuthorityUtils;
|
||||
import org.springframework.security.oauth2.client.registration.ClientRegistration;
|
||||
|
||||
/**
|
||||
* An {@link org.springframework.security.core.Authentication} instance that represents a
|
||||
* request to authenticate an OIDC Logout Token.
|
||||
*
|
||||
* @author Josh Cummings
|
||||
* @since 6.2
|
||||
*/
|
||||
class OidcLogoutAuthenticationToken extends AbstractAuthenticationToken {
|
||||
|
||||
private final String logoutToken;
|
||||
|
||||
private final ClientRegistration clientRegistration;
|
||||
|
||||
/**
|
||||
* Construct an {@link OidcLogoutAuthenticationToken}
|
||||
* @param logoutToken a signed, serialized OIDC Logout token
|
||||
* @param clientRegistration the {@link ClientRegistration client} associated with
|
||||
* this token; this is usually derived from material in the logout HTTP request
|
||||
*/
|
||||
OidcLogoutAuthenticationToken(String logoutToken, ClientRegistration clientRegistration) {
|
||||
super(AuthorityUtils.NO_AUTHORITIES);
|
||||
this.logoutToken = logoutToken;
|
||||
this.clientRegistration = clientRegistration;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
public String getCredentials() {
|
||||
return this.logoutToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
public String getPrincipal() {
|
||||
return this.logoutToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the signed, serialized OIDC Logout token
|
||||
* @return the logout token
|
||||
*/
|
||||
String getLogoutToken() {
|
||||
return this.logoutToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the {@link ClientRegistration} associated with this logout token
|
||||
* @return the {@link ClientRegistration}
|
||||
*/
|
||||
ClientRegistration getClientRegistration() {
|
||||
return this.clientRegistration;
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,88 @@
|
|||
/*
|
||||
* Copyright 2002-2023 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.security.config.web.server;
|
||||
|
||||
import org.apache.commons.logging.Log;
|
||||
import org.apache.commons.logging.LogFactory;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.oauth2.client.registration.ReactiveClientRegistrationRepository;
|
||||
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
|
||||
import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
|
||||
import org.springframework.security.web.authentication.AuthenticationConverter;
|
||||
import org.springframework.security.web.server.authentication.ServerAuthenticationConverter;
|
||||
import org.springframework.security.web.server.util.matcher.PathPatternParserServerWebExchangeMatcher;
|
||||
import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.web.server.ServerWebExchange;
|
||||
|
||||
/**
|
||||
* An {@link AuthenticationConverter} that extracts the OIDC Logout Token authentication
|
||||
* request
|
||||
*
|
||||
* @author Josh Cummings
|
||||
* @since 6.2
|
||||
*/
|
||||
final class OidcLogoutServerAuthenticationConverter implements ServerAuthenticationConverter {
|
||||
|
||||
private static final String DEFAULT_LOGOUT_URI = "/logout/connect/back-channel/{registrationId}";
|
||||
|
||||
private final Log logger = LogFactory.getLog(getClass());
|
||||
|
||||
private final ReactiveClientRegistrationRepository clientRegistrationRepository;
|
||||
|
||||
private ServerWebExchangeMatcher exchangeMatcher = new PathPatternParserServerWebExchangeMatcher(DEFAULT_LOGOUT_URI,
|
||||
HttpMethod.POST);
|
||||
|
||||
OidcLogoutServerAuthenticationConverter(ReactiveClientRegistrationRepository clientRegistrationRepository) {
|
||||
Assert.notNull(clientRegistrationRepository, "clientRegistrationRepository cannot be null");
|
||||
this.clientRegistrationRepository = clientRegistrationRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Authentication> convert(ServerWebExchange exchange) {
|
||||
return this.exchangeMatcher.matches(exchange).filter(ServerWebExchangeMatcher.MatchResult::isMatch)
|
||||
.flatMap((match) -> {
|
||||
String registrationId = (String) match.getVariables().get("registrationId");
|
||||
return this.clientRegistrationRepository.findByRegistrationId(registrationId)
|
||||
.switchIfEmpty(Mono.error(() -> {
|
||||
this.logger.debug(
|
||||
"Did not process OIDC Back-Channel Logout since no ClientRegistration was found");
|
||||
return new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_REQUEST);
|
||||
}));
|
||||
}).flatMap((clientRegistration) -> exchange.getFormData().map((data) -> {
|
||||
String logoutToken = data.getFirst("logout_token");
|
||||
return new OidcLogoutAuthenticationToken(logoutToken, clientRegistration);
|
||||
}).switchIfEmpty(Mono.error(() -> {
|
||||
this.logger.debug("Failed to process OIDC Back-Channel Logout since no logout token was found");
|
||||
return new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_REQUEST);
|
||||
})));
|
||||
}
|
||||
|
||||
/**
|
||||
* The logout endpoint. Defaults to
|
||||
* {@code /logout/connect/back-channel/{registrationId}}.
|
||||
* @param exchangeMatcher the {@link ServerWebExchangeMatcher} to use
|
||||
*/
|
||||
void setExchangeMatcher(ServerWebExchangeMatcher exchangeMatcher) {
|
||||
Assert.notNull(exchangeMatcher, "exchangeMatcher cannot be null");
|
||||
this.exchangeMatcher = exchangeMatcher;
|
||||
}
|
||||
|
||||
}
|
|
@ -21,6 +21,7 @@ import java.io.PrintWriter;
|
|||
import java.io.StringWriter;
|
||||
import java.security.interfaces.RSAPublicKey;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
|
@ -28,10 +29,13 @@ import java.util.HashMap;
|
|||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import io.micrometer.observation.ObservationRegistry;
|
||||
import org.apache.commons.logging.Log;
|
||||
import org.apache.commons.logging.LogFactory;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.util.context.Context;
|
||||
|
||||
|
@ -67,6 +71,9 @@ import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCo
|
|||
import org.springframework.security.oauth2.client.endpoint.ReactiveOAuth2AccessTokenResponseClient;
|
||||
import org.springframework.security.oauth2.client.endpoint.WebClientReactiveAuthorizationCodeTokenResponseClient;
|
||||
import org.springframework.security.oauth2.client.oidc.authentication.OidcAuthorizationCodeReactiveAuthenticationManager;
|
||||
import org.springframework.security.oauth2.client.oidc.server.session.InMemoryReactiveOidcSessionRegistry;
|
||||
import org.springframework.security.oauth2.client.oidc.server.session.ReactiveOidcSessionRegistry;
|
||||
import org.springframework.security.oauth2.client.oidc.session.OidcSessionInformation;
|
||||
import org.springframework.security.oauth2.client.oidc.userinfo.OidcReactiveOAuth2UserService;
|
||||
import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest;
|
||||
import org.springframework.security.oauth2.client.registration.ClientRegistration;
|
||||
|
@ -113,6 +120,7 @@ import org.springframework.security.web.server.MatcherSecurityWebFilterChain;
|
|||
import org.springframework.security.web.server.SecurityWebFilterChain;
|
||||
import org.springframework.security.web.server.ServerAuthenticationEntryPoint;
|
||||
import org.springframework.security.web.server.ServerRedirectStrategy;
|
||||
import org.springframework.security.web.server.WebFilterExchange;
|
||||
import org.springframework.security.web.server.authentication.AnonymousAuthenticationWebFilter;
|
||||
import org.springframework.security.web.server.authentication.AuthenticationConverterServerWebExchangeMatcher;
|
||||
import org.springframework.security.web.server.authentication.AuthenticationWebFilter;
|
||||
|
@ -147,6 +155,7 @@ import org.springframework.security.web.server.context.SecurityContextServerWebE
|
|||
import org.springframework.security.web.server.context.ServerSecurityContextRepository;
|
||||
import org.springframework.security.web.server.context.WebSessionServerSecurityContextRepository;
|
||||
import org.springframework.security.web.server.csrf.CsrfServerLogoutHandler;
|
||||
import org.springframework.security.web.server.csrf.CsrfToken;
|
||||
import org.springframework.security.web.server.csrf.CsrfWebFilter;
|
||||
import org.springframework.security.web.server.csrf.ServerCsrfTokenRepository;
|
||||
import org.springframework.security.web.server.csrf.ServerCsrfTokenRequestHandler;
|
||||
|
@ -193,8 +202,10 @@ import org.springframework.web.cors.reactive.CorsWebFilter;
|
|||
import org.springframework.web.cors.reactive.DefaultCorsProcessor;
|
||||
import org.springframework.web.reactive.result.method.annotation.RequestMappingHandlerMapping;
|
||||
import org.springframework.web.server.ServerWebExchange;
|
||||
import org.springframework.web.server.ServerWebExchangeDecorator;
|
||||
import org.springframework.web.server.WebFilter;
|
||||
import org.springframework.web.server.WebFilterChain;
|
||||
import org.springframework.web.server.WebSession;
|
||||
import org.springframework.web.util.pattern.PathPatternParser;
|
||||
|
||||
/**
|
||||
|
@ -295,6 +306,8 @@ public class ServerHttpSecurity {
|
|||
|
||||
private OAuth2ClientSpec client;
|
||||
|
||||
private OidcLogoutSpec oidcLogout;
|
||||
|
||||
private LogoutSpec logout = new LogoutSpec();
|
||||
|
||||
private LoginPageSpec loginPage = new LoginPageSpec();
|
||||
|
@ -1093,6 +1106,33 @@ public class ServerHttpSecurity {
|
|||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures OIDC Connect 1.0 Logout support.
|
||||
*
|
||||
* <pre class="code">
|
||||
* @Bean
|
||||
* public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
|
||||
* http
|
||||
* // ...
|
||||
* .oidcLogout((logout) -> logout
|
||||
* .backChannel(Customizer.withDefaults())
|
||||
* );
|
||||
* return http.build();
|
||||
* }
|
||||
* </pre>
|
||||
* @param oidcLogoutCustomizer the {@link Customizer} to provide more options for the
|
||||
* {@link OidcLogoutSpec}
|
||||
* @return the {@link ServerHttpSecurity} to customize
|
||||
* @since 6.2
|
||||
*/
|
||||
public ServerHttpSecurity oidcLogout(Customizer<OidcLogoutSpec> oidcLogoutCustomizer) {
|
||||
if (this.oidcLogout == null) {
|
||||
this.oidcLogout = new OidcLogoutSpec();
|
||||
}
|
||||
oidcLogoutCustomizer.customize(this.oidcLogout);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures HTTP Response Headers. The default headers are:
|
||||
*
|
||||
|
@ -1537,6 +1577,9 @@ public class ServerHttpSecurity {
|
|||
if (this.resourceServer != null) {
|
||||
this.resourceServer.configure(this);
|
||||
}
|
||||
if (this.oidcLogout != null) {
|
||||
this.oidcLogout.configure(this);
|
||||
}
|
||||
if (this.client != null) {
|
||||
this.client.configure(this);
|
||||
}
|
||||
|
@ -3689,6 +3732,8 @@ public class ServerHttpSecurity {
|
|||
|
||||
private ServerWebExchangeMatcher authenticationMatcher;
|
||||
|
||||
private ReactiveOidcSessionRegistry oidcSessionRegistry;
|
||||
|
||||
private ServerAuthenticationSuccessHandler authenticationSuccessHandler;
|
||||
|
||||
private ServerAuthenticationFailureHandler authenticationFailureHandler;
|
||||
|
@ -3720,6 +3765,20 @@ public class ServerHttpSecurity {
|
|||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures the {@link ReactiveOidcSessionRegistry} to use when logins use OIDC.
|
||||
* Default is to look the value up as a Bean, or else use an
|
||||
* {@link InMemoryReactiveOidcSessionRegistry}.
|
||||
* @param oidcSessionRegistry the registry to use
|
||||
* @return the {@link OidcLogoutSpec} to customize
|
||||
* @since 6.2
|
||||
*/
|
||||
public OAuth2LoginSpec oidcSessionRegistry(ReactiveOidcSessionRegistry oidcSessionRegistry) {
|
||||
Assert.notNull(oidcSessionRegistry, "oidcSessionRegistry cannot be null");
|
||||
this.oidcSessionRegistry = oidcSessionRegistry;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* The {@link ServerAuthenticationSuccessHandler} used after authentication
|
||||
* success. Defaults to {@link RedirectServerAuthenticationSuccessHandler}
|
||||
|
@ -3913,8 +3972,9 @@ public class ServerHttpSecurity {
|
|||
oauthRedirectFilter.setRequestCache(http.requestCache.requestCache);
|
||||
|
||||
ReactiveAuthenticationManager manager = getAuthenticationManager();
|
||||
AuthenticationWebFilter authenticationFilter = new OAuth2LoginAuthenticationWebFilter(manager,
|
||||
authorizedClientRepository);
|
||||
ReactiveOidcSessionRegistry sessionRegistry = getOidcSessionRegistry();
|
||||
AuthenticationWebFilter authenticationFilter = new OidcSessionRegistryAuthenticationWebFilter(manager,
|
||||
authorizedClientRepository, sessionRegistry);
|
||||
authenticationFilter.setRequiresAuthenticationMatcher(getAuthenticationMatcher());
|
||||
authenticationFilter
|
||||
.setServerAuthenticationConverter(getAuthenticationConverter(clientRegistrationRepository));
|
||||
|
@ -3923,6 +3983,8 @@ public class ServerHttpSecurity {
|
|||
authenticationFilter.setSecurityContextRepository(this.securityContextRepository);
|
||||
|
||||
setDefaultEntryPoints(http);
|
||||
http.addFilterAfter(new OidcSessionRegistryWebFilter(sessionRegistry),
|
||||
SecurityWebFiltersOrder.HTTP_HEADERS_WRITER);
|
||||
http.addFilterAt(oauthRedirectFilter, SecurityWebFiltersOrder.HTTP_BASIC);
|
||||
http.addFilterAt(authenticationFilter, SecurityWebFiltersOrder.AUTHENTICATION);
|
||||
}
|
||||
|
@ -3967,6 +4029,16 @@ public class ServerHttpSecurity {
|
|||
http.defaultEntryPoints.add(new DelegateEntry(defaultEntryPointMatcher, defaultEntryPoint));
|
||||
}
|
||||
|
||||
private ReactiveOidcSessionRegistry getOidcSessionRegistry() {
|
||||
if (this.oidcSessionRegistry == null) {
|
||||
this.oidcSessionRegistry = getBeanOrNull(ReactiveOidcSessionRegistry.class);
|
||||
}
|
||||
if (this.oidcSessionRegistry == null) {
|
||||
this.oidcSessionRegistry = new InMemoryReactiveOidcSessionRegistry();
|
||||
}
|
||||
return this.oidcSessionRegistry;
|
||||
}
|
||||
|
||||
private ServerAuthenticationSuccessHandler getAuthenticationSuccessHandler(ServerHttpSecurity http) {
|
||||
if (this.authenticationSuccessHandler == null) {
|
||||
RedirectServerAuthenticationSuccessHandler handler = new RedirectServerAuthenticationSuccessHandler();
|
||||
|
@ -4083,6 +4155,154 @@ public class ServerHttpSecurity {
|
|||
return new InMemoryReactiveOAuth2AuthorizedClientService(getClientRegistrationRepository());
|
||||
}
|
||||
|
||||
private static final class OidcSessionRegistryWebFilter implements WebFilter {
|
||||
|
||||
private final ReactiveOidcSessionRegistry oidcSessionRegistry;
|
||||
|
||||
OidcSessionRegistryWebFilter(ReactiveOidcSessionRegistry oidcSessionRegistry) {
|
||||
this.oidcSessionRegistry = oidcSessionRegistry;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
|
||||
return chain.filter(new OidcSessionRegistryServerWebExchange(exchange));
|
||||
}
|
||||
|
||||
private final class OidcSessionRegistryServerWebExchange extends ServerWebExchangeDecorator {
|
||||
|
||||
private final Mono<WebSession> sessionMono;
|
||||
|
||||
protected OidcSessionRegistryServerWebExchange(ServerWebExchange delegate) {
|
||||
super(delegate);
|
||||
this.sessionMono = delegate.getSession().map(OidcSessionRegistryWebSession::new);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<WebSession> getSession() {
|
||||
return this.sessionMono;
|
||||
}
|
||||
|
||||
private final class OidcSessionRegistryWebSession implements WebSession {
|
||||
|
||||
private final WebSession session;
|
||||
|
||||
OidcSessionRegistryWebSession(WebSession session) {
|
||||
this.session = session;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getId() {
|
||||
return this.session.getId();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> getAttributes() {
|
||||
return this.session.getAttributes();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void start() {
|
||||
this.session.start();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isStarted() {
|
||||
return this.session.isStarted();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> changeSessionId() {
|
||||
String currentId = this.session.getId();
|
||||
return this.session.changeSessionId()
|
||||
.then(Mono.defer(() -> OidcSessionRegistryWebFilter.this.oidcSessionRegistry
|
||||
.removeSessionInformation(currentId).flatMap((information) -> {
|
||||
information = information.withSessionId(this.session.getId());
|
||||
return OidcSessionRegistryWebFilter.this.oidcSessionRegistry
|
||||
.saveSessionInformation(information);
|
||||
})));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> invalidate() {
|
||||
String currentId = this.session.getId();
|
||||
return this.session.invalidate()
|
||||
.then(Mono.defer(() -> OidcSessionRegistryWebFilter.this.oidcSessionRegistry
|
||||
.removeSessionInformation(currentId).then(Mono.empty())));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> save() {
|
||||
return this.session.save();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isExpired() {
|
||||
return this.session.isExpired();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Instant getCreationTime() {
|
||||
return this.session.getCreationTime();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Instant getLastAccessTime() {
|
||||
return this.session.getLastAccessTime();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setMaxIdleTime(Duration maxIdleTime) {
|
||||
this.session.setMaxIdleTime(maxIdleTime);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Duration getMaxIdleTime() {
|
||||
return this.session.getMaxIdleTime();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private static final class OidcSessionRegistryAuthenticationWebFilter
|
||||
extends OAuth2LoginAuthenticationWebFilter {
|
||||
|
||||
private final Log logger = LogFactory.getLog(getClass());
|
||||
|
||||
private final ReactiveOidcSessionRegistry oidcSessionRegistry;
|
||||
|
||||
OidcSessionRegistryAuthenticationWebFilter(ReactiveAuthenticationManager authenticationManager,
|
||||
ServerOAuth2AuthorizedClientRepository authorizedClientRepository,
|
||||
ReactiveOidcSessionRegistry oidcSessionRegistry) {
|
||||
super(authenticationManager, authorizedClientRepository);
|
||||
this.oidcSessionRegistry = oidcSessionRegistry;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Mono<Void> onAuthenticationSuccess(Authentication authentication, WebFilterExchange webFilterExchange) {
|
||||
if (!(authentication.getPrincipal() instanceof OidcUser user)) {
|
||||
return super.onAuthenticationSuccess(authentication, webFilterExchange);
|
||||
}
|
||||
return webFilterExchange.getExchange().getSession()
|
||||
.doOnNext((session) -> {
|
||||
if (this.logger.isTraceEnabled()) {
|
||||
this.logger.trace(String.format("Linking a provider [%s] session to this client's session", user.getIssuer()));
|
||||
}
|
||||
})
|
||||
.flatMap((session) -> {
|
||||
Mono<CsrfToken> csrfToken = webFilterExchange.getExchange().getAttribute(CsrfToken.class.getName());
|
||||
return (csrfToken != null) ?
|
||||
csrfToken.map((token) -> new OidcSessionInformation(session.getId(), Map.of(token.getHeaderName(), token.getToken()), user)) :
|
||||
Mono.just(new OidcSessionInformation(session.getId(), Map.of(), user));
|
||||
})
|
||||
.flatMap(this.oidcSessionRegistry::saveSessionInformation)
|
||||
.then(super.onAuthenticationSuccess(authentication, webFilterExchange));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public final class OAuth2ClientSpec {
|
||||
|
@ -4755,6 +4975,129 @@ public class ServerHttpSecurity {
|
|||
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures OIDC 1.0 Logout support
|
||||
*
|
||||
* @author Josh Cummings
|
||||
* @since 6.2
|
||||
*/
|
||||
public final class OidcLogoutSpec {
|
||||
|
||||
private ReactiveClientRegistrationRepository clientRegistrationRepository;
|
||||
|
||||
private ReactiveOidcSessionRegistry sessionRegistry;
|
||||
|
||||
private BackChannelLogoutConfigurer backChannel;
|
||||
|
||||
/**
|
||||
* Configures the {@link ReactiveClientRegistrationRepository}. Default is to look
|
||||
* the value up as a Bean.
|
||||
* @param clientRegistrationRepository the repository to use
|
||||
* @return the {@link OidcLogoutSpec} to customize
|
||||
*/
|
||||
public OidcLogoutSpec clientRegistrationRepository(
|
||||
ReactiveClientRegistrationRepository clientRegistrationRepository) {
|
||||
Assert.notNull(clientRegistrationRepository, "clientRegistrationRepository cannot be null");
|
||||
this.clientRegistrationRepository = clientRegistrationRepository;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures the {@link ReactiveOidcSessionRegistry}. Default is to use the value
|
||||
* from {@link OAuth2LoginSpec#oidcSessionRegistry}, then look the value up as a
|
||||
* Bean, or else use an {@link InMemoryReactiveOidcSessionRegistry}.
|
||||
* @param sessionRegistry the registry to use
|
||||
* @return the {@link OidcLogoutSpec} to customize
|
||||
*/
|
||||
public OidcLogoutSpec oidcSessionRegistry(ReactiveOidcSessionRegistry sessionRegistry) {
|
||||
Assert.notNull(sessionRegistry, "sessionRegistry cannot be null");
|
||||
this.sessionRegistry = sessionRegistry;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure OIDC Back-Channel Logout using the provided {@link Consumer}
|
||||
* @return the {@link OidcLogoutSpec} for further configuration
|
||||
*/
|
||||
public OidcLogoutSpec backChannel(Customizer<BackChannelLogoutConfigurer> backChannelLogoutConfigurer) {
|
||||
if (this.backChannel == null) {
|
||||
this.backChannel = new OidcLogoutSpec.BackChannelLogoutConfigurer();
|
||||
}
|
||||
backChannelLogoutConfigurer.customize(this.backChannel);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Deprecated(forRemoval = true, since = "6.2")
|
||||
public ServerHttpSecurity and() {
|
||||
return ServerHttpSecurity.this;
|
||||
}
|
||||
|
||||
void configure(ServerHttpSecurity http) {
|
||||
if (this.backChannel != null) {
|
||||
this.backChannel.configure(http);
|
||||
}
|
||||
}
|
||||
|
||||
private ReactiveClientRegistrationRepository getClientRegistrationRepository() {
|
||||
if (this.clientRegistrationRepository == null) {
|
||||
this.clientRegistrationRepository = getBeanOrNull(ReactiveClientRegistrationRepository.class);
|
||||
}
|
||||
return this.clientRegistrationRepository;
|
||||
}
|
||||
|
||||
private ReactiveOidcSessionRegistry getSessionRegistry() {
|
||||
if (this.sessionRegistry == null && ServerHttpSecurity.this.oauth2Login == null) {
|
||||
return new InMemoryReactiveOidcSessionRegistry();
|
||||
}
|
||||
if (this.sessionRegistry == null) {
|
||||
return ServerHttpSecurity.this.oauth2Login.oidcSessionRegistry;
|
||||
}
|
||||
return this.sessionRegistry;
|
||||
}
|
||||
|
||||
/**
|
||||
* A configurer for configuring OIDC Back-Channel Logout
|
||||
*/
|
||||
public final class BackChannelLogoutConfigurer {
|
||||
|
||||
private ServerAuthenticationConverter authenticationConverter;
|
||||
|
||||
private final ReactiveAuthenticationManager authenticationManager = new OidcBackChannelLogoutReactiveAuthenticationManager();
|
||||
|
||||
private ServerLogoutHandler logoutHandler;
|
||||
|
||||
private ServerAuthenticationConverter authenticationConverter() {
|
||||
if (this.authenticationConverter == null) {
|
||||
this.authenticationConverter = new OidcLogoutServerAuthenticationConverter(
|
||||
OidcLogoutSpec.this.getClientRegistrationRepository());
|
||||
}
|
||||
return this.authenticationConverter;
|
||||
}
|
||||
|
||||
private ReactiveAuthenticationManager authenticationManager() {
|
||||
return this.authenticationManager;
|
||||
}
|
||||
|
||||
private ServerLogoutHandler logoutHandler() {
|
||||
if (this.logoutHandler == null) {
|
||||
OidcBackChannelServerLogoutHandler logoutHandler = new OidcBackChannelServerLogoutHandler();
|
||||
logoutHandler.setSessionRegistry(OidcLogoutSpec.this.getSessionRegistry());
|
||||
this.logoutHandler = logoutHandler;
|
||||
}
|
||||
return this.logoutHandler;
|
||||
}
|
||||
|
||||
void configure(ServerHttpSecurity http) {
|
||||
OidcBackChannelLogoutWebFilter filter = new OidcBackChannelLogoutWebFilter(authenticationConverter(),
|
||||
authenticationManager());
|
||||
filter.setLogoutHandler(logoutHandler());
|
||||
http.addFilterBefore(filter, SecurityWebFiltersOrder.CSRF);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures anonymous authentication
|
||||
*
|
||||
|
|
|
@ -868,6 +868,38 @@ class HttpSecurityDsl(private val http: HttpSecurity, private val init: HttpSecu
|
|||
this.http.oauth2ResourceServer(oauth2ResourceServerCustomizer)
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures OIDC 1.0 logout support.
|
||||
*
|
||||
* Example:
|
||||
*
|
||||
* ```
|
||||
* @Configuration
|
||||
* @EnableWebSecurity
|
||||
* class SecurityConfig {
|
||||
*
|
||||
* @Bean
|
||||
* fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
|
||||
* http {
|
||||
* oauth2Login { }
|
||||
* oidcLogout {
|
||||
* backChannel { }
|
||||
* }
|
||||
* }
|
||||
* return http.build()
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @param oidcLogoutConfiguration custom configuration to configure the
|
||||
* OIDC 1.0 logout support
|
||||
* @see [OidcLogoutDsl]
|
||||
*/
|
||||
fun oidcLogout(oidcLogoutConfiguration: OidcLogoutDsl.() -> Unit) {
|
||||
val oidcLogoutCustomizer = OidcLogoutDsl().apply(oidcLogoutConfiguration).get()
|
||||
this.http.oidcLogout(oidcLogoutCustomizer)
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures Remember Me authentication.
|
||||
*
|
||||
|
|
|
@ -0,0 +1,75 @@
|
|||
/*
|
||||
* Copyright 2002-2023 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.security.config.annotation.web
|
||||
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity
|
||||
import org.springframework.security.config.annotation.web.configurers.oauth2.client.OidcLogoutConfigurer
|
||||
import org.springframework.security.config.annotation.web.oauth2.login.OidcBackChannelLogoutDsl
|
||||
import org.springframework.security.oauth2.client.oidc.session.OidcSessionRegistry
|
||||
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository
|
||||
|
||||
/**
|
||||
* A Kotlin DSL to configure [HttpSecurity] OAuth 1.0 Logout using idiomatic Kotlin code.
|
||||
*
|
||||
* @author Josh Cummings
|
||||
* @since 6.2
|
||||
*/
|
||||
@SecurityMarker
|
||||
class OidcLogoutDsl {
|
||||
var clientRegistrationRepository: ClientRegistrationRepository? = null
|
||||
var oidcSessionRegistry: OidcSessionRegistry? = null
|
||||
|
||||
private var backChannel: ((OidcLogoutConfigurer<HttpSecurity>.BackChannelLogoutConfigurer) -> Unit)? = null
|
||||
|
||||
/**
|
||||
* Configures the OIDC 1.0 Back-Channel endpoint.
|
||||
*
|
||||
* Example:
|
||||
*
|
||||
* ```
|
||||
* @Configuration
|
||||
* @EnableWebSecurity
|
||||
* class SecurityConfig {
|
||||
*
|
||||
* @Bean
|
||||
* fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
|
||||
* http {
|
||||
* oauth2Login { }
|
||||
* oidcLogout {
|
||||
* backChannel { }
|
||||
* }
|
||||
* }
|
||||
* return http.build()
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @param backChannelConfig custom configurations to configure the back-channel endpoint
|
||||
* @see [OidcBackChannelLogoutDsl]
|
||||
*/
|
||||
fun backChannel(backChannelConfig: OidcBackChannelLogoutDsl.() -> Unit) {
|
||||
this.backChannel = OidcBackChannelLogoutDsl().apply(backChannelConfig).get()
|
||||
}
|
||||
|
||||
internal fun get(): (OidcLogoutConfigurer<HttpSecurity>) -> Unit {
|
||||
return { oidcLogout ->
|
||||
clientRegistrationRepository?.also { oidcLogout.clientRegistrationRepository(clientRegistrationRepository) }
|
||||
oidcSessionRegistry?.also { oidcLogout.oidcSessionRegistry(oidcSessionRegistry) }
|
||||
backChannel?.also { oidcLogout.backChannel(backChannel) }
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
/*
|
||||
* Copyright 2002-2023 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.security.config.annotation.web.oauth2.login
|
||||
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity
|
||||
import org.springframework.security.config.annotation.web.configurers.oauth2.client.OidcLogoutConfigurer
|
||||
|
||||
/**
|
||||
* A Kotlin DSL to configure the OIDC 1.0 Back-Channel configuration using
|
||||
* idiomatic Kotlin code.
|
||||
*
|
||||
* @author Josh Cummings
|
||||
* @since 6.2
|
||||
*/
|
||||
@OAuth2LoginSecurityMarker
|
||||
class OidcBackChannelLogoutDsl {
|
||||
internal fun get(): (OidcLogoutConfigurer<HttpSecurity>.BackChannelLogoutConfigurer) -> Unit {
|
||||
return { backChannel -> }
|
||||
}
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright 2002-2021 the original author or authors.
|
||||
* Copyright 2002-2023 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
|
@ -650,6 +650,38 @@ class ServerHttpSecurityDsl(private val http: ServerHttpSecurity, private val in
|
|||
this.http.oauth2ResourceServer(oauth2ResourceServerCustomizer)
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures logout support using an OpenID Connect 1.0 Provider.
|
||||
* A [ReactiveClientRegistrationRepository] is required and must be registered as a Bean or
|
||||
* configured via [ServerOidcLogoutDsl.clientRegistrationRepository].
|
||||
*
|
||||
* Example:
|
||||
*
|
||||
* ```
|
||||
* @Configuration
|
||||
* @EnableWebFluxSecurity
|
||||
* class SecurityConfig {
|
||||
*
|
||||
* @Bean
|
||||
* fun springWebFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain {
|
||||
* return http {
|
||||
* oauth2Login { }
|
||||
* oidcLogout {
|
||||
* backChannel { }
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @param oidcLogoutConfiguration custom configuration to configure the OIDC 1.0 Logout
|
||||
* @see [ServerOidcLogoutDsl]
|
||||
*/
|
||||
fun oidcLogout(oidcLogoutConfiguration: ServerOidcLogoutDsl.() -> Unit) {
|
||||
val oidcLogoutCustomizer = ServerOidcLogoutDsl().apply(oidcLogoutConfiguration).get()
|
||||
this.http.oidcLogout(oidcLogoutCustomizer)
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply all configurations to the provided [ServerHttpSecurity]
|
||||
*/
|
||||
|
|
|
@ -0,0 +1,30 @@
|
|||
/*
|
||||
* Copyright 2002-2023 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.security.config.web.server
|
||||
|
||||
/**
|
||||
* A Kotlin DSL to configure [ServerHttpSecurity] OIDC 1.0 Back-Channel Logout support using idiomatic Kotlin code.
|
||||
*
|
||||
* @author Josh Cummings
|
||||
* @since 6.2
|
||||
*/
|
||||
@ServerSecurityMarker
|
||||
class ServerOidcBackChannelLogoutDsl {
|
||||
internal fun get(): (ServerHttpSecurity.OidcLogoutSpec.BackChannelLogoutConfigurer) -> Unit {
|
||||
return { backChannel -> }
|
||||
}
|
||||
}
|
|
@ -0,0 +1,71 @@
|
|||
/*
|
||||
* Copyright 2002-2023 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.security.config.web.server
|
||||
|
||||
import org.springframework.security.oauth2.client.oidc.server.session.ReactiveOidcSessionRegistry
|
||||
import org.springframework.security.oauth2.client.registration.ReactiveClientRegistrationRepository
|
||||
|
||||
/**
|
||||
* A Kotlin DSL to configure [ServerHttpSecurity] OIDC 1.0 login using idiomatic Kotlin code.
|
||||
*
|
||||
* @author Josh Cummings
|
||||
* @since 6.2
|
||||
*/
|
||||
@ServerSecurityMarker
|
||||
class ServerOidcLogoutDsl {
|
||||
var clientRegistrationRepository: ReactiveClientRegistrationRepository? = null
|
||||
var oidcSessionRegistry: ReactiveOidcSessionRegistry? = null
|
||||
|
||||
private var backChannel: ((ServerHttpSecurity.OidcLogoutSpec.BackChannelLogoutConfigurer) -> Unit)? = null
|
||||
|
||||
/**
|
||||
* Enables OIDC 1.0 Back-Channel Logout support.
|
||||
*
|
||||
* Example:
|
||||
*
|
||||
* ```
|
||||
* @Configuration
|
||||
* @EnableWebFluxSecurity
|
||||
* class SecurityConfig {
|
||||
*
|
||||
* @Bean
|
||||
* fun springWebFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain {
|
||||
* return http {
|
||||
* oauth2Login { }
|
||||
* oidcLogout {
|
||||
* backChannel { }
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @param backChannelConfig custom configurations to configure OIDC 1.0 Back-Channel Logout support
|
||||
* @see [ServerOidcBackChannelLogoutDsl]
|
||||
*/
|
||||
fun backChannel(backChannelConfig: ServerOidcBackChannelLogoutDsl.() -> Unit) {
|
||||
this.backChannel = ServerOidcBackChannelLogoutDsl().apply(backChannelConfig).get()
|
||||
}
|
||||
|
||||
internal fun get(): (ServerHttpSecurity.OidcLogoutSpec) -> Unit {
|
||||
return { oidcLogout ->
|
||||
clientRegistrationRepository?.also { oidcLogout.clientRegistrationRepository(clientRegistrationRepository) }
|
||||
oidcSessionRegistry?.also { oidcLogout.oidcSessionRegistry(oidcSessionRegistry) }
|
||||
backChannel?.also { oidcLogout.backChannel(backChannel) }
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,550 @@
|
|||
/*
|
||||
* Copyright 2002-2023 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.security.config.annotation.web.configurers.oauth2.client;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.security.KeyPair;
|
||||
import java.security.KeyPairGenerator;
|
||||
import java.security.interfaces.RSAPublicKey;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import com.gargoylesoftware.htmlunit.util.UrlUtils;
|
||||
import com.nimbusds.jose.jwk.JWKSet;
|
||||
import com.nimbusds.jose.jwk.RSAKey;
|
||||
import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
|
||||
import com.nimbusds.jose.jwk.source.JWKSource;
|
||||
import com.nimbusds.jose.proc.SecurityContext;
|
||||
import com.nimbusds.oauth2.sdk.Scope;
|
||||
import com.nimbusds.oauth2.sdk.token.BearerAccessToken;
|
||||
import com.nimbusds.openid.connect.sdk.token.OIDCTokens;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpSession;
|
||||
import okhttp3.mockwebserver.Dispatcher;
|
||||
import okhttp3.mockwebserver.MockResponse;
|
||||
import okhttp3.mockwebserver.MockWebServer;
|
||||
import okhttp3.mockwebserver.RecordedRequest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
import org.springframework.mock.web.MockHttpSession;
|
||||
import org.springframework.mock.web.MockServletContext;
|
||||
import org.springframework.security.config.Customizer;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.test.SpringTestContext;
|
||||
import org.springframework.security.config.test.SpringTestContextExtension;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.core.userdetails.User;
|
||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||
import org.springframework.security.oauth2.client.oidc.authentication.logout.LogoutTokenClaimNames;
|
||||
import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcLogoutToken;
|
||||
import org.springframework.security.oauth2.client.oidc.authentication.logout.TestOidcLogoutTokens;
|
||||
import org.springframework.security.oauth2.client.oidc.session.InMemoryOidcSessionRegistry;
|
||||
import org.springframework.security.oauth2.client.oidc.session.OidcSessionRegistry;
|
||||
import org.springframework.security.oauth2.client.registration.ClientRegistration;
|
||||
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
|
||||
import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository;
|
||||
import org.springframework.security.oauth2.client.registration.TestClientRegistrations;
|
||||
import org.springframework.security.oauth2.core.oidc.OidcIdToken;
|
||||
import org.springframework.security.oauth2.core.oidc.TestOidcIdTokens;
|
||||
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
|
||||
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
|
||||
import org.springframework.security.oauth2.jwt.JwtEncoder;
|
||||
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
|
||||
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
|
||||
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.logout.LogoutHandler;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.MvcResult;
|
||||
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
|
||||
|
||||
import static org.hamcrest.Matchers.containsString;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.BDDMockito.willThrow;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.spy;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
/**
|
||||
* Tests for {@link OidcLogoutConfigurer}
|
||||
*/
|
||||
@ExtendWith(SpringTestContextExtension.class)
|
||||
public class OidcLogoutConfigurerTests {
|
||||
|
||||
@Autowired
|
||||
private MockMvc mvc;
|
||||
|
||||
@Autowired(required = false)
|
||||
private MockWebServer web;
|
||||
|
||||
@Autowired
|
||||
private ClientRegistration clientRegistration;
|
||||
|
||||
public final SpringTestContext spring = new SpringTestContext(this);
|
||||
|
||||
@Test
|
||||
void logoutWhenDefaultsThenRemotelyInvalidatesSessions() throws Exception {
|
||||
this.spring.register(WebServerConfig.class, OidcProviderConfig.class, DefaultConfig.class).autowire();
|
||||
String registrationId = this.clientRegistration.getRegistrationId();
|
||||
MockHttpSession session = login();
|
||||
String logoutToken = this.mvc.perform(get("/token/logout").session(session)).andExpect(status().isOk())
|
||||
.andReturn().getResponse().getContentAsString();
|
||||
this.mvc.perform(post(this.web.url("/logout/connect/back-channel/" + registrationId).toString())
|
||||
.param("logout_token", logoutToken)).andExpect(status().isOk());
|
||||
this.mvc.perform(get("/token/logout").session(session)).andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
void logoutWhenInvalidLogoutTokenThenBadRequest() throws Exception {
|
||||
this.spring.register(WebServerConfig.class, OidcProviderConfig.class, DefaultConfig.class).autowire();
|
||||
this.mvc.perform(get("/token/logout")).andExpect(status().isUnauthorized());
|
||||
String registrationId = this.clientRegistration.getRegistrationId();
|
||||
MvcResult result = this.mvc.perform(get("/oauth2/authorization/" + registrationId))
|
||||
.andExpect(status().isFound()).andReturn();
|
||||
MockHttpSession session = (MockHttpSession) result.getRequest().getSession();
|
||||
String redirectUrl = UrlUtils.decode(result.getResponse().getRedirectedUrl());
|
||||
String state = this.mvc
|
||||
.perform(get(redirectUrl).with(
|
||||
httpBasic(this.clientRegistration.getClientId(), this.clientRegistration.getClientSecret())))
|
||||
.andReturn().getResponse().getContentAsString();
|
||||
result = this.mvc.perform(get("/login/oauth2/code/" + registrationId).param("code", "code")
|
||||
.param("state", state).session(session)).andExpect(status().isFound()).andReturn();
|
||||
session = (MockHttpSession) result.getRequest().getSession();
|
||||
this.mvc.perform(post(this.web.url("/logout/connect/back-channel/" + registrationId).toString())
|
||||
.param("logout_token", "invalid")).andExpect(status().isBadRequest());
|
||||
this.mvc.perform(get("/token/logout").session(session)).andExpect(status().isOk());
|
||||
}
|
||||
|
||||
@Test
|
||||
void logoutWhenLogoutTokenSpecifiesOneSessionThenRemotelyInvalidatesOnlyThatSession() throws Exception {
|
||||
this.spring.register(WebServerConfig.class, OidcProviderConfig.class, DefaultConfig.class).autowire();
|
||||
String registrationId = this.clientRegistration.getRegistrationId();
|
||||
MockHttpSession one = login();
|
||||
MockHttpSession two = login();
|
||||
MockHttpSession three = login();
|
||||
String logoutToken = this.mvc.perform(get("/token/logout").session(one)).andExpect(status().isOk()).andReturn()
|
||||
.getResponse().getContentAsString();
|
||||
this.mvc.perform(post(this.web.url("/logout/connect/back-channel/" + registrationId).toString())
|
||||
.param("logout_token", logoutToken)).andExpect(status().isOk());
|
||||
this.mvc.perform(get("/token/logout").session(one)).andExpect(status().isUnauthorized());
|
||||
this.mvc.perform(get("/token/logout").session(two)).andExpect(status().isOk());
|
||||
logoutToken = this.mvc.perform(get("/token/logout/all").session(three)).andExpect(status().isOk()).andReturn()
|
||||
.getResponse().getContentAsString();
|
||||
this.mvc.perform(post(this.web.url("/logout/connect/back-channel/" + registrationId).toString())
|
||||
.param("logout_token", logoutToken)).andExpect(status().isOk());
|
||||
this.mvc.perform(get("/token/logout").session(two)).andExpect(status().isUnauthorized());
|
||||
this.mvc.perform(get("/token/logout").session(three)).andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
void logoutWhenRemoteLogoutFailsThenReportsPartialLogout() throws Exception {
|
||||
this.spring.register(WebServerConfig.class, OidcProviderConfig.class, WithBrokenLogoutConfig.class).autowire();
|
||||
LogoutHandler logoutHandler = this.spring.getContext().getBean(LogoutHandler.class);
|
||||
willThrow(IllegalStateException.class).given(logoutHandler).logout(any(), any(), any());
|
||||
String registrationId = this.clientRegistration.getRegistrationId();
|
||||
MockHttpSession one = login();
|
||||
String logoutToken = this.mvc.perform(get("/token/logout/all").session(one)).andExpect(status().isOk())
|
||||
.andReturn().getResponse().getContentAsString();
|
||||
this.mvc.perform(post(this.web.url("/logout/connect/back-channel/" + registrationId).toString())
|
||||
.param("logout_token", logoutToken)).andExpect(status().isBadRequest())
|
||||
.andExpect(content().string(containsString("partial_logout")));
|
||||
this.mvc.perform(get("/token/logout").session(one)).andExpect(status().isOk());
|
||||
}
|
||||
|
||||
@Test
|
||||
void logoutWhenCustomComponentsThenUses() throws Exception {
|
||||
this.spring.register(WebServerConfig.class, OidcProviderConfig.class, WithCustomComponentsConfig.class)
|
||||
.autowire();
|
||||
String registrationId = this.clientRegistration.getRegistrationId();
|
||||
MockHttpSession session = login();
|
||||
String logoutToken = this.mvc.perform(get("/token/logout").session(session)).andExpect(status().isOk())
|
||||
.andReturn().getResponse().getContentAsString();
|
||||
this.mvc.perform(post(this.web.url("/logout/connect/back-channel/" + registrationId).toString())
|
||||
.param("logout_token", logoutToken)).andExpect(status().isOk());
|
||||
this.mvc.perform(get("/token/logout").session(session)).andExpect(status().isUnauthorized());
|
||||
OidcSessionRegistry sessionRegistry = this.spring.getContext().getBean(OidcSessionRegistry.class);
|
||||
verify(sessionRegistry).saveSessionInformation(any());
|
||||
verify(sessionRegistry).removeSessionInformation(any(OidcLogoutToken.class));
|
||||
}
|
||||
|
||||
private MockHttpSession login() throws Exception {
|
||||
MockMvcDispatcher dispatcher = (MockMvcDispatcher) this.web.getDispatcher();
|
||||
this.mvc.perform(get("/token/logout")).andExpect(status().isUnauthorized());
|
||||
String registrationId = this.clientRegistration.getRegistrationId();
|
||||
MvcResult result = this.mvc.perform(get("/oauth2/authorization/" + registrationId))
|
||||
.andExpect(status().isFound()).andReturn();
|
||||
MockHttpSession session = (MockHttpSession) result.getRequest().getSession();
|
||||
String redirectUrl = UrlUtils.decode(result.getResponse().getRedirectedUrl());
|
||||
String state = this.mvc
|
||||
.perform(get(redirectUrl).with(
|
||||
httpBasic(this.clientRegistration.getClientId(), this.clientRegistration.getClientSecret())))
|
||||
.andReturn().getResponse().getContentAsString();
|
||||
result = this.mvc.perform(get("/login/oauth2/code/" + registrationId).param("code", "code")
|
||||
.param("state", state).session(session)).andExpect(status().isFound()).andReturn();
|
||||
session = (MockHttpSession) result.getRequest().getSession();
|
||||
dispatcher.registerSession(session);
|
||||
return session;
|
||||
}
|
||||
|
||||
@Configuration
|
||||
static class RegistrationConfig {
|
||||
|
||||
@Autowired(required = false)
|
||||
MockWebServer web;
|
||||
|
||||
@Bean
|
||||
ClientRegistration clientRegistration() {
|
||||
if (this.web == null) {
|
||||
return TestClientRegistrations.clientRegistration().build();
|
||||
}
|
||||
String issuer = this.web.url("/").toString();
|
||||
return TestClientRegistrations.clientRegistration().issuerUri(issuer).jwkSetUri(issuer + "jwks")
|
||||
.tokenUri(issuer + "token").userInfoUri(issuer + "user").scope("openid").build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
ClientRegistrationRepository clientRegistrationRepository(ClientRegistration clientRegistration) {
|
||||
return new InMemoryClientRegistrationRepository(clientRegistration);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
@Import(RegistrationConfig.class)
|
||||
static class DefaultConfig {
|
||||
|
||||
@Bean
|
||||
@Order(1)
|
||||
SecurityFilterChain filters(HttpSecurity http) throws Exception {
|
||||
// @formatter:off
|
||||
http
|
||||
.authorizeHttpRequests((authorize) -> authorize.anyRequest().authenticated())
|
||||
.oauth2Login(Customizer.withDefaults())
|
||||
.oidcLogout((oidc) -> oidc.backChannel(Customizer.withDefaults()));
|
||||
// @formatter:on
|
||||
|
||||
return http.build();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
@Import(RegistrationConfig.class)
|
||||
static class WithCustomComponentsConfig {
|
||||
|
||||
OidcSessionRegistry sessionRegistry = spy(new InMemoryOidcSessionRegistry());
|
||||
|
||||
@Bean
|
||||
@Order(1)
|
||||
SecurityFilterChain filters(HttpSecurity http) throws Exception {
|
||||
// @formatter:off
|
||||
http
|
||||
.authorizeHttpRequests((authorize) -> authorize.anyRequest().authenticated())
|
||||
.oauth2Login((oauth2) -> oauth2.oidcSessionRegistry(this.sessionRegistry))
|
||||
.oidcLogout((oidc) -> oidc.backChannel(Customizer.withDefaults()));
|
||||
// @formatter:on
|
||||
|
||||
return http.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
OidcSessionRegistry sessionRegistry() {
|
||||
return this.sessionRegistry;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
@Import(RegistrationConfig.class)
|
||||
static class WithBrokenLogoutConfig {
|
||||
|
||||
private final LogoutHandler logoutHandler = mock(LogoutHandler.class);
|
||||
|
||||
@Bean
|
||||
@Order(1)
|
||||
SecurityFilterChain filters(HttpSecurity http) throws Exception {
|
||||
// @formatter:off
|
||||
http
|
||||
.authorizeHttpRequests((authorize) -> authorize.anyRequest().authenticated())
|
||||
.logout((logout) -> logout.addLogoutHandler(this.logoutHandler))
|
||||
.oauth2Login(Customizer.withDefaults())
|
||||
.oidcLogout((oidc) -> oidc.backChannel(Customizer.withDefaults()));
|
||||
// @formatter:on
|
||||
|
||||
return http.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
LogoutHandler logoutHandler() {
|
||||
return this.logoutHandler;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
@EnableWebMvc
|
||||
@RestController
|
||||
static class OidcProviderConfig {
|
||||
|
||||
private static final RSAKey key = key();
|
||||
|
||||
private static final JWKSource<SecurityContext> jwks = jwks(key);
|
||||
|
||||
private static RSAKey key() {
|
||||
try {
|
||||
KeyPair pair = KeyPairGenerator.getInstance("RSA").generateKeyPair();
|
||||
return new RSAKey.Builder((RSAPublicKey) pair.getPublic()).privateKey(pair.getPrivate()).build();
|
||||
}
|
||||
catch (Exception ex) {
|
||||
throw new RuntimeException(ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static JWKSource<SecurityContext> jwks(RSAKey key) {
|
||||
try {
|
||||
return new ImmutableJWKSet<>(new JWKSet(key));
|
||||
}
|
||||
catch (Exception ex) {
|
||||
throw new RuntimeException(ex);
|
||||
}
|
||||
}
|
||||
|
||||
private final String username = "user";
|
||||
|
||||
private final JwtEncoder encoder = new NimbusJwtEncoder(jwks);
|
||||
|
||||
private String nonce;
|
||||
|
||||
@Autowired
|
||||
ClientRegistration registration;
|
||||
|
||||
@Bean
|
||||
@Order(0)
|
||||
SecurityFilterChain authorizationServer(HttpSecurity http, ClientRegistration registration) throws Exception {
|
||||
// @formatter:off
|
||||
http
|
||||
.securityMatcher("/jwks", "/login/oauth/authorize", "/nonce", "/token", "/token/logout", "/user")
|
||||
.authorizeHttpRequests((authorize) -> authorize
|
||||
.requestMatchers("/jwks").permitAll()
|
||||
.anyRequest().authenticated()
|
||||
)
|
||||
.httpBasic(Customizer.withDefaults())
|
||||
.oauth2ResourceServer((oauth2) -> oauth2
|
||||
.jwt((jwt) -> jwt.jwkSetUri(registration.getProviderDetails().getJwkSetUri()))
|
||||
);
|
||||
// @formatter:off
|
||||
|
||||
return http.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
UserDetailsService users(ClientRegistration registration) {
|
||||
return new InMemoryUserDetailsManager(User.withUsername(registration.getClientId())
|
||||
.password("{noop}" + registration.getClientSecret()).authorities("APP").build());
|
||||
}
|
||||
|
||||
@GetMapping("/login/oauth/authorize")
|
||||
String nonce(@RequestParam("nonce") String nonce, @RequestParam("state") String state) {
|
||||
this.nonce = nonce;
|
||||
return state;
|
||||
}
|
||||
|
||||
@PostMapping("/token")
|
||||
Map<String, Object> accessToken(HttpServletRequest request) {
|
||||
HttpSession session = request.getSession();
|
||||
JwtEncoderParameters parameters = JwtEncoderParameters
|
||||
.from(JwtClaimsSet.builder().id("id").subject(this.username)
|
||||
.issuer(this.registration.getProviderDetails().getIssuerUri()).issuedAt(Instant.now())
|
||||
.expiresAt(Instant.now().plusSeconds(86400)).claim("scope", "openid").build());
|
||||
String token = this.encoder.encode(parameters).getTokenValue();
|
||||
return new OIDCTokens(idToken(session.getId()), new BearerAccessToken(token, 86400, new Scope("openid")), null)
|
||||
.toJSONObject();
|
||||
}
|
||||
|
||||
String idToken(String sessionId) {
|
||||
OidcIdToken token = TestOidcIdTokens.idToken().issuer(this.registration.getProviderDetails().getIssuerUri())
|
||||
.subject(this.username).expiresAt(Instant.now().plusSeconds(86400))
|
||||
.audience(List.of(this.registration.getClientId())).nonce(this.nonce)
|
||||
.claim(LogoutTokenClaimNames.SID, sessionId).build();
|
||||
JwtEncoderParameters parameters = JwtEncoderParameters
|
||||
.from(JwtClaimsSet.builder().claims((claims) -> claims.putAll(token.getClaims())).build());
|
||||
return this.encoder.encode(parameters).getTokenValue();
|
||||
}
|
||||
|
||||
@GetMapping("/user")
|
||||
Map<String, Object> userinfo() {
|
||||
return Map.of("sub", this.username, "id", this.username);
|
||||
}
|
||||
|
||||
@GetMapping("/jwks")
|
||||
String jwks() {
|
||||
return new JWKSet(key).toString();
|
||||
}
|
||||
|
||||
@GetMapping("/token/logout")
|
||||
String logoutToken(@AuthenticationPrincipal OidcUser user) {
|
||||
OidcLogoutToken token = TestOidcLogoutTokens.withUser(user)
|
||||
.audience(List.of(this.registration.getClientId())).build();
|
||||
JwtEncoderParameters parameters = JwtEncoderParameters
|
||||
.from(JwtClaimsSet.builder().claims((claims) -> claims.putAll(token.getClaims())).build());
|
||||
return this.encoder.encode(parameters).getTokenValue();
|
||||
}
|
||||
|
||||
@GetMapping("/token/logout/all")
|
||||
String logoutTokenAll(@AuthenticationPrincipal OidcUser user) {
|
||||
OidcLogoutToken token = TestOidcLogoutTokens.withUser(user)
|
||||
.audience(List.of(this.registration.getClientId()))
|
||||
.claims((claims) -> claims.remove(LogoutTokenClaimNames.SID)).build();
|
||||
JwtEncoderParameters parameters = JwtEncoderParameters
|
||||
.from(JwtClaimsSet.builder().claims((claims) -> claims.putAll(token.getClaims())).build());
|
||||
return this.encoder.encode(parameters).getTokenValue();
|
||||
}
|
||||
}
|
||||
|
||||
@Configuration
|
||||
static class WebServerConfig {
|
||||
|
||||
private final MockWebServer server = new MockWebServer();
|
||||
|
||||
@Bean
|
||||
MockWebServer web(ObjectProvider<MockMvc> mvc) {
|
||||
this.server.setDispatcher(new MockMvcDispatcher(mvc));
|
||||
return this.server;
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
void shutdown() throws IOException {
|
||||
this.server.shutdown();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private static class MockMvcDispatcher extends Dispatcher {
|
||||
|
||||
private final Map<String, MockHttpSession> session = new ConcurrentHashMap<>();
|
||||
|
||||
private final ObjectProvider<MockMvc> mvcProvider;
|
||||
|
||||
private MockMvc mvc;
|
||||
|
||||
MockMvcDispatcher(ObjectProvider<MockMvc> mvc) {
|
||||
this.mvcProvider = mvc;
|
||||
}
|
||||
|
||||
@Override
|
||||
public MockResponse dispatch(RecordedRequest request) throws InterruptedException {
|
||||
this.mvc = this.mvcProvider.getObject();
|
||||
String method = request.getMethod();
|
||||
String path = request.getPath();
|
||||
String csrf = request.getHeader("X-CSRF-TOKEN");
|
||||
MockHttpSession session = session(request);
|
||||
MockHttpServletRequestBuilder builder;
|
||||
if ("GET".equals(method)) {
|
||||
builder = get(path);
|
||||
}
|
||||
else {
|
||||
builder = post(path).content(request.getBody().readUtf8());
|
||||
if (csrf != null) {
|
||||
builder.header("X-CSRF-TOKEN", csrf);
|
||||
}
|
||||
else {
|
||||
builder.with(csrf());
|
||||
}
|
||||
}
|
||||
for (Map.Entry<String, List<String>> header : request.getHeaders().toMultimap().entrySet()) {
|
||||
builder.header(header.getKey(), header.getValue().iterator().next());
|
||||
}
|
||||
try {
|
||||
MockHttpServletResponse mvcResponse = this.mvc.perform(builder.session(session)).andReturn().getResponse();
|
||||
return toMockResponse(mvcResponse);
|
||||
}
|
||||
catch (Exception ex) {
|
||||
MockResponse response = new MockResponse();
|
||||
response.setResponseCode(500);
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
void registerSession(MockHttpSession session) {
|
||||
this.session.put(session.getId(), session);
|
||||
}
|
||||
|
||||
private MockHttpSession session(RecordedRequest request) {
|
||||
String cookieHeaderValue = request.getHeader("Cookie");
|
||||
if (cookieHeaderValue == null) {
|
||||
return new MockHttpSession();
|
||||
}
|
||||
String[] cookies = cookieHeaderValue.split(";");
|
||||
for (String cookie : cookies) {
|
||||
String[] parts = cookie.split("=");
|
||||
if ("JSESSIONID".equals(parts[0])) {
|
||||
return this.session.computeIfAbsent(parts[1],
|
||||
(k) -> new MockHttpSession(new MockServletContext(), parts[1]));
|
||||
}
|
||||
}
|
||||
return new MockHttpSession();
|
||||
}
|
||||
|
||||
private MockResponse toMockResponse(MockHttpServletResponse mvcResponse) {
|
||||
MockResponse response = new MockResponse();
|
||||
response.setResponseCode(mvcResponse.getStatus());
|
||||
for (String name : mvcResponse.getHeaderNames()) {
|
||||
response.addHeader(name, mvcResponse.getHeaderValue(name));
|
||||
}
|
||||
response.setBody(getContentAsString(mvcResponse));
|
||||
return response;
|
||||
}
|
||||
|
||||
private String getContentAsString(MockHttpServletResponse response) {
|
||||
try {
|
||||
return response.getContentAsString();
|
||||
}
|
||||
catch (Exception ex) {
|
||||
throw new RuntimeException(ex);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,595 @@
|
|||
/*
|
||||
* Copyright 2002-2021 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.security.config.web.server;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.security.KeyPair;
|
||||
import java.security.KeyPairGenerator;
|
||||
import java.security.interfaces.RSAPublicKey;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import com.gargoylesoftware.htmlunit.util.UrlUtils;
|
||||
import com.nimbusds.jose.jwk.JWKSet;
|
||||
import com.nimbusds.jose.jwk.RSAKey;
|
||||
import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
|
||||
import com.nimbusds.jose.jwk.source.JWKSource;
|
||||
import com.nimbusds.jose.proc.SecurityContext;
|
||||
import com.nimbusds.oauth2.sdk.Scope;
|
||||
import com.nimbusds.oauth2.sdk.token.BearerAccessToken;
|
||||
import com.nimbusds.openid.connect.sdk.token.OIDCTokens;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
import okhttp3.mockwebserver.Dispatcher;
|
||||
import okhttp3.mockwebserver.MockResponse;
|
||||
import okhttp3.mockwebserver.MockWebServer;
|
||||
import okhttp3.mockwebserver.RecordedRequest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.http.ResponseCookie;
|
||||
import org.springframework.http.client.reactive.ClientHttpConnector;
|
||||
import org.springframework.security.authentication.TestingAuthenticationToken;
|
||||
import org.springframework.security.config.Customizer;
|
||||
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
|
||||
import org.springframework.security.config.test.SpringTestContext;
|
||||
import org.springframework.security.config.test.SpringTestContextExtension;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.core.userdetails.MapReactiveUserDetailsService;
|
||||
import org.springframework.security.core.userdetails.ReactiveUserDetailsService;
|
||||
import org.springframework.security.core.userdetails.User;
|
||||
import org.springframework.security.oauth2.client.oidc.authentication.logout.LogoutTokenClaimNames;
|
||||
import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcLogoutToken;
|
||||
import org.springframework.security.oauth2.client.oidc.authentication.logout.TestOidcLogoutTokens;
|
||||
import org.springframework.security.oauth2.client.oidc.server.session.InMemoryReactiveOidcSessionRegistry;
|
||||
import org.springframework.security.oauth2.client.oidc.server.session.ReactiveOidcSessionRegistry;
|
||||
import org.springframework.security.oauth2.client.registration.ClientRegistration;
|
||||
import org.springframework.security.oauth2.client.registration.InMemoryReactiveClientRegistrationRepository;
|
||||
import org.springframework.security.oauth2.client.registration.ReactiveClientRegistrationRepository;
|
||||
import org.springframework.security.oauth2.client.registration.TestClientRegistrations;
|
||||
import org.springframework.security.oauth2.core.oidc.OidcIdToken;
|
||||
import org.springframework.security.oauth2.core.oidc.TestOidcIdTokens;
|
||||
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
|
||||
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
|
||||
import org.springframework.security.oauth2.jwt.JwtEncoder;
|
||||
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
|
||||
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
|
||||
import org.springframework.security.web.server.SecurityWebFilterChain;
|
||||
import org.springframework.security.web.server.authentication.logout.ServerLogoutHandler;
|
||||
import org.springframework.security.web.server.util.matcher.OrServerWebExchangeMatcher;
|
||||
import org.springframework.security.web.server.util.matcher.PathPatternParserServerWebExchangeMatcher;
|
||||
import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher;
|
||||
import org.springframework.test.web.reactive.server.FluxExchangeResult;
|
||||
import org.springframework.test.web.reactive.server.WebTestClient;
|
||||
import org.springframework.test.web.reactive.server.WebTestClientConfigurer;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.context.ConfigurableWebApplicationContext;
|
||||
import org.springframework.web.reactive.config.EnableWebFlux;
|
||||
import org.springframework.web.reactive.function.BodyInserters;
|
||||
import org.springframework.web.server.WebSession;
|
||||
import org.springframework.web.server.adapter.WebHttpHandlerBuilder;
|
||||
|
||||
import static org.hamcrest.Matchers.containsString;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.BDDMockito.given;
|
||||
import static org.mockito.Mockito.atLeastOnce;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.spy;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers.csrf;
|
||||
import static org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers.mockAuthentication;
|
||||
import static org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers.springSecurity;
|
||||
|
||||
/**
|
||||
* Tests for
|
||||
* {@link ServerHttpSecurity.OAuth2ResourceServerSpec}
|
||||
*/
|
||||
@ExtendWith({ SpringTestContextExtension.class })
|
||||
public class OidcLogoutSpecTests {
|
||||
|
||||
private static final String SESSION_COOKIE_NAME = "SESSION";
|
||||
|
||||
private WebTestClient test;
|
||||
|
||||
@Autowired(required = false)
|
||||
private MockWebServer web;
|
||||
|
||||
@Autowired
|
||||
private ClientRegistration clientRegistration;
|
||||
|
||||
public final SpringTestContext spring = new SpringTestContext(this);
|
||||
|
||||
@Autowired
|
||||
public void setApplicationContext(ApplicationContext context) {
|
||||
this.test = WebTestClient.bindToApplicationContext(context)
|
||||
.apply(springSecurity())
|
||||
.configureClient().responseTimeout(Duration.ofDays(1))
|
||||
.build();
|
||||
if (context instanceof ConfigurableWebApplicationContext configurable) {
|
||||
configurable.getBeanFactory().registerResolvableDependency(WebTestClient.class, this.test);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void logoutWhenDefaultsThenRemotelyInvalidatesSessions() {
|
||||
this.spring.register(WebServerConfig.class, OidcProviderConfig.class, DefaultConfig.class).autowire();
|
||||
String registrationId = this.clientRegistration.getRegistrationId();
|
||||
String session = login();
|
||||
String logoutToken = this.test.mutateWith(session(session)).get().uri("/token/logout").exchange().expectStatus()
|
||||
.isOk().returnResult(String.class).getResponseBody().blockFirst();
|
||||
this.test.post().uri(this.web.url("/logout/connect/back-channel/" + registrationId).toString())
|
||||
.body(BodyInserters.fromFormData("logout_token", logoutToken)).exchange().expectStatus().isOk();
|
||||
this.test.mutateWith(session(session)).get().uri("/token/logout").exchange().expectStatus().isUnauthorized();
|
||||
}
|
||||
|
||||
@Test
|
||||
void logoutWhenInvalidLogoutTokenThenBadRequest() {
|
||||
this.spring.register(WebServerConfig.class, OidcProviderConfig.class, DefaultConfig.class).autowire();
|
||||
this.test.get().uri("/token/logout").exchange().expectStatus().isUnauthorized();
|
||||
String registrationId = this.clientRegistration.getRegistrationId();
|
||||
FluxExchangeResult<String> result = this.test.get().uri("/oauth2/authorization/" + registrationId).exchange()
|
||||
.expectStatus().isFound().returnResult(String.class);
|
||||
String session = sessionId(result);
|
||||
String redirectUrl = UrlUtils.decode(result.getResponseHeaders().getLocation().toString());
|
||||
String state = this.test
|
||||
.mutateWith(mockAuthentication(new TestingAuthenticationToken(this.clientRegistration.getClientId(),
|
||||
this.clientRegistration.getClientSecret(), "APP")))
|
||||
.get().uri(redirectUrl).exchange().returnResult(String.class).getResponseBody().blockFirst();
|
||||
result = this.test.get().uri("/login/oauth2/code/" + registrationId + "?code=code&state=" + state)
|
||||
.cookie("SESSION", session).exchange().expectStatus().isFound().returnResult(String.class);
|
||||
session = sessionId(result);
|
||||
this.test.post().uri(this.web.url("/logout/connect/back-channel/" + registrationId).toString())
|
||||
.body(BodyInserters.fromFormData("logout_token", "invalid")).exchange().expectStatus().isBadRequest();
|
||||
this.test.get().uri("/token/logout").cookie("SESSION", session).exchange().expectStatus().isOk();
|
||||
}
|
||||
|
||||
@Test
|
||||
void logoutWhenLogoutTokenSpecifiesOneSessionThenRemotelyInvalidatesOnlyThatSession() throws Exception {
|
||||
this.spring.register(WebServerConfig.class, OidcProviderConfig.class, DefaultConfig.class).autowire();
|
||||
String registrationId = this.clientRegistration.getRegistrationId();
|
||||
String one = login();
|
||||
String two = login();
|
||||
String three = login();
|
||||
String logoutToken = this.test.get().uri("/token/logout").cookie("SESSION", one).exchange().expectStatus()
|
||||
.isOk().returnResult(String.class).getResponseBody().blockFirst();
|
||||
this.test.post().uri(this.web.url("/logout/connect/back-channel/" + registrationId).toString())
|
||||
.body(BodyInserters.fromFormData("logout_token", logoutToken)).exchange().expectStatus().isOk();
|
||||
this.test.get().uri("/token/logout").cookie("SESSION", one).exchange().expectStatus().isUnauthorized();
|
||||
this.test.get().uri("/token/logout").cookie("SESSION", two).exchange().expectStatus().isOk();
|
||||
logoutToken = this.test.get().uri("/token/logout/all").cookie("SESSION", three).exchange().expectStatus().isOk()
|
||||
.returnResult(String.class).getResponseBody().blockFirst();
|
||||
this.test.post().uri(this.web.url("/logout/connect/back-channel/" + registrationId).toString())
|
||||
.body(BodyInserters.fromFormData("logout_token", logoutToken)).exchange().expectStatus().isOk();
|
||||
this.test.get().uri("/token/logout").cookie("SESSION", two).exchange().expectStatus().isUnauthorized();
|
||||
this.test.get().uri("/token/logout").cookie("SESSION", three).exchange().expectStatus().isUnauthorized();
|
||||
}
|
||||
|
||||
@Test
|
||||
void logoutWhenRemoteLogoutFailsThenReportsPartialLogout() {
|
||||
this.spring.register(WebServerConfig.class, OidcProviderConfig.class, WithBrokenLogoutConfig.class).autowire();
|
||||
ServerLogoutHandler logoutHandler = this.spring.getContext().getBean(ServerLogoutHandler.class);
|
||||
given(logoutHandler.logout(any(), any())).willReturn(Mono.error(() -> new IllegalStateException("illegal")));
|
||||
String registrationId = this.clientRegistration.getRegistrationId();
|
||||
String one = login();
|
||||
String logoutToken = this.test.get().uri("/token/logout/all").cookie("SESSION", one).exchange().expectStatus()
|
||||
.isOk().returnResult(String.class).getResponseBody().blockFirst();
|
||||
this.test.post().uri(this.web.url("/logout/connect/back-channel/" + registrationId).toString())
|
||||
.body(BodyInserters.fromFormData("logout_token", logoutToken)).exchange().expectStatus().isBadRequest()
|
||||
.expectBody(String.class).value(containsString("partial_logout"));
|
||||
this.test.get().uri("/token/logout").cookie("SESSION", one).exchange().expectStatus().isOk();
|
||||
}
|
||||
|
||||
@Test
|
||||
void logoutWhenCustomComponentsThenUses() {
|
||||
this.spring.register(WebServerConfig.class, OidcProviderConfig.class, WithCustomComponentsConfig.class)
|
||||
.autowire();
|
||||
String registrationId = this.clientRegistration.getRegistrationId();
|
||||
String sessionId = login();
|
||||
String logoutToken = this.test.get().uri("/token/logout").cookie("SESSION", sessionId).exchange().expectStatus()
|
||||
.isOk().returnResult(String.class).getResponseBody().blockFirst();
|
||||
this.test.post().uri(this.web.url("/logout/connect/back-channel/" + registrationId).toString())
|
||||
.body(BodyInserters.fromFormData("logout_token", logoutToken)).exchange().expectStatus().isOk();
|
||||
this.test.get().uri("/token/logout").cookie("SESSION", sessionId).exchange().expectStatus().isUnauthorized();
|
||||
ReactiveOidcSessionRegistry sessionRegistry = this.spring.getContext()
|
||||
.getBean(ReactiveOidcSessionRegistry.class);
|
||||
verify(sessionRegistry, atLeastOnce()).saveSessionInformation(any());
|
||||
verify(sessionRegistry, atLeastOnce()).removeSessionInformation(any(OidcLogoutToken.class));
|
||||
}
|
||||
|
||||
private String login() {
|
||||
this.test.get().uri("/token/logout").exchange().expectStatus().isUnauthorized();
|
||||
String registrationId = this.clientRegistration.getRegistrationId();
|
||||
FluxExchangeResult<String> result = this.test.get().uri("/oauth2/authorization/" + registrationId).exchange()
|
||||
.expectStatus().isFound().returnResult(String.class);
|
||||
String sessionId = sessionId(result);
|
||||
String redirectUrl = UrlUtils.decode(result.getResponseHeaders().getLocation().toString());
|
||||
result = this.test
|
||||
.mutateWith(mockAuthentication(new TestingAuthenticationToken(this.clientRegistration.getClientId(),
|
||||
this.clientRegistration.getClientSecret(), "APP")))
|
||||
.get().uri(redirectUrl).exchange().returnResult(String.class);
|
||||
String state = result.getResponseBody().blockFirst();
|
||||
result = this.test.mutateWith(session(sessionId)).get()
|
||||
.uri("/login/oauth2/code/" + registrationId + "?code=code&state=" + state).exchange().expectStatus()
|
||||
.isFound().returnResult(String.class);
|
||||
return sessionId(result);
|
||||
}
|
||||
|
||||
private String sessionId(FluxExchangeResult<?> result) {
|
||||
List<ResponseCookie> cookies = result.getResponseCookies().get(SESSION_COOKIE_NAME);
|
||||
if (cookies == null || cookies.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
return cookies.get(0).getValue();
|
||||
}
|
||||
|
||||
static SessionMutator session(String session) {
|
||||
return new SessionMutator(session);
|
||||
}
|
||||
|
||||
private record SessionMutator(String session) implements WebTestClientConfigurer {
|
||||
|
||||
@Override
|
||||
public void afterConfigurerAdded(WebTestClient.Builder builder, WebHttpHandlerBuilder httpHandlerBuilder,
|
||||
ClientHttpConnector connector) {
|
||||
builder.defaultCookie(SESSION_COOKIE_NAME, this.session);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Configuration
|
||||
static class RegistrationConfig {
|
||||
|
||||
@Autowired(required = false)
|
||||
MockWebServer web;
|
||||
|
||||
@Bean
|
||||
ClientRegistration clientRegistration() {
|
||||
if (this.web == null) {
|
||||
return TestClientRegistrations.clientRegistration().build();
|
||||
}
|
||||
String issuer = this.web.url("/").toString();
|
||||
return TestClientRegistrations.clientRegistration().issuerUri(issuer).jwkSetUri(issuer + "jwks")
|
||||
.tokenUri(issuer + "token").userInfoUri(issuer + "user").scope("openid").build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
ReactiveClientRegistrationRepository clientRegistrationRepository(ClientRegistration clientRegistration) {
|
||||
return new InMemoryReactiveClientRegistrationRepository(clientRegistration);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Configuration
|
||||
@EnableWebFluxSecurity
|
||||
@Import(RegistrationConfig.class)
|
||||
static class DefaultConfig {
|
||||
|
||||
@Bean
|
||||
@Order(1)
|
||||
SecurityWebFilterChain filters(ServerHttpSecurity http) throws Exception {
|
||||
// @formatter:off
|
||||
http
|
||||
.authorizeExchange((authorize) -> authorize.anyExchange().authenticated())
|
||||
.oauth2Login(Customizer.withDefaults())
|
||||
.oidcLogout((oidc) -> oidc.backChannel(Customizer.withDefaults()));
|
||||
// @formatter:on
|
||||
|
||||
return http.build();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Configuration
|
||||
@EnableWebFluxSecurity
|
||||
@Import(RegistrationConfig.class)
|
||||
static class WithCustomComponentsConfig {
|
||||
|
||||
ReactiveOidcSessionRegistry sessionRegistry = spy(new InMemoryReactiveOidcSessionRegistry());
|
||||
|
||||
@Bean
|
||||
@Order(1)
|
||||
SecurityWebFilterChain filters(ServerHttpSecurity http) throws Exception {
|
||||
// @formatter:off
|
||||
http
|
||||
.authorizeExchange((authorize) -> authorize.anyExchange().authenticated())
|
||||
.oauth2Login((oauth2) -> oauth2.oidcSessionRegistry(this.sessionRegistry))
|
||||
.oidcLogout((oidc) -> oidc.backChannel(Customizer.withDefaults()));
|
||||
// @formatter:on
|
||||
|
||||
return http.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
ReactiveOidcSessionRegistry sessionRegistry() {
|
||||
return this.sessionRegistry;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Configuration
|
||||
@EnableWebFluxSecurity
|
||||
@Import(RegistrationConfig.class)
|
||||
static class WithBrokenLogoutConfig {
|
||||
|
||||
private final ServerLogoutHandler logoutHandler = mock(ServerLogoutHandler.class);
|
||||
|
||||
@Bean
|
||||
@Order(1)
|
||||
SecurityWebFilterChain filters(ServerHttpSecurity http) throws Exception {
|
||||
// @formatter:off
|
||||
http
|
||||
.authorizeExchange((authorize) -> authorize.anyExchange().authenticated())
|
||||
.logout((logout) -> logout.logoutHandler(this.logoutHandler))
|
||||
.oauth2Login(Customizer.withDefaults())
|
||||
.oidcLogout((oidc) -> oidc.backChannel(Customizer.withDefaults()));
|
||||
// @formatter:on
|
||||
|
||||
return http.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
ServerLogoutHandler logoutHandler() {
|
||||
return this.logoutHandler;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Configuration
|
||||
@EnableWebFluxSecurity
|
||||
@EnableWebFlux
|
||||
@RestController
|
||||
static class OidcProviderConfig {
|
||||
|
||||
private static final RSAKey key = key();
|
||||
|
||||
private static final JWKSource<SecurityContext> jwks = jwks(key);
|
||||
|
||||
private static RSAKey key() {
|
||||
try {
|
||||
KeyPair pair = KeyPairGenerator.getInstance("RSA").generateKeyPair();
|
||||
return new RSAKey.Builder((RSAPublicKey) pair.getPublic()).privateKey(pair.getPrivate()).build();
|
||||
}
|
||||
catch (Exception ex) {
|
||||
throw new RuntimeException(ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static JWKSource<SecurityContext> jwks(RSAKey key) {
|
||||
try {
|
||||
return new ImmutableJWKSet<>(new JWKSet(key));
|
||||
}
|
||||
catch (Exception ex) {
|
||||
throw new RuntimeException(ex);
|
||||
}
|
||||
}
|
||||
|
||||
private final String username = "user";
|
||||
|
||||
private final JwtEncoder encoder = new NimbusJwtEncoder(jwks);
|
||||
|
||||
private String nonce;
|
||||
|
||||
@Autowired
|
||||
ClientRegistration registration;
|
||||
|
||||
static ServerWebExchangeMatcher or(String... patterns) {
|
||||
List<ServerWebExchangeMatcher> matchers = new ArrayList<>();
|
||||
for (String pattern : patterns) {
|
||||
matchers.add(new PathPatternParserServerWebExchangeMatcher(pattern));
|
||||
}
|
||||
return new OrServerWebExchangeMatcher(matchers);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@Order(0)
|
||||
SecurityWebFilterChain authorizationServer(ServerHttpSecurity http, ClientRegistration registration)
|
||||
throws Exception {
|
||||
// @formatter:off
|
||||
http
|
||||
.securityMatcher(or("/jwks", "/login/oauth/authorize", "/nonce", "/token", "/token/logout", "/user"))
|
||||
.authorizeExchange((authorize) -> authorize
|
||||
.pathMatchers("/jwks").permitAll()
|
||||
.anyExchange().authenticated()
|
||||
)
|
||||
.httpBasic(Customizer.withDefaults())
|
||||
.oauth2ResourceServer((oauth2) -> oauth2
|
||||
.jwt((jwt) -> jwt.jwkSetUri(registration.getProviderDetails().getJwkSetUri()))
|
||||
);
|
||||
// @formatter:off
|
||||
|
||||
return http.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
ReactiveUserDetailsService users(ClientRegistration registration) {
|
||||
return new MapReactiveUserDetailsService(User.withUsername(registration.getClientId())
|
||||
.password("{noop}" + registration.getClientSecret()).authorities("APP").build());
|
||||
}
|
||||
|
||||
@GetMapping("/login/oauth/authorize")
|
||||
String nonce(@RequestParam("nonce") String nonce, @RequestParam("state") String state) {
|
||||
this.nonce = nonce;
|
||||
return state;
|
||||
}
|
||||
|
||||
@PostMapping("/token")
|
||||
Map<String, Object> accessToken(WebSession session) {
|
||||
JwtEncoderParameters parameters = JwtEncoderParameters
|
||||
.from(JwtClaimsSet.builder().id("id").subject(this.username)
|
||||
.issuer(this.registration.getProviderDetails().getIssuerUri()).issuedAt(Instant.now())
|
||||
.expiresAt(Instant.now().plusSeconds(86400)).claim("scope", "openid").build());
|
||||
String token = this.encoder.encode(parameters).getTokenValue();
|
||||
return new OIDCTokens(idToken(session.getId()), new BearerAccessToken(token, 86400, new Scope("openid")), null)
|
||||
.toJSONObject();
|
||||
}
|
||||
|
||||
String idToken(String sessionId) {
|
||||
OidcIdToken token = TestOidcIdTokens.idToken().issuer(this.registration.getProviderDetails().getIssuerUri())
|
||||
.subject(this.username).expiresAt(Instant.now().plusSeconds(86400))
|
||||
.audience(List.of(this.registration.getClientId())).nonce(this.nonce)
|
||||
.claim(LogoutTokenClaimNames.SID, sessionId).build();
|
||||
JwtEncoderParameters parameters = JwtEncoderParameters
|
||||
.from(JwtClaimsSet.builder().claims((claims) -> claims.putAll(token.getClaims())).build());
|
||||
return this.encoder.encode(parameters).getTokenValue();
|
||||
}
|
||||
|
||||
@GetMapping("/user")
|
||||
Map<String, Object> userinfo() {
|
||||
return Map.of("sub", this.username, "id", this.username);
|
||||
}
|
||||
|
||||
@GetMapping("/jwks")
|
||||
String jwks() {
|
||||
return new JWKSet(key).toString();
|
||||
}
|
||||
|
||||
@GetMapping("/token/logout")
|
||||
String logoutToken(@AuthenticationPrincipal OidcUser user) {
|
||||
OidcLogoutToken token = TestOidcLogoutTokens.withUser(user)
|
||||
.audience(List.of(this.registration.getClientId())).build();
|
||||
JwtEncoderParameters parameters = JwtEncoderParameters
|
||||
.from(JwtClaimsSet.builder().claims((claims) -> claims.putAll(token.getClaims())).build());
|
||||
return this.encoder.encode(parameters).getTokenValue();
|
||||
}
|
||||
|
||||
@GetMapping("/token/logout/all")
|
||||
String logoutTokenAll(@AuthenticationPrincipal OidcUser user) {
|
||||
OidcLogoutToken token = TestOidcLogoutTokens.withUser(user)
|
||||
.audience(List.of(this.registration.getClientId()))
|
||||
.claims((claims) -> claims.remove(LogoutTokenClaimNames.SID)).build();
|
||||
JwtEncoderParameters parameters = JwtEncoderParameters
|
||||
.from(JwtClaimsSet.builder().claims((claims) -> claims.putAll(token.getClaims())).build());
|
||||
return this.encoder.encode(parameters).getTokenValue();
|
||||
}
|
||||
}
|
||||
|
||||
@Configuration
|
||||
static class WebServerConfig {
|
||||
|
||||
private final MockWebServer server = new MockWebServer();
|
||||
|
||||
@Bean
|
||||
MockWebServer web(ObjectProvider<WebTestClient> web) {
|
||||
this.server.setDispatcher(new WebTestClientDispatcher(web));
|
||||
return this.server;
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
void shutdown() throws IOException {
|
||||
this.server.shutdown();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private static class WebTestClientDispatcher extends Dispatcher {
|
||||
|
||||
private final ObjectProvider<WebTestClient> webProvider;
|
||||
|
||||
private WebTestClient web;
|
||||
|
||||
WebTestClientDispatcher(ObjectProvider<WebTestClient> web) {
|
||||
this.webProvider = web;
|
||||
}
|
||||
|
||||
@Override
|
||||
public MockResponse dispatch(RecordedRequest request) throws InterruptedException {
|
||||
this.web = this.webProvider.getObject();
|
||||
String method = request.getMethod();
|
||||
String path = request.getPath();
|
||||
String csrf = request.getHeader("X-CSRF-TOKEN");
|
||||
String sessionId = session(request);
|
||||
WebTestClient.RequestHeadersSpec<?> r;
|
||||
if ("GET".equals(method)) {
|
||||
r = this.web.get().uri(path);
|
||||
}
|
||||
else {
|
||||
WebTestClient.RequestBodySpec body;
|
||||
if (csrf == null) {
|
||||
body = this.web.mutateWith(csrf()).post().uri(path);
|
||||
}
|
||||
else {
|
||||
body = this.web.post().uri(path).header("X-CSRF-TOKEN", csrf);
|
||||
}
|
||||
body.body(BodyInserters.fromValue(request.getBody().readUtf8()));
|
||||
r = body;
|
||||
}
|
||||
for (Map.Entry<String, List<String>> header : request.getHeaders().toMultimap().entrySet()) {
|
||||
if (header.getKey().equalsIgnoreCase("Cookie")) {
|
||||
continue;
|
||||
}
|
||||
r.header(header.getKey(), header.getValue().iterator().next());
|
||||
}
|
||||
if (sessionId != null) {
|
||||
r.cookie(SESSION_COOKIE_NAME, sessionId);
|
||||
}
|
||||
|
||||
try {
|
||||
FluxExchangeResult<String> result = r.exchange().returnResult(String.class);
|
||||
return toMockResponse(result);
|
||||
}
|
||||
catch (Exception ex) {
|
||||
MockResponse response = new MockResponse();
|
||||
response.setResponseCode(500);
|
||||
response.setBody(ex.getMessage());
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
private String session(RecordedRequest request) {
|
||||
String cookieHeaderValue = request.getHeader("Cookie");
|
||||
if (cookieHeaderValue == null) {
|
||||
return null;
|
||||
}
|
||||
String[] cookies = cookieHeaderValue.split(";");
|
||||
for (String cookie : cookies) {
|
||||
String[] parts = cookie.split("=");
|
||||
if (SESSION_COOKIE_NAME.equals(parts[0])) {
|
||||
return parts[1];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private MockResponse toMockResponse(FluxExchangeResult<String> result) {
|
||||
MockResponse response = new MockResponse();
|
||||
response.setResponseCode(result.getStatus().value());
|
||||
for (String name : result.getResponseHeaders().keySet()) {
|
||||
response.addHeader(name, result.getResponseHeaders().getFirst(name));
|
||||
}
|
||||
String body = result.getResponseBody().blockFirst();
|
||||
if (body != null) {
|
||||
response.setBody(body);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
|
@ -717,7 +717,7 @@ public class ServerHttpSecurityTests {
|
|||
|
||||
private <T extends WebFilter> Optional<T> getWebFilter(SecurityWebFilterChain filterChain, Class<T> filterClass) {
|
||||
return (Optional<T>) filterChain.getWebFilters().filter(Objects::nonNull)
|
||||
.filter((filter) -> filter.getClass().isAssignableFrom(filterClass)).singleOrEmpty().blockOptional();
|
||||
.filter((filter) -> filterClass.isAssignableFrom(filter.getClass())).singleOrEmpty().blockOptional();
|
||||
}
|
||||
|
||||
private WebTestClient buildClient() {
|
||||
|
|
|
@ -0,0 +1,87 @@
|
|||
/*
|
||||
* Copyright 2002-2022 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.security.config.annotation.web
|
||||
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.extension.ExtendWith
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
|
||||
import org.springframework.security.config.test.SpringTestContext
|
||||
import org.springframework.security.config.test.SpringTestContextExtension
|
||||
import org.springframework.security.oauth2.client.registration.ClientRegistration
|
||||
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository
|
||||
import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository
|
||||
import org.springframework.security.oauth2.client.registration.TestClientRegistrations
|
||||
import org.springframework.security.web.SecurityFilterChain
|
||||
import org.springframework.test.web.servlet.MockMvc
|
||||
import org.springframework.test.web.servlet.post
|
||||
|
||||
/**
|
||||
* Tests for [OAuth2ClientDsl]
|
||||
*
|
||||
* @author Eleftheria Stein
|
||||
*/
|
||||
@ExtendWith(SpringTestContextExtension::class)
|
||||
class OidcLogoutDslTests {
|
||||
@JvmField
|
||||
val spring = SpringTestContext(this)
|
||||
|
||||
@Autowired
|
||||
lateinit var mockMvc: MockMvc
|
||||
|
||||
@Test
|
||||
fun `oidcLogout when invalid token then errors`() {
|
||||
this.spring.register(ClientRepositoryConfig::class.java).autowire()
|
||||
val clientRegistration = this.spring.context.getBean(ClientRegistration::class.java)
|
||||
this.mockMvc.post("/logout/connect/back-channel/" + clientRegistration.registrationId) {
|
||||
param("logout_token", "token")
|
||||
}.andExpect { status { isBadRequest() } }
|
||||
}
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
open class ClientRepositoryConfig {
|
||||
|
||||
@Bean
|
||||
open fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
|
||||
http {
|
||||
oauth2Login { }
|
||||
oidcLogout {
|
||||
backChannel { }
|
||||
}
|
||||
authorizeHttpRequests {
|
||||
authorize(anyRequest, authenticated)
|
||||
}
|
||||
}
|
||||
return http.build()
|
||||
}
|
||||
|
||||
@Bean
|
||||
open fun clientRegistration(): ClientRegistration {
|
||||
return TestClientRegistrations.clientRegistration().build()
|
||||
}
|
||||
|
||||
@Bean
|
||||
open fun clientRegistrationRepository(clientRegistration: ClientRegistration): ClientRegistrationRepository {
|
||||
return InMemoryClientRegistrationRepository(clientRegistration)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,97 @@
|
|||
/*
|
||||
* Copyright 2002-2023 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.security.config.web.server
|
||||
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.extension.ExtendWith
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.context.ApplicationContext
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity
|
||||
import org.springframework.security.config.test.SpringTestContext
|
||||
import org.springframework.security.config.test.SpringTestContextExtension
|
||||
import org.springframework.security.oauth2.client.registration.ClientRegistration
|
||||
import org.springframework.security.oauth2.client.registration.InMemoryReactiveClientRegistrationRepository
|
||||
import org.springframework.security.oauth2.client.registration.ReactiveClientRegistrationRepository
|
||||
import org.springframework.security.oauth2.client.registration.TestClientRegistrations
|
||||
import org.springframework.security.web.server.SecurityWebFilterChain
|
||||
import org.springframework.test.web.reactive.server.WebTestClient
|
||||
import org.springframework.web.reactive.config.EnableWebFlux
|
||||
import org.springframework.web.reactive.function.BodyInserters
|
||||
|
||||
/**
|
||||
* Tests for [ServerOidcLogoutDsl]
|
||||
*
|
||||
* @author Josh Cummings
|
||||
*/
|
||||
@ExtendWith(SpringTestContextExtension::class)
|
||||
class ServerOidcLogoutDslTests {
|
||||
@JvmField
|
||||
val spring = SpringTestContext(this)
|
||||
|
||||
private lateinit var client: WebTestClient
|
||||
|
||||
@Autowired
|
||||
fun setup(context: ApplicationContext) {
|
||||
this.client = WebTestClient
|
||||
.bindToApplicationContext(context)
|
||||
.configureClient()
|
||||
.build()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `oidcLogout when invalid token then errors`() {
|
||||
this.spring.register(ClientRepositoryConfig::class.java).autowire()
|
||||
val clientRegistration = this.spring.context.getBean(ClientRegistration::class.java)
|
||||
this.client.post()
|
||||
.uri("/logout/connect/back-channel/" + clientRegistration.registrationId)
|
||||
.body(BodyInserters.fromFormData("logout_token", "token"))
|
||||
.exchange()
|
||||
.expectStatus().isBadRequest
|
||||
}
|
||||
|
||||
@Configuration
|
||||
@EnableWebFlux
|
||||
@EnableWebFluxSecurity
|
||||
open class ClientRepositoryConfig {
|
||||
|
||||
@Bean
|
||||
open fun securityFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain {
|
||||
return http {
|
||||
oauth2Login { }
|
||||
oidcLogout {
|
||||
backChannel { }
|
||||
}
|
||||
authorizeExchange {
|
||||
authorize(anyExchange, authenticated)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Bean
|
||||
open fun clientRegistration(): ClientRegistration {
|
||||
return TestClientRegistrations.clientRegistration().build()
|
||||
}
|
||||
|
||||
@Bean
|
||||
open fun clientRegistrationRepository(clientRegistration: ClientRegistration): ReactiveClientRegistrationRepository {
|
||||
return InMemoryReactiveClientRegistrationRepository(clientRegistration)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -700,111 +700,5 @@ For MAC based algorithms such as `HS256`, `HS384` or `HS512`, the `client-secret
|
|||
[TIP]
|
||||
If more than one `ClientRegistration` is configured for OpenID Connect 1.0 Authentication, the JWS algorithm resolver may evaluate the provided `ClientRegistration` to determine which algorithm to return.
|
||||
|
||||
|
||||
[[webflux-oauth2-login-advanced-oidc-logout]]
|
||||
== OpenID Connect 1.0 Logout
|
||||
|
||||
OpenID Connect Session Management 1.0 allows the ability to log out the End-User at the Provider using the Client.
|
||||
One of the strategies available is https://openid.net/specs/openid-connect-rpinitiated-1_0.html[RP-Initiated Logout].
|
||||
|
||||
If the OpenID Provider supports both Session Management and https://openid.net/specs/openid-connect-discovery-1_0.html[Discovery], the client may obtain the `end_session_endpoint` `URL` from the OpenID Provider's https://openid.net/specs/openid-connect-session-1_0.html#OPMetadata[Discovery Metadata].
|
||||
This can be achieved by configuring the `ClientRegistration` with the `issuer-uri`, as in the following example:
|
||||
|
||||
[source,yaml]
|
||||
----
|
||||
spring:
|
||||
security:
|
||||
oauth2:
|
||||
client:
|
||||
registration:
|
||||
okta:
|
||||
client-id: okta-client-id
|
||||
client-secret: okta-client-secret
|
||||
...
|
||||
provider:
|
||||
okta:
|
||||
issuer-uri: https://dev-1234.oktapreview.com
|
||||
----
|
||||
|
||||
...and the `OidcClientInitiatedServerLogoutSuccessHandler`, which implements RP-Initiated Logout, may be configured as follows:
|
||||
|
||||
[tabs]
|
||||
======
|
||||
Java::
|
||||
+
|
||||
[source,java,role="primary",subs="-attributes"]
|
||||
----
|
||||
@Configuration
|
||||
@EnableWebFluxSecurity
|
||||
public class OAuth2LoginSecurityConfig {
|
||||
|
||||
@Autowired
|
||||
private ReactiveClientRegistrationRepository clientRegistrationRepository;
|
||||
|
||||
@Bean
|
||||
public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) {
|
||||
http
|
||||
.authorizeExchange(authorize -> authorize
|
||||
.anyExchange().authenticated()
|
||||
)
|
||||
.oauth2Login(withDefaults())
|
||||
.logout(logout -> logout
|
||||
.logoutSuccessHandler(oidcLogoutSuccessHandler())
|
||||
);
|
||||
|
||||
return http.build();
|
||||
}
|
||||
|
||||
private ServerLogoutSuccessHandler oidcLogoutSuccessHandler() {
|
||||
OidcClientInitiatedServerLogoutSuccessHandler oidcLogoutSuccessHandler =
|
||||
new OidcClientInitiatedServerLogoutSuccessHandler(this.clientRegistrationRepository);
|
||||
|
||||
// Sets the location that the End-User's User Agent will be redirected to
|
||||
// after the logout has been performed at the Provider
|
||||
oidcLogoutSuccessHandler.setPostLogoutRedirectUri("{baseUrl}");
|
||||
|
||||
return oidcLogoutSuccessHandler;
|
||||
}
|
||||
}
|
||||
----
|
||||
|
||||
Kotlin::
|
||||
+
|
||||
[source,kotlin,role="secondary",subs="-attributes"]
|
||||
----
|
||||
@Configuration
|
||||
@EnableWebFluxSecurity
|
||||
class OAuth2LoginSecurityConfig {
|
||||
|
||||
@Autowired
|
||||
private lateinit var clientRegistrationRepository: ReactiveClientRegistrationRepository
|
||||
|
||||
@Bean
|
||||
fun securityWebFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain {
|
||||
http {
|
||||
authorizeExchange {
|
||||
authorize(anyExchange, authenticated)
|
||||
}
|
||||
oauth2Login { }
|
||||
logout {
|
||||
logoutSuccessHandler = oidcLogoutSuccessHandler()
|
||||
}
|
||||
}
|
||||
|
||||
return http.build()
|
||||
}
|
||||
|
||||
private fun oidcLogoutSuccessHandler(): ServerLogoutSuccessHandler {
|
||||
val oidcLogoutSuccessHandler = OidcClientInitiatedServerLogoutSuccessHandler(clientRegistrationRepository)
|
||||
|
||||
// Sets the location that the End-User's User Agent will be redirected to
|
||||
// after the logout has been performed at the Provider
|
||||
oidcLogoutSuccessHandler.setPostLogoutRedirectUri("{baseUrl}")
|
||||
return oidcLogoutSuccessHandler
|
||||
}
|
||||
}
|
||||
----
|
||||
======
|
||||
|
||||
NOTE: `OidcClientInitiatedServerLogoutSuccessHandler` supports the `+{baseUrl}+` placeholder.
|
||||
If used, the application's base URL, like `https://app.example.org`, will replace it at request time.
|
||||
Then, you can proceed to configure xref:reactive/oauth2/login/logout.adoc[logout].
|
||||
|
|
|
@ -0,0 +1,267 @@
|
|||
= OIDC Logout
|
||||
|
||||
Once an end user is able to login to your application, it's important to consider how they will log out.
|
||||
|
||||
Generally speaking, there are three use cases for you to consider:
|
||||
|
||||
1. I want to perform only a local logout
|
||||
2. I want to log out both my application and the OIDC Provider, initiated by my application
|
||||
3. I want to log out both my application and the OIDC Provider, initiated by the OIDC Provider
|
||||
|
||||
[[configure-local-logout]]
|
||||
== Local Logout
|
||||
|
||||
To perform a local logout, no special OIDC configuration is needed.
|
||||
Spring Security automatically stands up a local logout endpoint, which you can xref:reactive/authentication/logout.adoc[configure through the `logout()` DSL].
|
||||
|
||||
[[configure-client-initiated-oidc-logout]]
|
||||
[[oauth2login-advanced-oidc-logout]]
|
||||
== OpenID Connect 1.0 Client-Initiated Logout
|
||||
|
||||
OpenID Connect Session Management 1.0 allows the ability to log out the end user at the Provider by using the Client.
|
||||
One of the strategies available is https://openid.net/specs/openid-connect-rpinitiated-1_0.html[RP-Initiated Logout].
|
||||
|
||||
If the OpenID Provider supports both Session Management and https://openid.net/specs/openid-connect-discovery-1_0.html[Discovery], the client can obtain the `end_session_endpoint` `URL` from the OpenID Provider's https://openid.net/specs/openid-connect-session-1_0.html#OPMetadata[Discovery Metadata].
|
||||
You can do so by configuring the `ClientRegistration` with the `issuer-uri`, as follows:
|
||||
|
||||
[source,yaml]
|
||||
----
|
||||
spring:
|
||||
security:
|
||||
oauth2:
|
||||
client:
|
||||
registration:
|
||||
okta:
|
||||
client-id: okta-client-id
|
||||
client-secret: okta-client-secret
|
||||
...
|
||||
provider:
|
||||
okta:
|
||||
issuer-uri: https://dev-1234.oktapreview.com
|
||||
----
|
||||
|
||||
Also, you should configure `OidcClientInitiatedServerLogoutSuccessHandler`, which implements RP-Initiated Logout, as follows:
|
||||
|
||||
[tabs]
|
||||
======
|
||||
Java::
|
||||
+
|
||||
[source,java,role="primary"]
|
||||
----
|
||||
@Configuration
|
||||
@EnableWebFluxSecurity
|
||||
public class OAuth2LoginSecurityConfig {
|
||||
|
||||
@Autowired
|
||||
private ReactiveClientRegistrationRepository clientRegistrationRepository;
|
||||
|
||||
@Bean
|
||||
public SecurityWebFilterChain filterChain(ServerHttpSecurity http) throws Exception {
|
||||
http
|
||||
.authorizeExchange((authorize) -> authorize
|
||||
.anyExchange().authenticated()
|
||||
)
|
||||
.oauth2Login(withDefaults())
|
||||
.logout((logout) -> logout
|
||||
.logoutSuccessHandler(oidcLogoutSuccessHandler())
|
||||
);
|
||||
return http.build();
|
||||
}
|
||||
|
||||
private ServerLogoutSuccessHandler oidcLogoutSuccessHandler() {
|
||||
OidcClientInitiatedServerLogoutSuccessHandler oidcLogoutSuccessHandler =
|
||||
new OidcClientInitiatedServerLogoutSuccessHandler(this.clientRegistrationRepository);
|
||||
|
||||
// Sets the location that the End-User's User Agent will be redirected to
|
||||
// after the logout has been performed at the Provider
|
||||
oidcLogoutSuccessHandler.setPostLogoutRedirectUri("{baseUrl}");
|
||||
|
||||
return oidcLogoutSuccessHandler;
|
||||
}
|
||||
}
|
||||
----
|
||||
|
||||
Kotlin::
|
||||
+
|
||||
[source,kotlin,role="secondary"]
|
||||
----
|
||||
@Configuration
|
||||
@EnableWebFluxSecurity
|
||||
class OAuth2LoginSecurityConfig {
|
||||
@Autowired
|
||||
private lateinit var clientRegistrationRepository: ReactiveClientRegistrationRepository
|
||||
|
||||
@Bean
|
||||
open fun filterChain(http: ServerHttpSecurity): SecurityWebFilterChain {
|
||||
http {
|
||||
authorizeExchange {
|
||||
authorize(anyExchange, authenticated)
|
||||
}
|
||||
oauth2Login { }
|
||||
logout {
|
||||
logoutSuccessHandler = oidcLogoutSuccessHandler()
|
||||
}
|
||||
}
|
||||
return http.build()
|
||||
}
|
||||
|
||||
private fun oidcLogoutSuccessHandler(): ServerLogoutSuccessHandler {
|
||||
val oidcLogoutSuccessHandler = OidcClientInitiatedServerLogoutSuccessHandler(clientRegistrationRepository)
|
||||
|
||||
// Sets the location that the End-User's User Agent will be redirected to
|
||||
// after the logout has been performed at the Provider
|
||||
oidcLogoutSuccessHandler.setPostLogoutRedirectUri("{baseUrl}")
|
||||
return oidcLogoutSuccessHandler
|
||||
}
|
||||
}
|
||||
----
|
||||
======
|
||||
|
||||
[NOTE]
|
||||
====
|
||||
`OidcClientInitiatedServerLogoutSuccessHandler` supports the `+{baseUrl}+` placeholder.
|
||||
If used, the application's base URL, such as `https://app.example.org`, replaces it at request time.
|
||||
====
|
||||
|
||||
[[configure-provider-initiated-oidc-logout]]
|
||||
== OpenID Connect 1.0 Back-Channel Logout
|
||||
|
||||
OpenID Connect Session Management 1.0 allows the ability to log out the end user at the Client by having the Provider make an API call to the Client.
|
||||
This is referred to as https://openid.net/specs/openid-connect-backchannel-1_0.html[OIDC Back-Channel Logout].
|
||||
|
||||
To enable this, you can stand up the Back-Channel Logout endpoint in the DSL like so:
|
||||
|
||||
[tabs]
|
||||
======
|
||||
Java::
|
||||
+
|
||||
[source=java,role="primary"]
|
||||
----
|
||||
@Bean
|
||||
public SecurityWebFilterChain filterChain(ServerHttpSecurity http) throws Exception {
|
||||
http
|
||||
.authorizeExchange((authorize) -> authorize
|
||||
.anyExchange().authenticated()
|
||||
)
|
||||
.oauth2Login(withDefaults())
|
||||
.oidcLogout((logout) -> logout
|
||||
.backChannel(Customizer.withDefaults())
|
||||
);
|
||||
return http.build();
|
||||
}
|
||||
----
|
||||
|
||||
Kotlin::
|
||||
+
|
||||
[source=kotlin,role="secondary"]
|
||||
----
|
||||
@Bean
|
||||
open fun filterChain(http: ServerHttpSecurity): SecurityWebFilterChain {
|
||||
http {
|
||||
authorizeExchange {
|
||||
authorize(anyExchange, authenticated)
|
||||
}
|
||||
oauth2Login { }
|
||||
oidcLogout {
|
||||
backChannel { }
|
||||
}
|
||||
}
|
||||
return http.build()
|
||||
}
|
||||
----
|
||||
======
|
||||
|
||||
And that's it!
|
||||
|
||||
This will stand up the endpoint `/logout/connect/back-channel/+{registrationId}` which the OIDC Provider can request to invalidate a given session of an end user in your application.
|
||||
|
||||
[NOTE]
|
||||
`oidcLogout` requires that `oauth2Login` also be configured.
|
||||
|
||||
[NOTE]
|
||||
`oidcLogout` requires that the session cookie be called `JSESSIONID` in order to correctly log out each session through a backchannel.
|
||||
|
||||
=== Back-Channel Logout Architecture
|
||||
|
||||
Consider a `ClientRegistration` whose identifier is `registrationId`.
|
||||
|
||||
The overall flow for a Back-Channel logout is like this:
|
||||
|
||||
1. At login time, Spring Security correlates the ID Token, CSRF Token, and Provider Session ID (if any) to your application's session id in its `ReactiveOidcSessionStrategy` implementation.
|
||||
2. Then at logout time, your OIDC Provider makes an API call to `/logout/connect/back-channel/registrationId` including a Logout Token that indicates either the `sub` (the End User) or the `sid` (the Provider Session ID) to logout.
|
||||
3. Spring Security validates the token's signature and claims.
|
||||
4. If the token contains a `sid` claim, then only the Client's session that correlates to that provider session is terminated.
|
||||
5. Otherwise, if the token contains a `sub` claim, then all that Client's sessions for that End User are terminated.
|
||||
|
||||
[NOTE]
|
||||
Remember that Spring Security's OIDC support is multi-tenant.
|
||||
This means that it will only terminate sessions whose Client matches the `aud` claim in the Logout Token.
|
||||
|
||||
=== Customizing the OIDC Provider Session Strategy
|
||||
|
||||
By default, Spring Security stores in-memory all links between the OIDC Provider session and the Client session.
|
||||
|
||||
There are a number of circumstances, like a clustered application, where it would be nice to store this instead in a separate location, like a database.
|
||||
|
||||
You can achieve this by configuring a custom `ReactiveOidcSessionStrategy`, like so:
|
||||
|
||||
[tabs]
|
||||
======
|
||||
Java::
|
||||
+
|
||||
[source=java,role="primary"]
|
||||
----
|
||||
@Component
|
||||
public final class MySpringDataOidcSessionStrategy implements OidcSessionStrategy {
|
||||
private final OidcProviderSessionRepository sessions;
|
||||
|
||||
// ...
|
||||
|
||||
@Override
|
||||
public void saveSessionInformation(OidcSessionInformation info) {
|
||||
this.sessions.save(info);
|
||||
}
|
||||
|
||||
@Override
|
||||
public OidcSessionInformation(String clientSessionId) {
|
||||
return this.sessions.removeByClientSessionId(clientSessionId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Iterable<OidcSessionInformation> removeSessionInformation(OidcLogoutToken token) {
|
||||
return token.getSessionId() != null ?
|
||||
this.sessions.removeBySessionIdAndIssuerAndAudience(...) :
|
||||
this.sessions.removeBySubjectAndIssuerAndAudience(...);
|
||||
}
|
||||
}
|
||||
----
|
||||
|
||||
Kotlin::
|
||||
+
|
||||
[source=kotlin,role="secondary"]
|
||||
----
|
||||
@Component
|
||||
class MySpringDataOidcSessionStrategy: ReactiveOidcSessionStrategy {
|
||||
val sessions: OidcProviderSessionRepository
|
||||
|
||||
// ...
|
||||
|
||||
@Override
|
||||
fun saveSessionInformation(info: OidcSessionInformation): Mono<Void> {
|
||||
return this.sessions.save(info)
|
||||
}
|
||||
|
||||
@Override
|
||||
fun removeSessionInformation(clientSessionId: String): Mono<OidcSessionInformation> {
|
||||
return this.sessions.removeByClientSessionId(clientSessionId);
|
||||
}
|
||||
|
||||
@Override
|
||||
fun removeSessionInformation(token: OidcLogoutToken): Flux<OidcSessionInformation> {
|
||||
return token.getSessionId() != null ?
|
||||
this.sessions.removeBySessionIdAndIssuerAndAudience(...) :
|
||||
this.sessions.removeBySubjectAndIssuerAndAudience(...);
|
||||
}
|
||||
}
|
||||
----
|
||||
======
|
|
@ -1037,3 +1037,6 @@ class OAuth2LoginSecurityConfig {
|
|||
`OidcClientInitiatedLogoutSuccessHandler` supports the `+{baseUrl}+` placeholder.
|
||||
If used, the application's base URL, such as `https://app.example.org`, replaces it at request time.
|
||||
====
|
||||
|
||||
[[oauth2login-advanced-oidc-logout]]
|
||||
Then, you can proceed to configure xref:reactive/oauth2/login/logout.adoc[logout]
|
||||
|
|
|
@ -0,0 +1,267 @@
|
|||
= OIDC Logout
|
||||
|
||||
Once an end user is able to login to your application, it's important to consider how they will log out.
|
||||
|
||||
Generally speaking, there are three use cases for you to consider:
|
||||
|
||||
1. I want to perform only a local logout
|
||||
2. I want to log out both my application and the OIDC Provider, initiated by my application
|
||||
3. I want to log out both my application and the OIDC Provider, initiated by the OIDC Provider
|
||||
|
||||
[[configure-local-logout]]
|
||||
== Local Logout
|
||||
|
||||
To perform a local logout, no special OIDC configuration is needed.
|
||||
Spring Security automatically stands up a local logout endpoint, which you can xref:servlet/authentication/logout.adoc[configure through the `logout()` DSL].
|
||||
|
||||
[[configure-client-initiated-oidc-logout]]
|
||||
== OpenID Connect 1.0 Client-Initiated Logout
|
||||
|
||||
OpenID Connect Session Management 1.0 allows the ability to log out the end user at the Provider by using the Client.
|
||||
One of the strategies available is https://openid.net/specs/openid-connect-rpinitiated-1_0.html[RP-Initiated Logout].
|
||||
|
||||
If the OpenID Provider supports both Session Management and https://openid.net/specs/openid-connect-discovery-1_0.html[Discovery], the client can obtain the `end_session_endpoint` `URL` from the OpenID Provider's https://openid.net/specs/openid-connect-session-1_0.html#OPMetadata[Discovery Metadata].
|
||||
You can do so by configuring the `ClientRegistration` with the `issuer-uri`, as follows:
|
||||
|
||||
[source,yaml]
|
||||
----
|
||||
spring:
|
||||
security:
|
||||
oauth2:
|
||||
client:
|
||||
registration:
|
||||
okta:
|
||||
client-id: okta-client-id
|
||||
client-secret: okta-client-secret
|
||||
...
|
||||
provider:
|
||||
okta:
|
||||
issuer-uri: https://dev-1234.oktapreview.com
|
||||
----
|
||||
|
||||
Also, you should configure `OidcClientInitiatedLogoutSuccessHandler`, which implements RP-Initiated Logout, as follows:
|
||||
|
||||
[tabs]
|
||||
======
|
||||
Java::
|
||||
+
|
||||
[source,java,role="primary"]
|
||||
----
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
public class OAuth2LoginSecurityConfig {
|
||||
|
||||
@Autowired
|
||||
private ClientRegistrationRepository clientRegistrationRepository;
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
.authorizeHttpRequests(authorize -> authorize
|
||||
.anyRequest().authenticated()
|
||||
)
|
||||
.oauth2Login(withDefaults())
|
||||
.logout(logout -> logout
|
||||
.logoutSuccessHandler(oidcLogoutSuccessHandler())
|
||||
);
|
||||
return http.build();
|
||||
}
|
||||
|
||||
private LogoutSuccessHandler oidcLogoutSuccessHandler() {
|
||||
OidcClientInitiatedLogoutSuccessHandler oidcLogoutSuccessHandler =
|
||||
new OidcClientInitiatedLogoutSuccessHandler(this.clientRegistrationRepository);
|
||||
|
||||
// Sets the location that the End-User's User Agent will be redirected to
|
||||
// after the logout has been performed at the Provider
|
||||
oidcLogoutSuccessHandler.setPostLogoutRedirectUri("{baseUrl}");
|
||||
|
||||
return oidcLogoutSuccessHandler;
|
||||
}
|
||||
}
|
||||
----
|
||||
|
||||
Kotlin::
|
||||
+
|
||||
[source,kotlin,role="secondary"]
|
||||
----
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
class OAuth2LoginSecurityConfig {
|
||||
@Autowired
|
||||
private lateinit var clientRegistrationRepository: ClientRegistrationRepository
|
||||
|
||||
@Bean
|
||||
open fun filterChain(http: HttpSecurity): SecurityFilterChain {
|
||||
http {
|
||||
authorizeHttpRequests {
|
||||
authorize(anyRequest, authenticated)
|
||||
}
|
||||
oauth2Login { }
|
||||
logout {
|
||||
logoutSuccessHandler = oidcLogoutSuccessHandler()
|
||||
}
|
||||
}
|
||||
return http.build()
|
||||
}
|
||||
|
||||
private fun oidcLogoutSuccessHandler(): LogoutSuccessHandler {
|
||||
val oidcLogoutSuccessHandler = OidcClientInitiatedLogoutSuccessHandler(clientRegistrationRepository)
|
||||
|
||||
// Sets the location that the End-User's User Agent will be redirected to
|
||||
// after the logout has been performed at the Provider
|
||||
oidcLogoutSuccessHandler.setPostLogoutRedirectUri("{baseUrl}")
|
||||
return oidcLogoutSuccessHandler
|
||||
}
|
||||
}
|
||||
----
|
||||
======
|
||||
|
||||
[NOTE]
|
||||
====
|
||||
`OidcClientInitiatedLogoutSuccessHandler` supports the `+{baseUrl}+` placeholder.
|
||||
If used, the application's base URL, such as `https://app.example.org`, replaces it at request time.
|
||||
====
|
||||
|
||||
[[configure-provider-initiated-oidc-logout]]
|
||||
== OpenID Connect 1.0 Back-Channel Logout
|
||||
|
||||
OpenID Connect Session Management 1.0 allows the ability to log out the end user at the Client by having the Provider make an API call to the Client.
|
||||
This is referred to as https://openid.net/specs/openid-connect-backchannel-1_0.html[OIDC Back-Channel Logout].
|
||||
|
||||
To enable this, you can stand up the Back-Channel Logout endpoint in the DSL like so:
|
||||
|
||||
[tabs]
|
||||
======
|
||||
Java::
|
||||
+
|
||||
[source=java,role="primary"]
|
||||
----
|
||||
@Bean
|
||||
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
.authorizeHttpRequests((authorize) -> authorize
|
||||
.anyRequest().authenticated()
|
||||
)
|
||||
.oauth2Login(withDefaults())
|
||||
.oidcLogout((logout) -> logout
|
||||
.backChannel(Customizer.withDefaults())
|
||||
);
|
||||
return http.build();
|
||||
}
|
||||
----
|
||||
|
||||
Kotlin::
|
||||
+
|
||||
[source=kotlin,role="secondary"]
|
||||
----
|
||||
@Bean
|
||||
open fun filterChain(http: HttpSecurity): SecurityFilterChain {
|
||||
http {
|
||||
authorizeRequests {
|
||||
authorize(anyRequest, authenticated)
|
||||
}
|
||||
oauth2Login { }
|
||||
oidcLogout {
|
||||
backChannel { }
|
||||
}
|
||||
}
|
||||
return http.build()
|
||||
}
|
||||
----
|
||||
======
|
||||
|
||||
And that's it!
|
||||
|
||||
This will stand up the endpoint `/logout/connect/back-channel/+{registrationId}` which the OIDC Provider can request to invalidate a given session of an end user in your application.
|
||||
|
||||
[NOTE]
|
||||
`oidcLogout` requires that `oauth2Login` also be configured.
|
||||
|
||||
[NOTE]
|
||||
`oidcLogout` requires that the session cookie be called `JSESSIONID` in order to correctly log out each session through a backchannel.
|
||||
|
||||
=== Back-Channel Logout Architecture
|
||||
|
||||
Consider a `ClientRegistration` whose identifier is `registrationId`.
|
||||
|
||||
The overall flow for a Back-Channel logout is like this:
|
||||
|
||||
1. At login time, Spring Security correlates the ID Token, CSRF Token, and Provider Session ID (if any) to your application's session id in its `OidcSessionStrategy` implementation.
|
||||
2. Then at logout time, your OIDC Provider makes an API call to `/logout/connect/back-channel/registrationId` including a Logout Token that indicates either the `sub` (the End User) or the `sid` (the Provider Session ID) to logout.
|
||||
3. Spring Security validates the token's signature and claims.
|
||||
4. If the token contains a `sid` claim, then only the Client's session that correlates to that provider session is terminated.
|
||||
5. Otherwise, if the token contains a `sub` claim, then all that Client's sessions for that End User are terminated.
|
||||
|
||||
[NOTE]
|
||||
Remember that Spring Security's OIDC support is multi-tenant.
|
||||
This means that it will only terminate sessions whose Client matches the `aud` claim in the Logout Token.
|
||||
|
||||
=== Customizing the OIDC Provider Session Strategy
|
||||
|
||||
By default, Spring Security stores in-memory all links between the OIDC Provider session and the Client session.
|
||||
|
||||
There are a number of circumstances, like a clustered application, where it would be nice to store this instead in a separate location, like a database.
|
||||
|
||||
You can achieve this by configuring a custom `OidcSessionStrategy`, like so:
|
||||
|
||||
[tabs]
|
||||
======
|
||||
Java::
|
||||
+
|
||||
[source=java,role="primary"]
|
||||
----
|
||||
@Component
|
||||
public final class MySpringDataOidcSessionStrategy implements OidcSessionStrategy {
|
||||
private final OidcProviderSessionRepository sessions;
|
||||
|
||||
// ...
|
||||
|
||||
@Override
|
||||
public void saveSessionInformation(OidcSessionInformation info) {
|
||||
this.sessions.save(info);
|
||||
}
|
||||
|
||||
@Override
|
||||
public OidcSessionInformation(String clientSessionId) {
|
||||
return this.sessions.removeByClientSessionId(clientSessionId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Iterable<OidcSessionInformation> removeSessionInformation(OidcLogoutToken token) {
|
||||
return token.getSessionId() != null ?
|
||||
this.sessions.removeBySessionIdAndIssuerAndAudience(...) :
|
||||
this.sessions.removeBySubjectAndIssuerAndAudience(...);
|
||||
}
|
||||
}
|
||||
----
|
||||
|
||||
Kotlin::
|
||||
+
|
||||
[source=kotlin,role="secondary"]
|
||||
----
|
||||
@Component
|
||||
class MySpringDataOidcSessionStrategy: OidcSessionStrategy {
|
||||
val sessions: OidcProviderSessionRepository
|
||||
|
||||
// ...
|
||||
|
||||
@Override
|
||||
fun saveSessionInformation(info: OidcSessionInformation) {
|
||||
this.sessions.save(info)
|
||||
}
|
||||
|
||||
@Override
|
||||
fun removeSessionInformation(clientSessionId: String): OidcSessionInformation {
|
||||
return this.sessions.removeByClientSessionId(clientSessionId);
|
||||
}
|
||||
|
||||
@Override
|
||||
fun removeSessionInformation(token: OidcLogoutToken): Iterable<OidcSessionInformation> {
|
||||
return token.getSessionId() != null ?
|
||||
this.sessions.removeBySessionIdAndIssuerAndAudience(...) :
|
||||
this.sessions.removeBySubjectAndIssuerAndAudience(...);
|
||||
}
|
||||
}
|
||||
----
|
||||
======
|
||||
|
|
@ -10,4 +10,5 @@
|
|||
^http://www.w3.org/2001/04/xmlenc
|
||||
^http://www.springframework.org/schema/security/.*
|
||||
^http://openoffice.org/.*
|
||||
^http://www.w3.org/2003/g/data-view
|
||||
^http://www.w3.org/2003/g/data-view
|
||||
^http://schemas.openid.net/event/backchannel-logout
|
||||
|
|
|
@ -0,0 +1,96 @@
|
|||
/*
|
||||
* Copyright 2002-2023 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.security.oauth2.client.oidc.authentication.logout;
|
||||
|
||||
import java.net.URL;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.security.oauth2.core.ClaimAccessor;
|
||||
|
||||
/**
|
||||
* A {@link ClaimAccessor} for the "claims" that can be returned in OIDC Logout
|
||||
* Tokens
|
||||
*
|
||||
* @author Josh Cummings
|
||||
* @since 6.2
|
||||
* @see OidcLogoutToken
|
||||
* @see <a target="_blank" href=
|
||||
* "https://openid.net/specs/openid-connect-backchannel-1_0.html#LogoutToken">OIDC
|
||||
* Back-Channel Logout Token</a>
|
||||
*/
|
||||
public interface LogoutTokenClaimAccessor extends ClaimAccessor {
|
||||
|
||||
/**
|
||||
* Returns the Issuer identifier {@code (iss)}.
|
||||
* @return the Issuer identifier
|
||||
*/
|
||||
default URL getIssuer() {
|
||||
return this.getClaimAsURL(LogoutTokenClaimNames.ISS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Subject identifier {@code (sub)}.
|
||||
* @return the Subject identifier
|
||||
*/
|
||||
default String getSubject() {
|
||||
return this.getClaimAsString(LogoutTokenClaimNames.SUB);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Audience(s) {@code (aud)} that this ID Token is intended for.
|
||||
* @return the Audience(s) that this ID Token is intended for
|
||||
*/
|
||||
default List<String> getAudience() {
|
||||
return this.getClaimAsStringList(LogoutTokenClaimNames.AUD);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the time at which the ID Token was issued {@code (iat)}.
|
||||
* @return the time at which the ID Token was issued
|
||||
*/
|
||||
default Instant getIssuedAt() {
|
||||
return this.getClaimAsInstant(LogoutTokenClaimNames.IAT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a {@link Map} that identifies this token as a logout token
|
||||
* @return the identifying {@link Map}
|
||||
*/
|
||||
default Map<String, Object> getEvents() {
|
||||
return getClaimAsMap(LogoutTokenClaimNames.EVENTS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a {@code String} value {@code (sid)} representing the OIDC Provider session
|
||||
* @return the value representing the OIDC Provider session
|
||||
*/
|
||||
default String getSessionId() {
|
||||
return getClaimAsString(LogoutTokenClaimNames.SID);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the JWT ID {@code (jti)} claim which provides a unique identifier for the
|
||||
* JWT.
|
||||
* @return the JWT ID claim which provides a unique identifier for the JWT
|
||||
*/
|
||||
default String getId() {
|
||||
return this.getClaimAsString(LogoutTokenClaimNames.JTI);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,70 @@
|
|||
/*
|
||||
* Copyright 2002-2022 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.security.oauth2.client.oidc.authentication.logout;
|
||||
|
||||
/**
|
||||
* The names of the "claims" defined by the OpenID Back-Channel Logout 1.0
|
||||
* specification that can be returned in a Logout Token.
|
||||
*
|
||||
* @author Josh Cummings
|
||||
* @since 6.2
|
||||
* @see OidcLogoutToken
|
||||
* @see <a target="_blank" href=
|
||||
* "https://openid.net/specs/openid-connect-backchannel-1_0.html#LogoutToken">OIDC
|
||||
* Back-Channel Logout Token</a>
|
||||
*/
|
||||
public final class LogoutTokenClaimNames {
|
||||
|
||||
/**
|
||||
* {@code jti} - the JTI identifier
|
||||
*/
|
||||
public static final String JTI = "jti";
|
||||
|
||||
/**
|
||||
* {@code iss} - the Issuer identifier
|
||||
*/
|
||||
public static final String ISS = "iss";
|
||||
|
||||
/**
|
||||
* {@code sub} - the Subject identifier
|
||||
*/
|
||||
public static final String SUB = "sub";
|
||||
|
||||
/**
|
||||
* {@code aud} - the Audience(s) that the ID Token is intended for
|
||||
*/
|
||||
public static final String AUD = "aud";
|
||||
|
||||
/**
|
||||
* {@code iat} - the time at which the ID Token was issued
|
||||
*/
|
||||
public static final String IAT = "iat";
|
||||
|
||||
/**
|
||||
* {@code events} - a JSON object that identifies this token as a logout token
|
||||
*/
|
||||
public static final String EVENTS = "events";
|
||||
|
||||
/**
|
||||
* {@code sid} - the session id for the OIDC provider
|
||||
*/
|
||||
public static final String SID = "sid";
|
||||
|
||||
private LogoutTokenClaimNames() {
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,223 @@
|
|||
/*
|
||||
* Copyright 2002-2023 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.security.oauth2.client.oidc.authentication.logout;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import org.springframework.security.oauth2.core.AbstractOAuth2Token;
|
||||
import org.springframework.security.oauth2.core.oidc.IdTokenClaimNames;
|
||||
import org.springframework.util.Assert;
|
||||
|
||||
/**
|
||||
* An implementation of an {@link AbstractOAuth2Token} representing an OpenID Backchannel
|
||||
* Logout Token.
|
||||
*
|
||||
* <p>
|
||||
* The {@code OidcLogoutToken} is a security token that contains "claims" about
|
||||
* terminating sessions for a given OIDC Provider session id or End User.
|
||||
*
|
||||
* @author Josh Cummings
|
||||
* @since 6.2
|
||||
* @see AbstractOAuth2Token
|
||||
* @see LogoutTokenClaimAccessor
|
||||
* @see <a target="_blank" href=
|
||||
* "https://openid.net/specs/openid-connect-backchannel-1_0.html#LogoutToken">Logout
|
||||
* Token</a>
|
||||
*/
|
||||
public class OidcLogoutToken extends AbstractOAuth2Token implements LogoutTokenClaimAccessor {
|
||||
|
||||
private static final String BACKCHANNEL_LOGOUT_TOKEN_EVENT_NAME = "http://schemas.openid.net/event/backchannel-logout";
|
||||
|
||||
private final Map<String, Object> claims;
|
||||
|
||||
/**
|
||||
* Constructs a {@link OidcLogoutToken} using the provided parameters.
|
||||
* @param tokenValue the Logout Token value
|
||||
* @param issuedAt the time at which the Logout Token was issued {@code (iat)}
|
||||
* @param claims the claims about the logout statement
|
||||
*/
|
||||
OidcLogoutToken(String tokenValue, Instant issuedAt, Map<String, Object> claims) {
|
||||
super(tokenValue, issuedAt, Instant.MAX);
|
||||
this.claims = Collections.unmodifiableMap(claims);
|
||||
Assert.notNull(claims, "claims must not be null");
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> getClaims() {
|
||||
return this.claims;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a {@link OidcLogoutToken.Builder} based on the given token value
|
||||
* @param tokenValue the token value to use
|
||||
* @return the {@link OidcLogoutToken.Builder} for further configuration
|
||||
*/
|
||||
public static Builder withTokenValue(String tokenValue) {
|
||||
return new Builder(tokenValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* A builder for {@link OidcLogoutToken}s
|
||||
*
|
||||
* @author Josh Cummings
|
||||
*/
|
||||
public static final class Builder {
|
||||
|
||||
private String tokenValue;
|
||||
|
||||
private final Map<String, Object> claims = new LinkedHashMap<>();
|
||||
|
||||
private Builder(String tokenValue) {
|
||||
this.tokenValue = tokenValue;
|
||||
this.claims.put(LogoutTokenClaimNames.EVENTS,
|
||||
Collections.singletonMap(BACKCHANNEL_LOGOUT_TOKEN_EVENT_NAME, Collections.emptyMap()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Use this token value in the resulting {@link OidcLogoutToken}
|
||||
* @param tokenValue The token value to use
|
||||
* @return the {@link Builder} for further configurations
|
||||
*/
|
||||
public Builder tokenValue(String tokenValue) {
|
||||
this.tokenValue = tokenValue;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Use this claim in the resulting {@link OidcLogoutToken}
|
||||
* @param name The claim name
|
||||
* @param value The claim value
|
||||
* @return the {@link Builder} for further configurations
|
||||
*/
|
||||
public Builder claim(String name, Object value) {
|
||||
this.claims.put(name, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides access to every {@link #claim(String, Object)} declared so far with
|
||||
* the possibility to add, replace, or remove.
|
||||
* @param claimsConsumer the consumer
|
||||
* @return the {@link Builder} for further configurations
|
||||
*/
|
||||
public Builder claims(Consumer<Map<String, Object>> claimsConsumer) {
|
||||
claimsConsumer.accept(this.claims);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Use this audience in the resulting {@link OidcLogoutToken}
|
||||
* @param audience The audience(s) to use
|
||||
* @return the {@link Builder} for further configurations
|
||||
*/
|
||||
public Builder audience(Collection<String> audience) {
|
||||
return claim(LogoutTokenClaimNames.AUD, audience);
|
||||
}
|
||||
|
||||
/**
|
||||
* Use this issued-at timestamp in the resulting {@link OidcLogoutToken}
|
||||
* @param issuedAt The issued-at timestamp to use
|
||||
* @return the {@link Builder} for further configurations
|
||||
*/
|
||||
public Builder issuedAt(Instant issuedAt) {
|
||||
return claim(LogoutTokenClaimNames.IAT, issuedAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Use this issuer in the resulting {@link OidcLogoutToken}
|
||||
* @param issuer The issuer to use
|
||||
* @return the {@link Builder} for further configurations
|
||||
*/
|
||||
public Builder issuer(String issuer) {
|
||||
return claim(LogoutTokenClaimNames.ISS, issuer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Use this id to identify the resulting {@link OidcLogoutToken}
|
||||
* @param jti The unique identifier to use
|
||||
* @return the {@link Builder} for further configurations
|
||||
*/
|
||||
public Builder jti(String jti) {
|
||||
return claim(LogoutTokenClaimNames.JTI, jti);
|
||||
}
|
||||
|
||||
/**
|
||||
* Use this subject in the resulting {@link OidcLogoutToken}
|
||||
* @param subject The subject to use
|
||||
* @return the {@link Builder} for further configurations
|
||||
*/
|
||||
public Builder subject(String subject) {
|
||||
return claim(LogoutTokenClaimNames.SUB, subject);
|
||||
}
|
||||
|
||||
/**
|
||||
* A JSON object that identifies this token as a logout token
|
||||
* @param events The JSON object to use
|
||||
* @return the {@link Builder} for further configurations
|
||||
*/
|
||||
public Builder events(Map<String, Object> events) {
|
||||
return claim(LogoutTokenClaimNames.EVENTS, events);
|
||||
}
|
||||
|
||||
/**
|
||||
* Use this session id to correlate the OIDC Provider session
|
||||
* @param sessionId The session id to use
|
||||
* @return the {@link Builder} for further configurations
|
||||
*/
|
||||
public Builder sessionId(String sessionId) {
|
||||
return claim(LogoutTokenClaimNames.SID, sessionId);
|
||||
}
|
||||
|
||||
public OidcLogoutToken build() {
|
||||
Assert.notNull(this.claims.get(LogoutTokenClaimNames.ISS), "issuer must not be null");
|
||||
Assert.isInstanceOf(Collection.class, this.claims.get(LogoutTokenClaimNames.AUD),
|
||||
"audience must be a collection");
|
||||
Assert.notEmpty((Collection<?>) this.claims.get(LogoutTokenClaimNames.AUD), "audience must not be empty");
|
||||
Assert.notNull(this.claims.get(LogoutTokenClaimNames.JTI), "jti must not be null");
|
||||
Assert.isTrue(hasLogoutTokenIdentifyingMember(),
|
||||
"logout token must contain an events claim that contains a member called " + "'"
|
||||
+ BACKCHANNEL_LOGOUT_TOKEN_EVENT_NAME + "' whose value is an empty Map");
|
||||
Assert.isNull(this.claims.get("nonce"), "logout token must not contain a nonce claim");
|
||||
Instant iat = toInstant(this.claims.get(IdTokenClaimNames.IAT));
|
||||
return new OidcLogoutToken(this.tokenValue, iat, this.claims);
|
||||
}
|
||||
|
||||
private boolean hasLogoutTokenIdentifyingMember() {
|
||||
if (!(this.claims.get(LogoutTokenClaimNames.EVENTS) instanceof Map<?, ?> events)) {
|
||||
return false;
|
||||
}
|
||||
if (!(events.get(BACKCHANNEL_LOGOUT_TOKEN_EVENT_NAME) instanceof Map<?, ?> object)) {
|
||||
return false;
|
||||
}
|
||||
return object.isEmpty();
|
||||
}
|
||||
|
||||
private Instant toInstant(Object timestamp) {
|
||||
if (timestamp != null) {
|
||||
Assert.isInstanceOf(Instant.class, timestamp, "timestamps must be of type Instant");
|
||||
}
|
||||
return (Instant) timestamp;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,53 @@
|
|||
/*
|
||||
* Copyright 2002-2023 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.security.oauth2.client.oidc.server.session;
|
||||
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcLogoutToken;
|
||||
import org.springframework.security.oauth2.client.oidc.session.InMemoryOidcSessionRegistry;
|
||||
import org.springframework.security.oauth2.client.oidc.session.OidcSessionInformation;
|
||||
|
||||
/**
|
||||
* An in-memory implementation of
|
||||
* {@link org.springframework.security.oauth2.client.oidc.server.session.ReactiveOidcSessionRegistry}
|
||||
*
|
||||
* @author Josh Cummings
|
||||
* @since 6.2
|
||||
*/
|
||||
public final class InMemoryReactiveOidcSessionRegistry implements ReactiveOidcSessionRegistry {
|
||||
|
||||
private final InMemoryOidcSessionRegistry delegate = new InMemoryOidcSessionRegistry();
|
||||
|
||||
@Override
|
||||
public Mono<Void> saveSessionInformation(OidcSessionInformation info) {
|
||||
this.delegate.saveSessionInformation(info);
|
||||
return Mono.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<OidcSessionInformation> removeSessionInformation(String clientSessionId) {
|
||||
return Mono.justOrEmpty(this.delegate.removeSessionInformation(clientSessionId));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Flux<OidcSessionInformation> removeSessionInformation(OidcLogoutToken token) {
|
||||
return Flux.fromIterable(this.delegate.removeSessionInformation(token));
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,63 @@
|
|||
/*
|
||||
* Copyright 2002-2023 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.security.oauth2.client.oidc.server.session;
|
||||
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcLogoutToken;
|
||||
import org.springframework.security.oauth2.client.oidc.session.OidcSessionInformation;
|
||||
|
||||
/**
|
||||
* A registry to record the tie between the OIDC Provider session and the Client session.
|
||||
* This is handy when a provider makes a logout request that indicates the OIDC Provider
|
||||
* session or the End User.
|
||||
*
|
||||
* @author Josh Cummings
|
||||
* @since 6.2
|
||||
* @see <a target="_blank" href=
|
||||
* "https://openid.net/specs/openid-connect-backchannel-1_0.html#LogoutToken">Logout
|
||||
* Token</a>
|
||||
*/
|
||||
public interface ReactiveOidcSessionRegistry {
|
||||
|
||||
/**
|
||||
* Register a OIDC Provider session with the provided client session. Generally
|
||||
* speaking, the client session should be the session tied to the current login.
|
||||
* @param info the {@link OidcSessionInformation} to use
|
||||
*/
|
||||
Mono<Void> saveSessionInformation(OidcSessionInformation info);
|
||||
|
||||
/**
|
||||
* Deregister the OIDC Provider session tied to the provided client session. Generally
|
||||
* speaking, the client session should be the session tied to the current logout.
|
||||
* @param clientSessionId the client session
|
||||
* @return any found {@link OidcSessionInformation}, could be {@code null}
|
||||
*/
|
||||
Mono<OidcSessionInformation> removeSessionInformation(String clientSessionId);
|
||||
|
||||
/**
|
||||
* Deregister the OIDC Provider sessions referenced by the provided OIDC Logout Token
|
||||
* by its session id or its subject. Note that the issuer and audience should also
|
||||
* match the corresponding values found in each {@link OidcSessionInformation}
|
||||
* returned.
|
||||
* @param logoutToken the {@link OidcLogoutToken}
|
||||
* @return any found {@link OidcSessionInformation}s, could be empty
|
||||
*/
|
||||
Flux<OidcSessionInformation> removeSessionInformation(OidcLogoutToken logoutToken);
|
||||
|
||||
}
|
|
@ -0,0 +1,123 @@
|
|||
/*
|
||||
* Copyright 2002-2023 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.security.oauth2.client.oidc.session;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
import org.apache.commons.logging.Log;
|
||||
import org.apache.commons.logging.LogFactory;
|
||||
|
||||
import org.springframework.security.oauth2.client.oidc.authentication.logout.LogoutTokenClaimNames;
|
||||
import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcLogoutToken;
|
||||
|
||||
/**
|
||||
* An in-memory implementation of {@link OidcSessionRegistry}
|
||||
*
|
||||
* @author Josh Cummings
|
||||
* @since 6.2
|
||||
*/
|
||||
public final class InMemoryOidcSessionRegistry implements OidcSessionRegistry {
|
||||
|
||||
private final Log logger = LogFactory.getLog(InMemoryOidcSessionRegistry.class);
|
||||
|
||||
private final Map<String, OidcSessionInformation> sessions = new ConcurrentHashMap<>();
|
||||
|
||||
@Override
|
||||
public void saveSessionInformation(OidcSessionInformation info) {
|
||||
this.sessions.put(info.getSessionId(), info);
|
||||
}
|
||||
|
||||
@Override
|
||||
public OidcSessionInformation removeSessionInformation(String clientSessionId) {
|
||||
OidcSessionInformation information = this.sessions.remove(clientSessionId);
|
||||
if (information != null) {
|
||||
this.logger.trace("Removed client session");
|
||||
}
|
||||
return information;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Iterable<OidcSessionInformation> removeSessionInformation(OidcLogoutToken token) {
|
||||
List<String> audience = token.getAudience();
|
||||
String issuer = token.getIssuer().toString();
|
||||
String subject = token.getSubject();
|
||||
String providerSessionId = token.getSessionId();
|
||||
Predicate<OidcSessionInformation> matcher = (providerSessionId != null)
|
||||
? sessionIdMatcher(audience, issuer, providerSessionId) : subjectMatcher(audience, issuer, subject);
|
||||
if (this.logger.isTraceEnabled()) {
|
||||
String message = "Looking up sessions by issuer [%s] and %s [%s]";
|
||||
if (providerSessionId != null) {
|
||||
this.logger.trace(String.format(message, issuer, LogoutTokenClaimNames.SID, providerSessionId));
|
||||
}
|
||||
else {
|
||||
this.logger.trace(String.format(message, issuer, LogoutTokenClaimNames.SUB, subject));
|
||||
}
|
||||
}
|
||||
int size = this.sessions.size();
|
||||
Set<OidcSessionInformation> infos = new HashSet<>();
|
||||
this.sessions.values().removeIf((info) -> {
|
||||
boolean result = matcher.test(info);
|
||||
if (result) {
|
||||
infos.add(info);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
if (infos.isEmpty()) {
|
||||
this.logger.debug("Failed to remove any sessions since none matched");
|
||||
}
|
||||
else if (this.logger.isTraceEnabled()) {
|
||||
String message = "Found and removed %d session(s) from mapping of %d session(s)";
|
||||
this.logger.trace(String.format(message, infos.size(), size));
|
||||
}
|
||||
return infos;
|
||||
}
|
||||
|
||||
private static Predicate<OidcSessionInformation> sessionIdMatcher(List<String> audience, String issuer,
|
||||
String sessionId) {
|
||||
return (session) -> {
|
||||
List<String> thatAudience = session.getPrincipal().getAudience();
|
||||
String thatIssuer = session.getPrincipal().getIssuer().toString();
|
||||
String thatSessionId = session.getPrincipal().getClaimAsString(LogoutTokenClaimNames.SID);
|
||||
if (thatAudience == null) {
|
||||
return false;
|
||||
}
|
||||
return !Collections.disjoint(audience, thatAudience) && issuer.equals(thatIssuer)
|
||||
&& sessionId.equals(thatSessionId);
|
||||
};
|
||||
}
|
||||
|
||||
private static Predicate<OidcSessionInformation> subjectMatcher(List<String> audience, String issuer,
|
||||
String subject) {
|
||||
return (session) -> {
|
||||
List<String> thatAudience = session.getPrincipal().getAudience();
|
||||
String thatIssuer = session.getPrincipal().getIssuer().toString();
|
||||
String thatSubject = session.getPrincipal().getSubject();
|
||||
if (thatAudience == null) {
|
||||
return false;
|
||||
}
|
||||
return !Collections.disjoint(audience, thatAudience) && issuer.equals(thatIssuer)
|
||||
&& subject.equals(thatSubject);
|
||||
};
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,74 @@
|
|||
/*
|
||||
* Copyright 2002-2023 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.security.oauth2.client.oidc.session;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.Date;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.security.core.session.SessionInformation;
|
||||
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
|
||||
|
||||
/**
|
||||
* A {@link SessionInformation} extension that enforces the principal be of type
|
||||
* {@link OidcUser}.
|
||||
*
|
||||
* @author Josh Cummings
|
||||
* @since 6.2
|
||||
*/
|
||||
public class OidcSessionInformation extends SessionInformation {
|
||||
|
||||
private final Map<String, String> authorities;
|
||||
|
||||
/**
|
||||
* Construct an {@link OidcSessionInformation}
|
||||
* @param sessionId the Client's session id
|
||||
* @param authorities any material that authorizes operating on the session
|
||||
* @param user the OIDC Provider's session and end user
|
||||
*/
|
||||
public OidcSessionInformation(String sessionId, Map<String, String> authorities, OidcUser user) {
|
||||
super(user, sessionId, new Date());
|
||||
this.authorities = (authorities != null) ? new LinkedHashMap<>(authorities) : Collections.emptyMap();
|
||||
}
|
||||
|
||||
/**
|
||||
* Any material needed to authorize operations on this session
|
||||
* @return the {@link Map} of credentials
|
||||
*/
|
||||
public Map<String, String> getAuthorities() {
|
||||
return this.authorities;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
public OidcUser getPrincipal() {
|
||||
return (OidcUser) super.getPrincipal();
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy this {@link OidcSessionInformation}, using a new session identifier
|
||||
* @param sessionId the new session identifier to use
|
||||
* @return a new {@link OidcSessionInformation} instance
|
||||
*/
|
||||
public OidcSessionInformation withSessionId(String sessionId) {
|
||||
return new OidcSessionInformation(sessionId, getAuthorities(), getPrincipal());
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,59 @@
|
|||
/*
|
||||
* Copyright 2002-2023 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.security.oauth2.client.oidc.session;
|
||||
|
||||
import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcLogoutToken;
|
||||
|
||||
/**
|
||||
* A registry to record the tie between the OIDC Provider session and the Client session.
|
||||
* This is handy when a provider makes a logout request that indicates the OIDC Provider
|
||||
* session or the End User.
|
||||
*
|
||||
* @author Josh Cummings
|
||||
* @since 6.2
|
||||
* @see <a target="_blank" href=
|
||||
* "https://openid.net/specs/openid-connect-backchannel-1_0.html#LogoutToken">Logout
|
||||
* Token</a>
|
||||
*/
|
||||
public interface OidcSessionRegistry {
|
||||
|
||||
/**
|
||||
* Register a OIDC Provider session with the provided client session. Generally
|
||||
* speaking, the client session should be the session tied to the current login.
|
||||
* @param info the {@link OidcSessionInformation} to use
|
||||
*/
|
||||
void saveSessionInformation(OidcSessionInformation info);
|
||||
|
||||
/**
|
||||
* Deregister the OIDC Provider session tied to the provided client session. Generally
|
||||
* speaking, the client session should be the session tied to the current logout.
|
||||
* @param clientSessionId the client session
|
||||
* @return any found {@link OidcSessionInformation}, could be {@code null}
|
||||
*/
|
||||
OidcSessionInformation removeSessionInformation(String clientSessionId);
|
||||
|
||||
/**
|
||||
* Deregister the OIDC Provider sessions referenced by the provided OIDC Logout Token
|
||||
* by its session id or its subject. Note that the issuer and audience should also
|
||||
* match the corresponding values found in each {@link OidcSessionInformation}
|
||||
* returned.
|
||||
* @param logoutToken the {@link OidcLogoutToken}
|
||||
* @return any found {@link OidcSessionInformation}s, could be empty
|
||||
*/
|
||||
Iterable<OidcSessionInformation> removeSessionInformation(OidcLogoutToken logoutToken);
|
||||
|
||||
}
|
|
@ -0,0 +1,50 @@
|
|||
/*
|
||||
* Copyright 2002-2023 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.security.oauth2.client.oidc.authentication.logout;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Collections;
|
||||
|
||||
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
|
||||
|
||||
public final class TestOidcLogoutTokens {
|
||||
|
||||
public static OidcLogoutToken.Builder withUser(OidcUser user) {
|
||||
OidcLogoutToken.Builder builder = OidcLogoutToken.withTokenValue("token")
|
||||
.audience(Collections.singleton("client-id")).issuedAt(Instant.now())
|
||||
.issuer(user.getIssuer().toString()).jti("id").subject(user.getSubject());
|
||||
if (user.hasClaim(LogoutTokenClaimNames.SID)) {
|
||||
builder.sessionId(user.getClaimAsString(LogoutTokenClaimNames.SID));
|
||||
}
|
||||
return builder;
|
||||
}
|
||||
|
||||
public static OidcLogoutToken.Builder withSessionId(String issuer, String sessionId) {
|
||||
return OidcLogoutToken.withTokenValue("token").audience(Collections.singleton("client-id"))
|
||||
.issuedAt(Instant.now()).issuer(issuer).jti("id").sessionId(sessionId);
|
||||
}
|
||||
|
||||
public static OidcLogoutToken.Builder withSubject(String issuer, String subject) {
|
||||
return OidcLogoutToken.withTokenValue("token").audience(Collections.singleton("client-id"))
|
||||
.issuedAt(Instant.now()).issuer(issuer).jti("id").subject(subject);
|
||||
}
|
||||
|
||||
private TestOidcLogoutTokens() {
|
||||
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,102 @@
|
|||
/*
|
||||
* Copyright 2002-2023 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.security.oauth2.client.oidc.session;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import org.springframework.security.core.authority.AuthorityUtils;
|
||||
import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcLogoutToken;
|
||||
import org.springframework.security.oauth2.client.oidc.authentication.logout.TestOidcLogoutTokens;
|
||||
import org.springframework.security.oauth2.core.oidc.OidcIdToken;
|
||||
import org.springframework.security.oauth2.core.oidc.TestOidcIdTokens;
|
||||
import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser;
|
||||
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Tests for {@link InMemoryOidcSessionRegistry}
|
||||
*/
|
||||
public class InMemoryOidcSessionRegistryTests {
|
||||
|
||||
@Test
|
||||
public void registerWhenDefaultsThenStoresSessionInformation() {
|
||||
InMemoryOidcSessionRegistry sessionRegistry = new InMemoryOidcSessionRegistry();
|
||||
String sessionId = "client";
|
||||
OidcSessionInformation info = TestOidcSessionInformations.create(sessionId);
|
||||
sessionRegistry.saveSessionInformation(info);
|
||||
OidcLogoutToken logoutToken = TestOidcLogoutTokens.withUser(info.getPrincipal()).build();
|
||||
Iterable<OidcSessionInformation> infos = sessionRegistry.removeSessionInformation(logoutToken);
|
||||
assertThat(infos).containsExactly(info);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void registerWhenIdTokenHasSessionIdThenStoresSessionInformation() {
|
||||
InMemoryOidcSessionRegistry sessionRegistry = new InMemoryOidcSessionRegistry();
|
||||
OidcIdToken idToken = TestOidcIdTokens.idToken().claim("sid", "provider").build();
|
||||
OidcUser user = new DefaultOidcUser(AuthorityUtils.NO_AUTHORITIES, idToken);
|
||||
OidcSessionInformation info = TestOidcSessionInformations.create("client", user);
|
||||
sessionRegistry.saveSessionInformation(info);
|
||||
OidcLogoutToken logoutToken = TestOidcLogoutTokens.withSessionId(idToken.getIssuer().toString(), "provider")
|
||||
.build();
|
||||
Iterable<OidcSessionInformation> infos = sessionRegistry.removeSessionInformation(logoutToken);
|
||||
assertThat(infos).containsExactly(info);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unregisterWhenMultipleSessionsThenRemovesAllMatching() {
|
||||
InMemoryOidcSessionRegistry sessionRegistry = new InMemoryOidcSessionRegistry();
|
||||
OidcIdToken idToken = TestOidcIdTokens.idToken().claim("sid", "providerOne").subject("otheruser").build();
|
||||
OidcUser user = new DefaultOidcUser(AuthorityUtils.NO_AUTHORITIES, idToken);
|
||||
OidcSessionInformation oneSession = TestOidcSessionInformations.create("clientOne", user);
|
||||
sessionRegistry.saveSessionInformation(oneSession);
|
||||
idToken = TestOidcIdTokens.idToken().claim("sid", "providerTwo").build();
|
||||
user = new DefaultOidcUser(AuthorityUtils.NO_AUTHORITIES, idToken);
|
||||
OidcSessionInformation twoSession = TestOidcSessionInformations.create("clientTwo", user);
|
||||
sessionRegistry.saveSessionInformation(twoSession);
|
||||
idToken = TestOidcIdTokens.idToken().claim("sid", "providerThree").build();
|
||||
user = new DefaultOidcUser(AuthorityUtils.NO_AUTHORITIES, idToken);
|
||||
OidcSessionInformation threeSession = TestOidcSessionInformations.create("clientThree", user);
|
||||
sessionRegistry.saveSessionInformation(threeSession);
|
||||
OidcLogoutToken logoutToken = TestOidcLogoutTokens
|
||||
.withSubject(idToken.getIssuer().toString(), idToken.getSubject()).build();
|
||||
Iterable<OidcSessionInformation> infos = sessionRegistry.removeSessionInformation(logoutToken);
|
||||
assertThat(infos).containsExactlyInAnyOrder(twoSession, threeSession);
|
||||
logoutToken = TestOidcLogoutTokens.withSubject(idToken.getIssuer().toString(), "otheruser").build();
|
||||
infos = sessionRegistry.removeSessionInformation(logoutToken);
|
||||
assertThat(infos).containsExactly(oneSession);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unregisterWhenNoSessionsThenEmptyList() {
|
||||
InMemoryOidcSessionRegistry sessionRegistry = new InMemoryOidcSessionRegistry();
|
||||
OidcIdToken idToken = TestOidcIdTokens.idToken().claim("sid", "provider").build();
|
||||
OidcUser user = new DefaultOidcUser(AuthorityUtils.NO_AUTHORITIES, idToken);
|
||||
OidcSessionInformation info = TestOidcSessionInformations.create("client", user);
|
||||
sessionRegistry.saveSessionInformation(info);
|
||||
OidcLogoutToken logoutToken = TestOidcLogoutTokens.withSessionId(idToken.getIssuer().toString(), "wrong")
|
||||
.build();
|
||||
Iterable<?> infos = sessionRegistry.removeSessionInformation(logoutToken);
|
||||
assertThat(infos).isNotNull();
|
||||
assertThat(infos).isEmpty();
|
||||
logoutToken = TestOidcLogoutTokens.withSessionId("https://wrong", "provider").build();
|
||||
infos = sessionRegistry.removeSessionInformation(logoutToken);
|
||||
assertThat(infos).isNotNull();
|
||||
assertThat(infos).isEmpty();
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,45 @@
|
|||
/*
|
||||
* Copyright 2002-2023 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.security.oauth2.client.oidc.session;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
|
||||
import org.springframework.security.oauth2.core.oidc.user.TestOidcUsers;
|
||||
|
||||
/**
|
||||
* Sample {@link OidcSessionInformation} instances
|
||||
*/
|
||||
public final class TestOidcSessionInformations {
|
||||
|
||||
public static OidcSessionInformation create() {
|
||||
return create("sessionId");
|
||||
}
|
||||
|
||||
public static OidcSessionInformation create(String sessionId) {
|
||||
return create(sessionId, TestOidcUsers.create());
|
||||
}
|
||||
|
||||
public static OidcSessionInformation create(String sessionId, OidcUser user) {
|
||||
return new OidcSessionInformation(sessionId, Map.of("_csrf", "token"), user);
|
||||
}
|
||||
|
||||
private TestOidcSessionInformations() {
|
||||
|
||||
}
|
||||
|
||||
}
|
|
@ -17,6 +17,7 @@
|
|||
package org.springframework.security.oauth2.core.oidc;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Test {@link OidcIdToken}s
|
||||
|
@ -32,6 +33,7 @@ public final class TestOidcIdTokens {
|
|||
// @formatter:off
|
||||
return OidcIdToken.withTokenValue("id-token")
|
||||
.issuer("https://example.com")
|
||||
.audience(List.of("client-id"))
|
||||
.subject("subject")
|
||||
.issuedAt(Instant.now())
|
||||
.expiresAt(Instant.now()
|
||||
|
|
|
@ -50,7 +50,7 @@ public final class TestOidcUsers {
|
|||
.expiresAt(expiresAt)
|
||||
.subject("subject")
|
||||
.issuer("http://localhost/issuer")
|
||||
.audience(Collections.unmodifiableSet(new LinkedHashSet<>(Collections.singletonList("client"))))
|
||||
.audience(Collections.unmodifiableSet(new LinkedHashSet<>(Collections.singletonList("client-id"))))
|
||||
.authorizedParty("client")
|
||||
.build();
|
||||
// @formatter:on
|
||||
|
|
Loading…
Reference in New Issue