51 changed files with 5397 additions and 114 deletions
@ -0,0 +1,35 @@
@@ -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)); |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,66 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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; |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,75 @@
@@ -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 @@
@@ -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 -> } |
||||
} |
||||
} |
||||
@ -0,0 +1,30 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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; |
||||
} |
||||
|
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,87 @@
@@ -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 @@
@@ -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) |
||||
} |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,267 @@
@@ -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(...); |
||||
} |
||||
} |
||||
---- |
||||
====== |
||||
@ -0,0 +1,267 @@
@@ -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(...); |
||||
} |
||||
} |
||||
---- |
||||
====== |
||||
|
||||
@ -0,0 +1,96 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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() { |
||||
|
||||
} |
||||
|
||||
} |
||||
Loading…
Reference in new issue