Add OIDC Back-Channel Logout Support

Closes gh-12570
This commit is contained in:
Josh Cummings
2023-01-17 17:25:16 -07:00
parent 1461c0f648
commit cb33fd7850
51 changed files with 5397 additions and 114 deletions

View File

@@ -0,0 +1,550 @@
/*
* Copyright 2002-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.config.annotation.web.configurers.oauth2.client;
import java.io.IOException;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.interfaces.RSAPublicKey;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import com.gargoylesoftware.htmlunit.util.UrlUtils;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
import com.nimbusds.jose.jwk.source.JWKSource;
import com.nimbusds.jose.proc.SecurityContext;
import com.nimbusds.oauth2.sdk.Scope;
import com.nimbusds.oauth2.sdk.token.BearerAccessToken;
import com.nimbusds.openid.connect.sdk.token.OIDCTokens;
import jakarta.annotation.PreDestroy;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
import okhttp3.mockwebserver.Dispatcher;
import okhttp3.mockwebserver.MockResponse;
import okhttp3.mockwebserver.MockWebServer;
import okhttp3.mockwebserver.RecordedRequest;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.core.annotation.Order;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.mock.web.MockHttpSession;
import org.springframework.mock.web.MockServletContext;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.test.SpringTestContext;
import org.springframework.security.config.test.SpringTestContextExtension;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.oauth2.client.oidc.authentication.logout.LogoutTokenClaimNames;
import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcLogoutToken;
import org.springframework.security.oauth2.client.oidc.authentication.logout.TestOidcLogoutTokens;
import org.springframework.security.oauth2.client.oidc.session.InMemoryOidcSessionRegistry;
import org.springframework.security.oauth2.client.oidc.session.OidcSessionRegistry;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository;
import org.springframework.security.oauth2.client.registration.TestClientRegistrations;
import org.springframework.security.oauth2.core.oidc.OidcIdToken;
import org.springframework.security.oauth2.core.oidc.TestOidcIdTokens;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.logout.LogoutHandler;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import static org.hamcrest.Matchers.containsString;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.willThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.verify;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* Tests for {@link OidcLogoutConfigurer}
*/
@ExtendWith(SpringTestContextExtension.class)
public class OidcLogoutConfigurerTests {
@Autowired
private MockMvc mvc;
@Autowired(required = false)
private MockWebServer web;
@Autowired
private ClientRegistration clientRegistration;
public final SpringTestContext spring = new SpringTestContext(this);
@Test
void logoutWhenDefaultsThenRemotelyInvalidatesSessions() throws Exception {
this.spring.register(WebServerConfig.class, OidcProviderConfig.class, DefaultConfig.class).autowire();
String registrationId = this.clientRegistration.getRegistrationId();
MockHttpSession session = login();
String logoutToken = this.mvc.perform(get("/token/logout").session(session)).andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
this.mvc.perform(post(this.web.url("/logout/connect/back-channel/" + registrationId).toString())
.param("logout_token", logoutToken)).andExpect(status().isOk());
this.mvc.perform(get("/token/logout").session(session)).andExpect(status().isUnauthorized());
}
@Test
void logoutWhenInvalidLogoutTokenThenBadRequest() throws Exception {
this.spring.register(WebServerConfig.class, OidcProviderConfig.class, DefaultConfig.class).autowire();
this.mvc.perform(get("/token/logout")).andExpect(status().isUnauthorized());
String registrationId = this.clientRegistration.getRegistrationId();
MvcResult result = this.mvc.perform(get("/oauth2/authorization/" + registrationId))
.andExpect(status().isFound()).andReturn();
MockHttpSession session = (MockHttpSession) result.getRequest().getSession();
String redirectUrl = UrlUtils.decode(result.getResponse().getRedirectedUrl());
String state = this.mvc
.perform(get(redirectUrl).with(
httpBasic(this.clientRegistration.getClientId(), this.clientRegistration.getClientSecret())))
.andReturn().getResponse().getContentAsString();
result = this.mvc.perform(get("/login/oauth2/code/" + registrationId).param("code", "code")
.param("state", state).session(session)).andExpect(status().isFound()).andReturn();
session = (MockHttpSession) result.getRequest().getSession();
this.mvc.perform(post(this.web.url("/logout/connect/back-channel/" + registrationId).toString())
.param("logout_token", "invalid")).andExpect(status().isBadRequest());
this.mvc.perform(get("/token/logout").session(session)).andExpect(status().isOk());
}
@Test
void logoutWhenLogoutTokenSpecifiesOneSessionThenRemotelyInvalidatesOnlyThatSession() throws Exception {
this.spring.register(WebServerConfig.class, OidcProviderConfig.class, DefaultConfig.class).autowire();
String registrationId = this.clientRegistration.getRegistrationId();
MockHttpSession one = login();
MockHttpSession two = login();
MockHttpSession three = login();
String logoutToken = this.mvc.perform(get("/token/logout").session(one)).andExpect(status().isOk()).andReturn()
.getResponse().getContentAsString();
this.mvc.perform(post(this.web.url("/logout/connect/back-channel/" + registrationId).toString())
.param("logout_token", logoutToken)).andExpect(status().isOk());
this.mvc.perform(get("/token/logout").session(one)).andExpect(status().isUnauthorized());
this.mvc.perform(get("/token/logout").session(two)).andExpect(status().isOk());
logoutToken = this.mvc.perform(get("/token/logout/all").session(three)).andExpect(status().isOk()).andReturn()
.getResponse().getContentAsString();
this.mvc.perform(post(this.web.url("/logout/connect/back-channel/" + registrationId).toString())
.param("logout_token", logoutToken)).andExpect(status().isOk());
this.mvc.perform(get("/token/logout").session(two)).andExpect(status().isUnauthorized());
this.mvc.perform(get("/token/logout").session(three)).andExpect(status().isUnauthorized());
}
@Test
void logoutWhenRemoteLogoutFailsThenReportsPartialLogout() throws Exception {
this.spring.register(WebServerConfig.class, OidcProviderConfig.class, WithBrokenLogoutConfig.class).autowire();
LogoutHandler logoutHandler = this.spring.getContext().getBean(LogoutHandler.class);
willThrow(IllegalStateException.class).given(logoutHandler).logout(any(), any(), any());
String registrationId = this.clientRegistration.getRegistrationId();
MockHttpSession one = login();
String logoutToken = this.mvc.perform(get("/token/logout/all").session(one)).andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
this.mvc.perform(post(this.web.url("/logout/connect/back-channel/" + registrationId).toString())
.param("logout_token", logoutToken)).andExpect(status().isBadRequest())
.andExpect(content().string(containsString("partial_logout")));
this.mvc.perform(get("/token/logout").session(one)).andExpect(status().isOk());
}
@Test
void logoutWhenCustomComponentsThenUses() throws Exception {
this.spring.register(WebServerConfig.class, OidcProviderConfig.class, WithCustomComponentsConfig.class)
.autowire();
String registrationId = this.clientRegistration.getRegistrationId();
MockHttpSession session = login();
String logoutToken = this.mvc.perform(get("/token/logout").session(session)).andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
this.mvc.perform(post(this.web.url("/logout/connect/back-channel/" + registrationId).toString())
.param("logout_token", logoutToken)).andExpect(status().isOk());
this.mvc.perform(get("/token/logout").session(session)).andExpect(status().isUnauthorized());
OidcSessionRegistry sessionRegistry = this.spring.getContext().getBean(OidcSessionRegistry.class);
verify(sessionRegistry).saveSessionInformation(any());
verify(sessionRegistry).removeSessionInformation(any(OidcLogoutToken.class));
}
private MockHttpSession login() throws Exception {
MockMvcDispatcher dispatcher = (MockMvcDispatcher) this.web.getDispatcher();
this.mvc.perform(get("/token/logout")).andExpect(status().isUnauthorized());
String registrationId = this.clientRegistration.getRegistrationId();
MvcResult result = this.mvc.perform(get("/oauth2/authorization/" + registrationId))
.andExpect(status().isFound()).andReturn();
MockHttpSession session = (MockHttpSession) result.getRequest().getSession();
String redirectUrl = UrlUtils.decode(result.getResponse().getRedirectedUrl());
String state = this.mvc
.perform(get(redirectUrl).with(
httpBasic(this.clientRegistration.getClientId(), this.clientRegistration.getClientSecret())))
.andReturn().getResponse().getContentAsString();
result = this.mvc.perform(get("/login/oauth2/code/" + registrationId).param("code", "code")
.param("state", state).session(session)).andExpect(status().isFound()).andReturn();
session = (MockHttpSession) result.getRequest().getSession();
dispatcher.registerSession(session);
return session;
}
@Configuration
static class RegistrationConfig {
@Autowired(required = false)
MockWebServer web;
@Bean
ClientRegistration clientRegistration() {
if (this.web == null) {
return TestClientRegistrations.clientRegistration().build();
}
String issuer = this.web.url("/").toString();
return TestClientRegistrations.clientRegistration().issuerUri(issuer).jwkSetUri(issuer + "jwks")
.tokenUri(issuer + "token").userInfoUri(issuer + "user").scope("openid").build();
}
@Bean
ClientRegistrationRepository clientRegistrationRepository(ClientRegistration clientRegistration) {
return new InMemoryClientRegistrationRepository(clientRegistration);
}
}
@Configuration
@EnableWebSecurity
@Import(RegistrationConfig.class)
static class DefaultConfig {
@Bean
@Order(1)
SecurityFilterChain filters(HttpSecurity http) throws Exception {
// @formatter:off
http
.authorizeHttpRequests((authorize) -> authorize.anyRequest().authenticated())
.oauth2Login(Customizer.withDefaults())
.oidcLogout((oidc) -> oidc.backChannel(Customizer.withDefaults()));
// @formatter:on
return http.build();
}
}
@Configuration
@EnableWebSecurity
@Import(RegistrationConfig.class)
static class WithCustomComponentsConfig {
OidcSessionRegistry sessionRegistry = spy(new InMemoryOidcSessionRegistry());
@Bean
@Order(1)
SecurityFilterChain filters(HttpSecurity http) throws Exception {
// @formatter:off
http
.authorizeHttpRequests((authorize) -> authorize.anyRequest().authenticated())
.oauth2Login((oauth2) -> oauth2.oidcSessionRegistry(this.sessionRegistry))
.oidcLogout((oidc) -> oidc.backChannel(Customizer.withDefaults()));
// @formatter:on
return http.build();
}
@Bean
OidcSessionRegistry sessionRegistry() {
return this.sessionRegistry;
}
}
@Configuration
@EnableWebSecurity
@Import(RegistrationConfig.class)
static class WithBrokenLogoutConfig {
private final LogoutHandler logoutHandler = mock(LogoutHandler.class);
@Bean
@Order(1)
SecurityFilterChain filters(HttpSecurity http) throws Exception {
// @formatter:off
http
.authorizeHttpRequests((authorize) -> authorize.anyRequest().authenticated())
.logout((logout) -> logout.addLogoutHandler(this.logoutHandler))
.oauth2Login(Customizer.withDefaults())
.oidcLogout((oidc) -> oidc.backChannel(Customizer.withDefaults()));
// @formatter:on
return http.build();
}
@Bean
LogoutHandler logoutHandler() {
return this.logoutHandler;
}
}
@Configuration
@EnableWebSecurity
@EnableWebMvc
@RestController
static class OidcProviderConfig {
private static final RSAKey key = key();
private static final JWKSource<SecurityContext> jwks = jwks(key);
private static RSAKey key() {
try {
KeyPair pair = KeyPairGenerator.getInstance("RSA").generateKeyPair();
return new RSAKey.Builder((RSAPublicKey) pair.getPublic()).privateKey(pair.getPrivate()).build();
}
catch (Exception ex) {
throw new RuntimeException(ex);
}
}
private static JWKSource<SecurityContext> jwks(RSAKey key) {
try {
return new ImmutableJWKSet<>(new JWKSet(key));
}
catch (Exception ex) {
throw new RuntimeException(ex);
}
}
private final String username = "user";
private final JwtEncoder encoder = new NimbusJwtEncoder(jwks);
private String nonce;
@Autowired
ClientRegistration registration;
@Bean
@Order(0)
SecurityFilterChain authorizationServer(HttpSecurity http, ClientRegistration registration) throws Exception {
// @formatter:off
http
.securityMatcher("/jwks", "/login/oauth/authorize", "/nonce", "/token", "/token/logout", "/user")
.authorizeHttpRequests((authorize) -> authorize
.requestMatchers("/jwks").permitAll()
.anyRequest().authenticated()
)
.httpBasic(Customizer.withDefaults())
.oauth2ResourceServer((oauth2) -> oauth2
.jwt((jwt) -> jwt.jwkSetUri(registration.getProviderDetails().getJwkSetUri()))
);
// @formatter:off
return http.build();
}
@Bean
UserDetailsService users(ClientRegistration registration) {
return new InMemoryUserDetailsManager(User.withUsername(registration.getClientId())
.password("{noop}" + registration.getClientSecret()).authorities("APP").build());
}
@GetMapping("/login/oauth/authorize")
String nonce(@RequestParam("nonce") String nonce, @RequestParam("state") String state) {
this.nonce = nonce;
return state;
}
@PostMapping("/token")
Map<String, Object> accessToken(HttpServletRequest request) {
HttpSession session = request.getSession();
JwtEncoderParameters parameters = JwtEncoderParameters
.from(JwtClaimsSet.builder().id("id").subject(this.username)
.issuer(this.registration.getProviderDetails().getIssuerUri()).issuedAt(Instant.now())
.expiresAt(Instant.now().plusSeconds(86400)).claim("scope", "openid").build());
String token = this.encoder.encode(parameters).getTokenValue();
return new OIDCTokens(idToken(session.getId()), new BearerAccessToken(token, 86400, new Scope("openid")), null)
.toJSONObject();
}
String idToken(String sessionId) {
OidcIdToken token = TestOidcIdTokens.idToken().issuer(this.registration.getProviderDetails().getIssuerUri())
.subject(this.username).expiresAt(Instant.now().plusSeconds(86400))
.audience(List.of(this.registration.getClientId())).nonce(this.nonce)
.claim(LogoutTokenClaimNames.SID, sessionId).build();
JwtEncoderParameters parameters = JwtEncoderParameters
.from(JwtClaimsSet.builder().claims((claims) -> claims.putAll(token.getClaims())).build());
return this.encoder.encode(parameters).getTokenValue();
}
@GetMapping("/user")
Map<String, Object> userinfo() {
return Map.of("sub", this.username, "id", this.username);
}
@GetMapping("/jwks")
String jwks() {
return new JWKSet(key).toString();
}
@GetMapping("/token/logout")
String logoutToken(@AuthenticationPrincipal OidcUser user) {
OidcLogoutToken token = TestOidcLogoutTokens.withUser(user)
.audience(List.of(this.registration.getClientId())).build();
JwtEncoderParameters parameters = JwtEncoderParameters
.from(JwtClaimsSet.builder().claims((claims) -> claims.putAll(token.getClaims())).build());
return this.encoder.encode(parameters).getTokenValue();
}
@GetMapping("/token/logout/all")
String logoutTokenAll(@AuthenticationPrincipal OidcUser user) {
OidcLogoutToken token = TestOidcLogoutTokens.withUser(user)
.audience(List.of(this.registration.getClientId()))
.claims((claims) -> claims.remove(LogoutTokenClaimNames.SID)).build();
JwtEncoderParameters parameters = JwtEncoderParameters
.from(JwtClaimsSet.builder().claims((claims) -> claims.putAll(token.getClaims())).build());
return this.encoder.encode(parameters).getTokenValue();
}
}
@Configuration
static class WebServerConfig {
private final MockWebServer server = new MockWebServer();
@Bean
MockWebServer web(ObjectProvider<MockMvc> mvc) {
this.server.setDispatcher(new MockMvcDispatcher(mvc));
return this.server;
}
@PreDestroy
void shutdown() throws IOException {
this.server.shutdown();
}
}
private static class MockMvcDispatcher extends Dispatcher {
private final Map<String, MockHttpSession> session = new ConcurrentHashMap<>();
private final ObjectProvider<MockMvc> mvcProvider;
private MockMvc mvc;
MockMvcDispatcher(ObjectProvider<MockMvc> mvc) {
this.mvcProvider = mvc;
}
@Override
public MockResponse dispatch(RecordedRequest request) throws InterruptedException {
this.mvc = this.mvcProvider.getObject();
String method = request.getMethod();
String path = request.getPath();
String csrf = request.getHeader("X-CSRF-TOKEN");
MockHttpSession session = session(request);
MockHttpServletRequestBuilder builder;
if ("GET".equals(method)) {
builder = get(path);
}
else {
builder = post(path).content(request.getBody().readUtf8());
if (csrf != null) {
builder.header("X-CSRF-TOKEN", csrf);
}
else {
builder.with(csrf());
}
}
for (Map.Entry<String, List<String>> header : request.getHeaders().toMultimap().entrySet()) {
builder.header(header.getKey(), header.getValue().iterator().next());
}
try {
MockHttpServletResponse mvcResponse = this.mvc.perform(builder.session(session)).andReturn().getResponse();
return toMockResponse(mvcResponse);
}
catch (Exception ex) {
MockResponse response = new MockResponse();
response.setResponseCode(500);
return response;
}
}
void registerSession(MockHttpSession session) {
this.session.put(session.getId(), session);
}
private MockHttpSession session(RecordedRequest request) {
String cookieHeaderValue = request.getHeader("Cookie");
if (cookieHeaderValue == null) {
return new MockHttpSession();
}
String[] cookies = cookieHeaderValue.split(";");
for (String cookie : cookies) {
String[] parts = cookie.split("=");
if ("JSESSIONID".equals(parts[0])) {
return this.session.computeIfAbsent(parts[1],
(k) -> new MockHttpSession(new MockServletContext(), parts[1]));
}
}
return new MockHttpSession();
}
private MockResponse toMockResponse(MockHttpServletResponse mvcResponse) {
MockResponse response = new MockResponse();
response.setResponseCode(mvcResponse.getStatus());
for (String name : mvcResponse.getHeaderNames()) {
response.addHeader(name, mvcResponse.getHeaderValue(name));
}
response.setBody(getContentAsString(mvcResponse));
return response;
}
private String getContentAsString(MockHttpServletResponse response) {
try {
return response.getContentAsString();
}
catch (Exception ex) {
throw new RuntimeException(ex);
}
}
}
}

