|
@@ -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;
|
|
|
+ }
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+}
|