From 8c32d5fe48c1b1e8f742d15fd389eaba1bc407a5 Mon Sep 17 00:00:00 2001 From: Josh Cummings Date: Mon, 2 Dec 2019 22:25:38 -0700 Subject: [PATCH] Add oidcLogin WebFlux Test Support Fixes: gh-7680 --- .../sample/OAuth2LoginApplicationTests.java | 67 ++++++ .../sample/OAuth2LoginControllerTests.java | 84 +++++++ .../server/SecurityMockServerConfigurers.java | 213 ++++++++++++++++++ ...tyMockServerConfigurersOidcLoginTests.java | 164 ++++++++++++++ 4 files changed, 528 insertions(+) create mode 100644 samples/boot/oauth2login-webflux/src/integration-test/java/sample/OAuth2LoginApplicationTests.java create mode 100644 samples/boot/oauth2login-webflux/src/test/java/sample/OAuth2LoginControllerTests.java create mode 100644 test/src/test/java/org/springframework/security/test/web/reactive/server/SecurityMockServerConfigurersOidcLoginTests.java diff --git a/samples/boot/oauth2login-webflux/src/integration-test/java/sample/OAuth2LoginApplicationTests.java b/samples/boot/oauth2login-webflux/src/integration-test/java/sample/OAuth2LoginApplicationTests.java new file mode 100644 index 0000000000..f694edb002 --- /dev/null +++ b/samples/boot/oauth2login-webflux/src/integration-test/java/sample/OAuth2LoginApplicationTests.java @@ -0,0 +1,67 @@ +/* + * Copyright 2002-2019 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 sample; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.security.oauth2.client.registration.ReactiveClientRegistrationRepository; +import org.springframework.security.oauth2.client.web.server.ServerOAuth2AuthorizedClientRepository; +import org.springframework.security.oauth2.client.web.server.WebSessionServerOAuth2AuthorizedClientRepository; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.web.reactive.server.WebTestClient; + +import static org.hamcrest.core.StringContains.containsString; +import static org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers.mockOidcLogin; + +/** + * Tests for {@link ReactiveOAuth2LoginApplication} + */ +@RunWith(SpringRunner.class) +@SpringBootTest +@AutoConfigureWebTestClient +public class OAuth2LoginApplicationTests { + + @Autowired + WebTestClient test; + + @Autowired + ReactiveClientRegistrationRepository clientRegistrationRepository; + + @TestConfiguration + static class AuthorizedClient { + @Bean + ServerOAuth2AuthorizedClientRepository authorizedClientRepository() { + return new WebSessionServerOAuth2AuthorizedClientRepository(); + } + } + + @Test + public void requestWhenMockOidcLoginThenIndex() { + this.clientRegistrationRepository.findByRegistrationId("github") + .map(clientRegistration -> + this.test.mutateWith(mockOidcLogin().clientRegistration(clientRegistration)) + .get().uri("/") + .exchange() + .expectBody(String.class).value(containsString("GitHub")) + ).block(); + } +} diff --git a/samples/boot/oauth2login-webflux/src/test/java/sample/OAuth2LoginControllerTests.java b/samples/boot/oauth2login-webflux/src/test/java/sample/OAuth2LoginControllerTests.java new file mode 100644 index 0000000000..5c7d2ce1f8 --- /dev/null +++ b/samples/boot/oauth2login-webflux/src/test/java/sample/OAuth2LoginControllerTests.java @@ -0,0 +1,84 @@ +/* + * Copyright 2002-2019 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 sample; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import sample.web.OAuth2LoginController; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest; +import org.springframework.core.ReactiveAdapterRegistry; +import org.springframework.security.oauth2.client.registration.ReactiveClientRegistrationRepository; +import org.springframework.security.oauth2.client.web.reactive.result.method.annotation.OAuth2AuthorizedClientArgumentResolver; +import org.springframework.security.oauth2.client.web.server.ServerOAuth2AuthorizedClientRepository; +import org.springframework.security.oauth2.client.web.server.WebSessionServerOAuth2AuthorizedClientRepository; +import org.springframework.security.web.reactive.result.method.annotation.AuthenticationPrincipalArgumentResolver; +import org.springframework.security.web.server.context.SecurityContextServerWebExchangeWebFilter; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.web.reactive.result.view.ViewResolver; + +import static org.hamcrest.Matchers.containsString; +import static org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers.mockOidcLogin; +import static org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers.springSecurity; + +/** + * @author Josh Cummings + */ +@RunWith(SpringRunner.class) +@WebFluxTest(OAuth2LoginController.class) +public class OAuth2LoginControllerTests { + + @Autowired + OAuth2LoginController controller; + + @Autowired + ViewResolver viewResolver; + + @Mock + ReactiveClientRegistrationRepository clientRegistrationRepository; + + WebTestClient rest; + + @Before + public void setup() { + ServerOAuth2AuthorizedClientRepository authorizedClientRepository = + new WebSessionServerOAuth2AuthorizedClientRepository(); + + this.rest = WebTestClient + .bindToController(this.controller) + .apply(springSecurity()) + .webFilter(new SecurityContextServerWebExchangeWebFilter()) + .argumentResolvers(c -> { + c.addCustomResolver(new AuthenticationPrincipalArgumentResolver(new ReactiveAdapterRegistry())); + c.addCustomResolver(new OAuth2AuthorizedClientArgumentResolver + (this.clientRegistrationRepository, authorizedClientRepository)); + }) + .viewResolvers(c -> c.viewResolver(this.viewResolver)) + .build(); + } + + @Test + public void indexGreetsAuthenticatedUser() { + this.rest.mutateWith(mockOidcLogin()) + .get().uri("/").exchange() + .expectBody(String.class).value(containsString("test-subject")); + } +} diff --git a/test/src/main/java/org/springframework/security/test/web/reactive/server/SecurityMockServerConfigurers.java b/test/src/main/java/org/springframework/security/test/web/reactive/server/SecurityMockServerConfigurers.java index 89ba9fb964..f0d7fec53a 100644 --- a/test/src/main/java/org/springframework/security/test/web/reactive/server/SecurityMockServerConfigurers.java +++ b/test/src/main/java/org/springframework/security/test/web/reactive/server/SecurityMockServerConfigurers.java @@ -18,7 +18,10 @@ package org.springframework.security.test.web.reactive.server; import java.util.Arrays; import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Set; import java.util.function.Consumer; import java.util.function.Supplier; @@ -30,11 +33,25 @@ import org.springframework.lang.Nullable; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.ReactiveSecurityContextHolder; import org.springframework.security.core.context.SecurityContext; import org.springframework.security.core.context.SecurityContextImpl; import org.springframework.security.core.userdetails.User; import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClient; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.web.server.ServerOAuth2AuthorizedClientRepository; +import org.springframework.security.oauth2.client.web.server.WebSessionServerOAuth2AuthorizedClientRepository; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.oidc.IdTokenClaimNames; +import org.springframework.security.oauth2.core.oidc.OidcIdToken; +import org.springframework.security.oauth2.core.oidc.OidcUserInfo; +import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; +import org.springframework.security.oauth2.core.oidc.user.OidcUserAuthority; import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter; @@ -130,6 +147,21 @@ public class SecurityMockServerConfigurers { return new JwtMutator(); } + /** + * Updates the ServerWebExchange to establish a {@link SecurityContext} that has a + * {@link OAuth2AuthenticationToken} for the + * {@link Authentication}. All details are + * declarative and do not require the corresponding OAuth 2.0 tokens to be valid. + * + * @return the {@link OidcLoginMutator} to further configure or use + * @since 5.3 + */ + public static OidcLoginMutator mockOidcLogin() { + OAuth2AccessToken accessToken = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, "access-token", + null, null, Collections.singleton("user")); + return new OidcLoginMutator(accessToken); + } + public static CsrfMutator csrf() { return new CsrfMutator(); } @@ -429,4 +461,185 @@ public class SecurityMockServerConfigurers { return mockAuthentication(new JwtAuthenticationToken(this.jwt, this.authoritiesConverter.convert(this.jwt))); } } + + /** + * @author Josh Cummings + * @since 5.3 + */ + public final static class OidcLoginMutator implements WebTestClientConfigurer, MockServerConfigurer { + private ClientRegistration clientRegistration; + private OAuth2AccessToken accessToken; + private OidcIdToken idToken; + private OidcUserInfo userInfo; + private OidcUser oidcUser; + private Collection authorities; + + ServerOAuth2AuthorizedClientRepository authorizedClientRepository = + new WebSessionServerOAuth2AuthorizedClientRepository(); + + private OidcLoginMutator(OAuth2AccessToken accessToken) { + this.accessToken = accessToken; + this.clientRegistration = clientRegistrationBuilder().build(); + } + + /** + * Use the provided authorities in the {@link Authentication} + * + * @param authorities the authorities to use + * @return the {@link OidcLoginMutator} for further configuration + */ + public OidcLoginMutator authorities(Collection authorities) { + Assert.notNull(authorities, "authorities cannot be null"); + this.authorities = authorities; + return this; + } + + /** + * Use the provided authorities in the {@link Authentication} + * + * @param authorities the authorities to use + * @return the {@link OidcLoginMutator} for further configuration + */ + public OidcLoginMutator authorities(GrantedAuthority... authorities) { + Assert.notNull(authorities, "authorities cannot be null"); + this.authorities = Arrays.asList(authorities); + return this; + } + + /** + * Use the provided {@link OidcIdToken} when constructing the authenticated user + * + * @param idTokenBuilderConsumer a {@link Consumer} of a {@link OidcIdToken.Builder} + * @return the {@link OidcLoginMutator} for further configuration + */ + public OidcLoginMutator idToken(Consumer idTokenBuilderConsumer) { + OidcIdToken.Builder builder = OidcIdToken.withTokenValue("id-token"); + builder.subject("test-subject"); + idTokenBuilderConsumer.accept(builder); + this.idToken = builder.build(); + return this; + } + + /** + * Use the provided {@link OidcUserInfo} when constructing the authenticated user + * + * @param userInfoBuilderConsumer a {@link Consumer} of a {@link OidcUserInfo.Builder} + * @return the {@link OidcLoginMutator} for further configuration + */ + public OidcLoginMutator userInfoToken(Consumer userInfoBuilderConsumer) { + OidcUserInfo.Builder builder = OidcUserInfo.builder(); + userInfoBuilderConsumer.accept(builder); + this.userInfo = builder.build(); + return this; + } + + /** + * Use the provided {@link OidcUser} as the authenticated user. + *

+ * Supplying an {@link OidcUser} will take precedence over {@link #idToken}, {@link #userInfo}, + * and list of {@link GrantedAuthority}s to use. + * + * @param oidcUser the {@link OidcUser} to use + * @return the {@link OidcLoginMutator} for further configuration + */ + public OidcLoginMutator oidcUser(OidcUser oidcUser) { + this.oidcUser = oidcUser; + return this; + } + + /** + * Use the provided {@link ClientRegistration} as the client to authorize. + *

+ * The supplied {@link ClientRegistration} will be registered into an + * {@link WebSessionServerOAuth2AuthorizedClientRepository}. Tests relying on + * {@link org.springframework.security.oauth2.client.annotation.RegisteredOAuth2AuthorizedClient} + * annotations should register an {@link WebSessionServerOAuth2AuthorizedClientRepository} bean + * to the application context. + * + * @param clientRegistration the {@link ClientRegistration} to use + * @return the {@link OidcLoginMutator} for further configuration + */ + public OidcLoginMutator clientRegistration(ClientRegistration clientRegistration) { + this.clientRegistration = clientRegistration; + return this; + } + + @Override + public void beforeServerCreated(WebHttpHandlerBuilder builder) { + OAuth2AuthenticationToken token = getToken(); + builder.filters(addAuthorizedClientFilter(token)); + mockAuthentication(getToken()).beforeServerCreated(builder); + } + + @Override + public void afterConfigureAdded(WebTestClient.MockServerSpec serverSpec) { + mockAuthentication(getToken()).afterConfigureAdded(serverSpec); + } + + @Override + public void afterConfigurerAdded( + WebTestClient.Builder builder, + @Nullable WebHttpHandlerBuilder httpHandlerBuilder, + @Nullable ClientHttpConnector connector) { + OAuth2AuthenticationToken token = getToken(); + httpHandlerBuilder.filters(addAuthorizedClientFilter(token)); + mockAuthentication(token).afterConfigurerAdded(builder, httpHandlerBuilder, connector); + } + + private Consumer> addAuthorizedClientFilter(OAuth2AuthenticationToken token) { + OAuth2AuthorizedClient client = getClient(); + return filters -> filters.add(0, (exchange, chain) -> + authorizedClientRepository.saveAuthorizedClient(client, token, exchange) + .then(chain.filter(exchange))); + } + + private ClientRegistration.Builder clientRegistrationBuilder() { + return ClientRegistration.withRegistrationId("test") + .authorizationGrantType(AuthorizationGrantType.PASSWORD) + .clientId("test-client") + .tokenUri("https://token-uri.example.org"); + } + + private OAuth2AuthenticationToken getToken() { + OidcUser oidcUser = getOidcUser(); + return new OAuth2AuthenticationToken(oidcUser, oidcUser.getAuthorities(), this.clientRegistration.getRegistrationId()); + } + + private OAuth2AuthorizedClient getClient() { + return new OAuth2AuthorizedClient(this.clientRegistration, getToken().getName(), this.accessToken); + } + + private Collection getAuthorities() { + if (this.authorities == null) { + Set authorities = new LinkedHashSet<>(); + authorities.add(new OidcUserAuthority(getOidcIdToken(), getOidcUserInfo())); + for (String authority : this.accessToken.getScopes()) { + authorities.add(new SimpleGrantedAuthority("SCOPE_" + authority)); + } + return authorities; + } else { + return this.authorities; + } + } + + private OidcIdToken getOidcIdToken() { + if (this.idToken == null) { + return new OidcIdToken("id-token", null, null, Collections.singletonMap(IdTokenClaimNames.SUB, "test-subject")); + } else { + return this.idToken; + } + } + + private OidcUserInfo getOidcUserInfo() { + return this.userInfo; + } + + private OidcUser getOidcUser() { + if (this.oidcUser == null) { + return new DefaultOidcUser(getAuthorities(), getOidcIdToken(), this.userInfo); + } else { + return this.oidcUser; + } + } + } } diff --git a/test/src/test/java/org/springframework/security/test/web/reactive/server/SecurityMockServerConfigurersOidcLoginTests.java b/test/src/test/java/org/springframework/security/test/web/reactive/server/SecurityMockServerConfigurersOidcLoginTests.java new file mode 100644 index 0000000000..3804cb22fb --- /dev/null +++ b/test/src/test/java/org/springframework/security/test/web/reactive/server/SecurityMockServerConfigurersOidcLoginTests.java @@ -0,0 +1,164 @@ +/* + * Copyright 2002-2019 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.test.web.reactive.server; + +import java.util.Collection; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClient; +import org.springframework.security.oauth2.client.annotation.RegisteredOAuth2AuthorizedClient; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.security.oauth2.client.registration.ReactiveClientRegistrationRepository; +import org.springframework.security.oauth2.client.web.reactive.result.method.annotation.OAuth2AuthorizedClientArgumentResolver; +import org.springframework.security.oauth2.client.web.server.ServerOAuth2AuthorizedClientRepository; +import org.springframework.security.oauth2.client.web.server.WebSessionServerOAuth2AuthorizedClientRepository; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; +import org.springframework.security.web.server.context.SecurityContextServerWebExchangeWebFilter; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers.mockOidcLogin; +import static org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers.springSecurity; + +@RunWith(MockitoJUnitRunner.class) +public class SecurityMockServerConfigurersOidcLoginTests extends AbstractMockServerConfigurersTests { + private OAuth2LoginController controller = new OAuth2LoginController(); + + @Mock + private ReactiveClientRegistrationRepository clientRegistrationRepository; + + private WebTestClient client; + + @Before + public void setup() { + ServerOAuth2AuthorizedClientRepository authorizedClientRepository = + new WebSessionServerOAuth2AuthorizedClientRepository(); + + this.client = WebTestClient + .bindToController(this.controller) + .argumentResolvers(c -> c.addCustomResolver( + new OAuth2AuthorizedClientArgumentResolver + (this.clientRegistrationRepository, authorizedClientRepository))) + .webFilter(new SecurityContextServerWebExchangeWebFilter()) + .apply(springSecurity()) + .configureClient() + .defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE) + .build(); + } + + @Test + public void oidcLoginWhenUsingDefaultsThenProducesDefaultAuthentication() { + this.client.mutateWith(mockOidcLogin()) + .get().uri("/token") + .exchange() + .expectStatus().isOk(); + + OAuth2AuthenticationToken token = this.controller.token; + assertThat(token).isNotNull(); + assertThat(token.getAuthorizedClientRegistrationId()).isEqualTo("test"); + assertThat(token.getPrincipal()).isInstanceOf(OidcUser.class); + assertThat(token.getPrincipal().getAttributes()) + .containsEntry("sub", "test-subject"); + assertThat((Collection) token.getPrincipal().getAuthorities()) + .contains(new SimpleGrantedAuthority("SCOPE_user")); + assertThat(((OidcUser) token.getPrincipal()).getIdToken().getTokenValue()) + .isEqualTo("id-token"); + } + + @Test + public void oidcLoginWhenUsingDefaultsThenProducesDefaultAuthorizedClient() { + this.client.mutateWith(mockOidcLogin()) + .get().uri("/client") + .exchange() + .expectStatus().isOk(); + + OAuth2AuthorizedClient client = this.controller.authorizedClient; + assertThat(client).isNotNull(); + assertThat(client.getClientRegistration().getRegistrationId()).isEqualTo("test"); + assertThat(client.getAccessToken().getTokenValue()).isEqualTo("access-token"); + assertThat(client.getRefreshToken()).isNull(); + } + + @Test + public void oidcLoginWhenAuthoritiesSpecifiedThenGrantsAccess() { + this.client.mutateWith(mockOidcLogin() + .authorities(new SimpleGrantedAuthority("SCOPE_admin"))) + .get().uri("/token") + .exchange() + .expectStatus().isOk(); + + OAuth2AuthenticationToken token = this.controller.token; + assertThat((Collection) token.getPrincipal().getAuthorities()) + .contains(new SimpleGrantedAuthority("SCOPE_admin")); + } + + @Test + public void oidcLoginWhenIdTokenSpecifiedThenUserHasClaims() { + this.client.mutateWith(mockOidcLogin() + .idToken(i -> i.issuer("https://idp.example.org"))) + .get().uri("/token") + .exchange() + .expectStatus().isOk(); + + OAuth2AuthenticationToken token = this.controller.token; + assertThat(token.getPrincipal().getAttributes()) + .containsEntry("iss", "https://idp.example.org"); + } + + @Test + public void oidcLoginWhenUserInfoSpecifiedThenUserHasClaims() throws Exception { + this.client.mutateWith(mockOidcLogin() + .userInfoToken(u -> u.email("email@email"))) + .get().uri("/token") + .exchange() + .expectStatus().isOk(); + + OAuth2AuthenticationToken token = this.controller.token; + assertThat(token.getPrincipal().getAttributes()) + .containsEntry("email", "email@email"); + } + + @RestController + static class OAuth2LoginController { + volatile OAuth2AuthenticationToken token; + volatile OAuth2AuthorizedClient authorizedClient; + + @GetMapping("/token") + OAuth2AuthenticationToken token(OAuth2AuthenticationToken token) { + this.token = token; + return token; + } + + @GetMapping("/client") + String authorizedClient + (@RegisteredOAuth2AuthorizedClient OAuth2AuthorizedClient authorizedClient) { + this.authorizedClient = authorizedClient; + return authorizedClient.getPrincipalName(); + } + } +}