View File

@@ -0,0 +1,595 @@
/*
* Copyright 2002-2021 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.config.web.server;
import java.io.IOException;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.interfaces.RSAPublicKey;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import com.gargoylesoftware.htmlunit.util.UrlUtils;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
import com.nimbusds.jose.jwk.source.JWKSource;
import com.nimbusds.jose.proc.SecurityContext;
import com.nimbusds.oauth2.sdk.Scope;
import com.nimbusds.oauth2.sdk.token.BearerAccessToken;
import com.nimbusds.openid.connect.sdk.token.OIDCTokens;
import jakarta.annotation.PreDestroy;
import okhttp3.mockwebserver.Dispatcher;
import okhttp3.mockwebserver.MockResponse;
import okhttp3.mockwebserver.MockWebServer;
import okhttp3.mockwebserver.RecordedRequest;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import reactor.core.publisher.Mono;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.core.annotation.Order;
import org.springframework.http.ResponseCookie;
import org.springframework.http.client.reactive.ClientHttpConnector;
import org.springframework.security.authentication.TestingAuthenticationToken;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
import org.springframework.security.config.test.SpringTestContext;
import org.springframework.security.config.test.SpringTestContextExtension;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.MapReactiveUserDetailsService;
import org.springframework.security.core.userdetails.ReactiveUserDetailsService;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.oauth2.client.oidc.authentication.logout.LogoutTokenClaimNames;
import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcLogoutToken;
import org.springframework.security.oauth2.client.oidc.authentication.logout.TestOidcLogoutTokens;
import org.springframework.security.oauth2.client.oidc.server.session.InMemoryReactiveOidcSessionRegistry;
import org.springframework.security.oauth2.client.oidc.server.session.ReactiveOidcSessionRegistry;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.client.registration.InMemoryReactiveClientRegistrationRepository;
import org.springframework.security.oauth2.client.registration.ReactiveClientRegistrationRepository;
import org.springframework.security.oauth2.client.registration.TestClientRegistrations;
import org.springframework.security.oauth2.core.oidc.OidcIdToken;
import org.springframework.security.oauth2.core.oidc.TestOidcIdTokens;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
import org.springframework.security.web.server.SecurityWebFilterChain;
import org.springframework.security.web.server.authentication.logout.ServerLogoutHandler;
import org.springframework.security.web.server.util.matcher.OrServerWebExchangeMatcher;
import org.springframework.security.web.server.util.matcher.PathPatternParserServerWebExchangeMatcher;
import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher;
import org.springframework.test.web.reactive.server.FluxExchangeResult;
import org.springframework.test.web.reactive.server.WebTestClient;
import org.springframework.test.web.reactive.server.WebTestClientConfigurer;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.context.ConfigurableWebApplicationContext;
import org.springframework.web.reactive.config.EnableWebFlux;
import org.springframework.web.reactive.function.BodyInserters;
import org.springframework.web.server.WebSession;
import org.springframework.web.server.adapter.WebHttpHandlerBuilder;
import static org.hamcrest.Matchers.containsString;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.verify;
import static org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers.csrf;
import static org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers.mockAuthentication;
import static org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers.springSecurity;
/**
* Tests for
* {@link ServerHttpSecurity.OAuth2ResourceServerSpec}
*/
@ExtendWith({ SpringTestContextExtension.class })
public class OidcLogoutSpecTests {
private static final String SESSION_COOKIE_NAME = "SESSION";
private WebTestClient test;
@Autowired(required = false)
private MockWebServer web;
@Autowired
private ClientRegistration clientRegistration;
public final SpringTestContext spring = new SpringTestContext(this);
@Autowired
public void setApplicationContext(ApplicationContext context) {
this.test = WebTestClient.bindToApplicationContext(context)
.apply(springSecurity())
.configureClient().responseTimeout(Duration.ofDays(1))
.build();
if (context instanceof ConfigurableWebApplicationContext configurable) {
configurable.getBeanFactory().registerResolvableDependency(WebTestClient.class, this.test);
}
}
@Test
void logoutWhenDefaultsThenRemotelyInvalidatesSessions() {
this.spring.register(WebServerConfig.class, OidcProviderConfig.class, DefaultConfig.class).autowire();
String registrationId = this.clientRegistration.getRegistrationId();
String session = login();
String logoutToken = this.test.mutateWith(session(session)).get().uri("/token/logout").exchange().expectStatus()
.isOk().returnResult(String.class).getResponseBody().blockFirst();
this.test.post().uri(this.web.url("/logout/connect/back-channel/" + registrationId).toString())
.body(BodyInserters.fromFormData("logout_token", logoutToken)).exchange().expectStatus().isOk();
this.test.mutateWith(session(session)).get().uri("/token/logout").exchange().expectStatus().isUnauthorized();
}
@Test
void logoutWhenInvalidLogoutTokenThenBadRequest() {
this.spring.register(WebServerConfig.class, OidcProviderConfig.class, DefaultConfig.class).autowire();
this.test.get().uri("/token/logout").exchange().expectStatus().isUnauthorized();
String registrationId = this.clientRegistration.getRegistrationId();
FluxExchangeResult<String> result = this.test.get().uri("/oauth2/authorization/" + registrationId).exchange()
.expectStatus().isFound().returnResult(String.class);
String session = sessionId(result);
String redirectUrl = UrlUtils.decode(result.getResponseHeaders().getLocation().toString());
String state = this.test
.mutateWith(mockAuthentication(new TestingAuthenticationToken(this.clientRegistration.getClientId(),
this.clientRegistration.getClientSecret(), "APP")))
.get().uri(redirectUrl).exchange().returnResult(String.class).getResponseBody().blockFirst();
result = this.test.get().uri("/login/oauth2/code/" + registrationId + "?code=code&state=" + state)
.cookie("SESSION", session).exchange().expectStatus().isFound().returnResult(String.class);
session = sessionId(result);
this.test.post().uri(this.web.url("/logout/connect/back-channel/" + registrationId).toString())
.body(BodyInserters.fromFormData("logout_token", "invalid")).exchange().expectStatus().isBadRequest();
this.test.get().uri("/token/logout").cookie("SESSION", session).exchange().expectStatus().isOk();
}
@Test
void logoutWhenLogoutTokenSpecifiesOneSessionThenRemotelyInvalidatesOnlyThatSession() throws Exception {
this.spring.register(WebServerConfig.class, OidcProviderConfig.class, DefaultConfig.class).autowire();
String registrationId = this.clientRegistration.getRegistrationId();
String one = login();
String two = login();
String three = login();
String logoutToken = this.test.get().uri("/token/logout").cookie("SESSION", one).exchange().expectStatus()
.isOk().returnResult(String.class).getResponseBody().blockFirst();
this.test.post().uri(this.web.url("/logout/connect/back-channel/" + registrationId).toString())
.body(BodyInserters.fromFormData("logout_token", logoutToken)).exchange().expectStatus().isOk();
this.test.get().uri("/token/logout").cookie("SESSION", one).exchange().expectStatus().isUnauthorized();
this.test.get().uri("/token/logout").cookie("SESSION", two).exchange().expectStatus().isOk();
logoutToken = this.test.get().uri("/token/logout/all").cookie("SESSION", three).exchange().expectStatus().isOk()
.returnResult(String.class).getResponseBody().blockFirst();
this.test.post().uri(this.web.url("/logout/connect/back-channel/" + registrationId).toString())
.body(BodyInserters.fromFormData("logout_token", logoutToken)).exchange().expectStatus().isOk();
this.test.get().uri("/token/logout").cookie("SESSION", two).exchange().expectStatus().isUnauthorized();
this.test.get().uri("/token/logout").cookie("SESSION", three).exchange().expectStatus().isUnauthorized();
}
@Test
void logoutWhenRemoteLogoutFailsThenReportsPartialLogout() {
this.spring.register(WebServerConfig.class, OidcProviderConfig.class, WithBrokenLogoutConfig.class).autowire();
ServerLogoutHandler logoutHandler = this.spring.getContext().getBean(ServerLogoutHandler.class);
given(logoutHandler.logout(any(), any())).willReturn(Mono.error(() -> new IllegalStateException("illegal")));
String registrationId = this.clientRegistration.getRegistrationId();
String one = login();
String logoutToken = this.test.get().uri("/token/logout/all").cookie("SESSION", one).exchange().expectStatus()
.isOk().returnResult(String.class).getResponseBody().blockFirst();
this.test.post().uri(this.web.url("/logout/connect/back-channel/" + registrationId).toString())
.body(BodyInserters.fromFormData("logout_token", logoutToken)).exchange().expectStatus().isBadRequest()
.expectBody(String.class).value(containsString("partial_logout"));
this.test.get().uri("/token/logout").cookie("SESSION", one).exchange().expectStatus().isOk();
}
@Test
void logoutWhenCustomComponentsThenUses() {
this.spring.register(WebServerConfig.class, OidcProviderConfig.class, WithCustomComponentsConfig.class)
.autowire();
String registrationId = this.clientRegistration.getRegistrationId();
String sessionId = login();
String logoutToken = this.test.get().uri("/token/logout").cookie("SESSION", sessionId).exchange().expectStatus()
.isOk().returnResult(String.class).getResponseBody().blockFirst();
this.test.post().uri(this.web.url("/logout/connect/back-channel/" + registrationId).toString())
.body(BodyInserters.fromFormData("logout_token", logoutToken)).exchange().expectStatus().isOk();
this.test.get().uri("/token/logout").cookie("SESSION", sessionId).exchange().expectStatus().isUnauthorized();
ReactiveOidcSessionRegistry sessionRegistry = this.spring.getContext()
.getBean(ReactiveOidcSessionRegistry.class);
verify(sessionRegistry, atLeastOnce()).saveSessionInformation(any());
verify(sessionRegistry, atLeastOnce()).removeSessionInformation(any(OidcLogoutToken.class));
}
private String login() {
this.test.get().uri("/token/logout").exchange().expectStatus().isUnauthorized();
String registrationId = this.clientRegistration.getRegistrationId();
FluxExchangeResult<String> result = this.test.get().uri("/oauth2/authorization/" + registrationId).exchange()
.expectStatus().isFound().returnResult(String.class);
String sessionId = sessionId(result);
String redirectUrl = UrlUtils.decode(result.getResponseHeaders().getLocation().toString());
result = this.test
.mutateWith(mockAuthentication(new TestingAuthenticationToken(this.clientRegistration.getClientId(),
this.clientRegistration.getClientSecret(), "APP")))
.get().uri(redirectUrl).exchange().returnResult(String.class);
String state = result.getResponseBody().blockFirst();
result = this.test.mutateWith(session(sessionId)).get()
.uri("/login/oauth2/code/" + registrationId + "?code=code&state=" + state).exchange().expectStatus()
.isFound().returnResult(String.class);
return sessionId(result);
}
private String sessionId(FluxExchangeResult<?> result) {
List<ResponseCookie> cookies = result.getResponseCookies().get(SESSION_COOKIE_NAME);
if (cookies == null || cookies.isEmpty()) {
return null;
}
return cookies.get(0).getValue();
}
static SessionMutator session(String session) {
return new SessionMutator(session);
}
private record SessionMutator(String session) implements WebTestClientConfigurer {
@Override
public void afterConfigurerAdded(WebTestClient.Builder builder, WebHttpHandlerBuilder httpHandlerBuilder,
ClientHttpConnector connector) {
builder.defaultCookie(SESSION_COOKIE_NAME, this.session);
}
}
@Configuration
static class RegistrationConfig {
@Autowired(required = false)
MockWebServer web;
@Bean
ClientRegistration clientRegistration() {
if (this.web == null) {
return TestClientRegistrations.clientRegistration().build();
}
String issuer = this.web.url("/").toString();
return TestClientRegistrations.clientRegistration().issuerUri(issuer).jwkSetUri(issuer + "jwks")
.tokenUri(issuer + "token").userInfoUri(issuer + "user").scope("openid").build();
}
@Bean
ReactiveClientRegistrationRepository clientRegistrationRepository(ClientRegistration clientRegistration) {
return new InMemoryReactiveClientRegistrationRepository(clientRegistration);
}
}
@Configuration
@EnableWebFluxSecurity
@Import(RegistrationConfig.class)
static class DefaultConfig {
@Bean
@Order(1)
SecurityWebFilterChain filters(ServerHttpSecurity http) throws Exception {
// @formatter:off
http
.authorizeExchange((authorize) -> authorize.anyExchange().authenticated())
.oauth2Login(Customizer.withDefaults())
.oidcLogout((oidc) -> oidc.backChannel(Customizer.withDefaults()));
// @formatter:on
return http.build();
}
}
@Configuration
@EnableWebFluxSecurity
@Import(RegistrationConfig.class)
static class WithCustomComponentsConfig {
ReactiveOidcSessionRegistry sessionRegistry = spy(new InMemoryReactiveOidcSessionRegistry());
@Bean
@Order(1)
SecurityWebFilterChain filters(ServerHttpSecurity http) throws Exception {
// @formatter:off
http
.authorizeExchange((authorize) -> authorize.anyExchange().authenticated())
.oauth2Login((oauth2) -> oauth2.oidcSessionRegistry(this.sessionRegistry))
.oidcLogout((oidc) -> oidc.backChannel(Customizer.withDefaults()));
// @formatter:on
return http.build();
}
@Bean
ReactiveOidcSessionRegistry sessionRegistry() {
return this.sessionRegistry;
}
}
@Configuration
@EnableWebFluxSecurity
@Import(RegistrationConfig.class)
static class WithBrokenLogoutConfig {
private final ServerLogoutHandler logoutHandler = mock(ServerLogoutHandler.class);
@Bean
@Order(1)
SecurityWebFilterChain filters(ServerHttpSecurity http) throws Exception {
// @formatter:off
http
.authorizeExchange((authorize) -> authorize.anyExchange().authenticated())
.logout((logout) -> logout.logoutHandler(this.logoutHandler))
.oauth2Login(Customizer.withDefaults())
.oidcLogout((oidc) -> oidc.backChannel(Customizer.withDefaults()));
// @formatter:on
return http.build();
}
@Bean
ServerLogoutHandler logoutHandler() {
return this.logoutHandler;
}
}
@Configuration
@EnableWebFluxSecurity
@EnableWebFlux
@RestController
static class OidcProviderConfig {
private static final RSAKey key = key();
private static final JWKSource<SecurityContext> jwks = jwks(key);
private static RSAKey key() {
try {
KeyPair pair = KeyPairGenerator.getInstance("RSA").generateKeyPair();
return new RSAKey.Builder((RSAPublicKey) pair.getPublic()).privateKey(pair.getPrivate()).build();
}
catch (Exception ex) {
throw new RuntimeException(ex);
}
}
private static JWKSource<SecurityContext> jwks(RSAKey key) {
try {
return new ImmutableJWKSet<>(new JWKSet(key));
}
catch (Exception ex) {
throw new RuntimeException(ex);
}
}
private final String username = "user";
private final JwtEncoder encoder = new NimbusJwtEncoder(jwks);
private String nonce;
@Autowired
ClientRegistration registration;
static ServerWebExchangeMatcher or(String... patterns) {
List<ServerWebExchangeMatcher> matchers = new ArrayList<>();
for (String pattern : patterns) {
matchers.add(new PathPatternParserServerWebExchangeMatcher(pattern));
}
return new OrServerWebExchangeMatcher(matchers);
}
@Bean
@Order(0)
SecurityWebFilterChain authorizationServer(ServerHttpSecurity http, ClientRegistration registration)
throws Exception {
// @formatter:off
http
.securityMatcher(or("/jwks", "/login/oauth/authorize", "/nonce", "/token", "/token/logout", "/user"))
.authorizeExchange((authorize) -> authorize
.pathMatchers("/jwks").permitAll()
.anyExchange().authenticated()
)
.httpBasic(Customizer.withDefaults())
.oauth2ResourceServer((oauth2) -> oauth2
.jwt((jwt) -> jwt.jwkSetUri(registration.getProviderDetails().getJwkSetUri()))
);
// @formatter:off
return http.build();
}
@Bean
ReactiveUserDetailsService users(ClientRegistration registration) {
return new MapReactiveUserDetailsService(User.withUsername(registration.getClientId())
.password("{noop}" + registration.getClientSecret()).authorities("APP").build());
}
@GetMapping("/login/oauth/authorize")
String nonce(@RequestParam("nonce") String nonce, @RequestParam("state") String state) {
this.nonce = nonce;
return state;
}
@PostMapping("/token")
Map<String, Object> accessToken(WebSession session) {
JwtEncoderParameters parameters = JwtEncoderParameters
.from(JwtClaimsSet.builder().id("id").subject(this.username)
.issuer(this.registration.getProviderDetails().getIssuerUri()).issuedAt(Instant.now())
.expiresAt(Instant.now().plusSeconds(86400)).claim("scope", "openid").build());
String token = this.encoder.encode(parameters).getTokenValue();
return new OIDCTokens(idToken(session.getId()), new BearerAccessToken(token, 86400, new Scope("openid")), null)
.toJSONObject();
}
String idToken(String sessionId) {
OidcIdToken token = TestOidcIdTokens.idToken().issuer(this.registration.getProviderDetails().getIssuerUri())
.subject(this.username).expiresAt(Instant.now().plusSeconds(86400))
.audience(List.of(this.registration.getClientId())).nonce(this.nonce)
.claim(LogoutTokenClaimNames.SID, sessionId).build();
JwtEncoderParameters parameters = JwtEncoderParameters
.from(JwtClaimsSet.builder().claims((claims) -> claims.putAll(token.getClaims())).build());
return this.encoder.encode(parameters).getTokenValue();
}
@GetMapping("/user")
Map<String, Object> userinfo() {
return Map.of("sub", this.username, "id", this.username);
}
@GetMapping("/jwks")
String jwks() {
return new JWKSet(key).toString();
}
@GetMapping("/token/logout")
String logoutToken(@AuthenticationPrincipal OidcUser user) {
OidcLogoutToken token = TestOidcLogoutTokens.withUser(user)
.audience(List.of(this.registration.getClientId())).build();
JwtEncoderParameters parameters = JwtEncoderParameters
.from(JwtClaimsSet.builder().claims((claims) -> claims.putAll(token.getClaims())).build());
return this.encoder.encode(parameters).getTokenValue();
}
@GetMapping("/token/logout/all")
String logoutTokenAll(@AuthenticationPrincipal OidcUser user) {
OidcLogoutToken token = TestOidcLogoutTokens.withUser(user)
.audience(List.of(this.registration.getClientId()))
.claims((claims) -> claims.remove(LogoutTokenClaimNames.SID)).build();
JwtEncoderParameters parameters = JwtEncoderParameters
.from(JwtClaimsSet.builder().claims((claims) -> claims.putAll(token.getClaims())).build());
return this.encoder.encode(parameters).getTokenValue();
}
}
@Configuration
static class WebServerConfig {
private final MockWebServer server = new MockWebServer();
@Bean
MockWebServer web(ObjectProvider<WebTestClient> web) {
this.server.setDispatcher(new WebTestClientDispatcher(web));
return this.server;
}
@PreDestroy
void shutdown() throws IOException {
this.server.shutdown();
}
}
private static class WebTestClientDispatcher extends Dispatcher {
private final ObjectProvider<WebTestClient> webProvider;
private WebTestClient web;
WebTestClientDispatcher(ObjectProvider<WebTestClient> web) {
this.webProvider = web;
}
@Override
public MockResponse dispatch(RecordedRequest request) throws InterruptedException {
this.web = this.webProvider.getObject();
String method = request.getMethod();
String path = request.getPath();
String csrf = request.getHeader("X-CSRF-TOKEN");
String sessionId = session(request);
WebTestClient.RequestHeadersSpec<?> r;
if ("GET".equals(method)) {
r = this.web.get().uri(path);
}
else {
WebTestClient.RequestBodySpec body;
if (csrf == null) {
body = this.web.mutateWith(csrf()).post().uri(path);
}
else {
body = this.web.post().uri(path).header("X-CSRF-TOKEN", csrf);
}
body.body(BodyInserters.fromValue(request.getBody().readUtf8()));
r = body;
}
for (Map.Entry<String, List<String>> header : request.getHeaders().toMultimap().entrySet()) {
if (header.getKey().equalsIgnoreCase("Cookie")) {
continue;
}
r.header(header.getKey(), header.getValue().iterator().next());
}
if (sessionId != null) {
r.cookie(SESSION_COOKIE_NAME, sessionId);
}
try {
FluxExchangeResult<String> result = r.exchange().returnResult(String.class);
return toMockResponse(result);
}
catch (Exception ex) {
MockResponse response = new MockResponse();
response.setResponseCode(500);
response.setBody(ex.getMessage());
return response;
}
}
private String session(RecordedRequest request) {
String cookieHeaderValue = request.getHeader("Cookie");
if (cookieHeaderValue == null) {
return null;
}
String[] cookies = cookieHeaderValue.split(";");
for (String cookie : cookies) {
String[] parts = cookie.split("=");
if (SESSION_COOKIE_NAME.equals(parts[0])) {
return parts[1];
}
}
return null;
}
private MockResponse toMockResponse(FluxExchangeResult<String> result) {
MockResponse response = new MockResponse();
response.setResponseCode(result.getStatus().value());
for (String name : result.getResponseHeaders().keySet()) {
response.addHeader(name, result.getResponseHeaders().getFirst(name));
}
String body = result.getResponseBody().blockFirst();
if (body != null) {
response.setBody(body);
}
return response;
}
}
}

View File

@@ -717,7 +717,7 @@ public class ServerHttpSecurityTests {
private <T extends WebFilter> Optional<T> getWebFilter(SecurityWebFilterChain filterChain, Class<T> filterClass) {
return (Optional<T>) filterChain.getWebFilters().filter(Objects::nonNull)
.filter((filter) -> filter.getClass().isAssignableFrom(filterClass)).singleOrEmpty().blockOptional();
.filter((filter) -> filterClass.isAssignableFrom(filter.getClass())).singleOrEmpty().blockOptional();
}
private WebTestClient buildClient() {