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