From d7599ab1926a8a6cba0e4385476654962d72a07b Mon Sep 17 00:00:00 2001 From: Josh Cummings Date: Tue, 30 Jan 2024 14:27:37 -0700 Subject: [PATCH] Polish setAttributesConverter - Add Tests - Add Reactive Support Issue gh-14186 --- .../DefaultReactiveOAuth2UserService.java | 35 ++++++++++- .../OidcReactiveOAuth2UserServiceTests.java | 63 ++++++++++++++++++- .../DefaultOAuth2UserServiceTests.java | 6 ++ ...DefaultReactiveOAuth2UserServiceTests.java | 48 +++++++++++++- 4 files changed, 148 insertions(+), 4 deletions(-) diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/userinfo/DefaultReactiveOAuth2UserService.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/userinfo/DefaultReactiveOAuth2UserService.java index 90c33fd41b..920119baab 100644 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/userinfo/DefaultReactiveOAuth2UserService.java +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/userinfo/DefaultReactiveOAuth2UserService.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2024 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. @@ -26,6 +26,7 @@ import net.minidev.json.JSONObject; import reactor.core.publisher.Mono; import org.springframework.core.ParameterizedTypeReference; +import org.springframework.core.convert.converter.Converter; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatusCode; import org.springframework.http.MediaType; @@ -78,6 +79,9 @@ public class DefaultReactiveOAuth2UserService implements ReactiveOAuth2UserServi private static final ParameterizedTypeReference> STRING_STRING_MAP = new ParameterizedTypeReference>() { }; + private Converter, Map>> attributesConverter = ( + request) -> (attributes) -> attributes; + private WebClient webClient = WebClient.create(); @Override @@ -123,7 +127,8 @@ public class DefaultReactiveOAuth2UserService implements ReactiveOAuth2UserServi throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString()); }) ) - .bodyToMono(DefaultReactiveOAuth2UserService.STRING_OBJECT_MAP); + .bodyToMono(DefaultReactiveOAuth2UserService.STRING_OBJECT_MAP) + .mapNotNull((attributes) -> this.attributesConverter.convert(userRequest).convert(attributes)); return userAttributes.map((attrs) -> { GrantedAuthority authority = new OAuth2UserAuthority(attrs); Set authorities = new HashSet<>(); @@ -184,6 +189,32 @@ public class DefaultReactiveOAuth2UserService implements ReactiveOAuth2UserServi // @formatter:on } + /** + * Use this strategy to adapt user attributes into a format understood by Spring + * Security; by default, the original attributes are preserved. + * + *

+ * This can be helpful, for example, if the user attribute is nested. Since Spring + * Security needs the username attribute to be at the top level, you can use this + * method to do: + * + *

+	 *     DefaultReactiveOAuth2UserService userService = new DefaultReactiveOAuth2UserService();
+	 *     userService.setAttributesConverter((userRequest) -> (attributes) ->
+	 *         Map<String, Object> userObject = (Map<String, Object>) attributes.get("user");
+	 *         attributes.put("user-name", userObject.get("user-name"));
+	 *         return attributes;
+	 *     });
+	 * 
+ * @param attributesConverter the attribute adaptation strategy to use + * @since 6.3 + */ + public void setAttributesConverter( + Converter, Map>> attributesConverter) { + Assert.notNull(attributesConverter, "attributesConverter cannot be null"); + this.attributesConverter = attributesConverter; + } + /** * Sets the {@link WebClient} used for retrieving the user endpoint * @param webClient the client to use diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/userinfo/OidcReactiveOAuth2UserServiceTests.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/userinfo/OidcReactiveOAuth2UserServiceTests.java index 2b8e6180df..14acfdea16 100644 --- a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/userinfo/OidcReactiveOAuth2UserServiceTests.java +++ b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/userinfo/OidcReactiveOAuth2UserServiceTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2024 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. @@ -16,6 +16,7 @@ package org.springframework.security.oauth2.client.oidc.userinfo; +import java.io.IOException; import java.time.Duration; import java.time.Instant; import java.util.Collections; @@ -24,6 +25,8 @@ import java.util.Iterator; import java.util.Map; import java.util.function.Function; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -32,13 +35,17 @@ import org.mockito.junit.jupiter.MockitoExtension; import reactor.core.publisher.Mono; import org.springframework.core.convert.converter.Converter; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.AuthorityUtils; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.oauth2.client.registration.ClientRegistration; import org.springframework.security.oauth2.client.registration.TestClientRegistrations; +import org.springframework.security.oauth2.client.userinfo.DefaultReactiveOAuth2UserService; import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; import org.springframework.security.oauth2.client.userinfo.ReactiveOAuth2UserService; +import org.springframework.security.oauth2.core.AuthenticationMethod; import org.springframework.security.oauth2.core.OAuth2AccessToken; import org.springframework.security.oauth2.core.OAuth2AuthenticationException; import org.springframework.security.oauth2.core.TestOAuth2AccessTokens; @@ -203,8 +210,62 @@ public class OidcReactiveOAuth2UserServiceTests { assertThat(userAuthority.getAttributes()).isEqualTo(user.getAttributes()); } + @Test + public void loadUserWhenNestedUserInfoSuccessThenReturnUser() throws IOException { + // @formatter:off + String userInfoResponse = "{\n" + + " \"user\": {\"user-name\": \"user1\"},\n" + + " \"sub\" : \"" + this.idToken.getSubject() + "\",\n" + + " \"first-name\": \"first\",\n" + + " \"last-name\": \"last\",\n" + + " \"middle-name\": \"middle\",\n" + + " \"address\": \"address\",\n" + + " \"email\": \"user1@example.com\"\n" + + "}\n"; + // @formatter:on + try (MockWebServer server = new MockWebServer()) { + server.start(); + enqueueApplicationJsonBody(server, userInfoResponse); + String userInfoUri = server.url("/user").toString(); + ClientRegistration clientRegistration = TestClientRegistrations.clientRegistration() + .userInfoUri(userInfoUri) + .userInfoAuthenticationMethod(AuthenticationMethod.HEADER) + .userNameAttributeName("user-name") + .build(); + OidcReactiveOAuth2UserService userService = new OidcReactiveOAuth2UserService(); + DefaultReactiveOAuth2UserService oAuth2UserService = new DefaultReactiveOAuth2UserService(); + oAuth2UserService.setAttributesConverter((request) -> (attributes) -> { + Map user = (Map) attributes.get("user"); + attributes.put("user-name", user.get("user-name")); + return attributes; + }); + userService.setOauth2UserService(oAuth2UserService); + OAuth2User user = userService + .loadUser(new OidcUserRequest(clientRegistration, this.accessToken, this.idToken)) + .block(); + assertThat(user.getName()).isEqualTo("user1"); + assertThat(user.getAttributes()).hasSize(13); + assertThat(((Map) user.getAttribute("user")).get("user-name")).isEqualTo("user1"); + assertThat((String) user.getAttribute("first-name")).isEqualTo("first"); + assertThat((String) user.getAttribute("last-name")).isEqualTo("last"); + assertThat((String) user.getAttribute("middle-name")).isEqualTo("middle"); + assertThat((String) user.getAttribute("address")).isEqualTo("address"); + assertThat((String) user.getAttribute("email")).isEqualTo("user1@example.com"); + assertThat(user.getAuthorities()).hasSize(2); + assertThat(user.getAuthorities().iterator().next()).isInstanceOf(OAuth2UserAuthority.class); + OAuth2UserAuthority userAuthority = (OAuth2UserAuthority) user.getAuthorities().iterator().next(); + assertThat(userAuthority.getAuthority()).isEqualTo("OIDC_USER"); + assertThat(userAuthority.getAttributes()).isEqualTo(user.getAttributes()); + } + } + private OidcUserRequest userRequest() { return new OidcUserRequest(this.registration.build(), this.accessToken, this.idToken); } + private void enqueueApplicationJsonBody(MockWebServer server, String json) { + server.enqueue( + new MockResponse().setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE).setBody(json)); + } + } diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/userinfo/DefaultOAuth2UserServiceTests.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/userinfo/DefaultOAuth2UserServiceTests.java index e7a04c8db9..99457d4e3e 100644 --- a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/userinfo/DefaultOAuth2UserServiceTests.java +++ b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/userinfo/DefaultOAuth2UserServiceTests.java @@ -413,6 +413,12 @@ public class DefaultOAuth2UserServiceTests { + "from '" + userInfoUri + "': response contains invalid content type 'text/plain'."); } + @Test + public void setAttributesConverterWhenNullThenException() { + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> this.userService.setAttributesConverter(null)); + } + private DefaultOAuth2UserService withMockResponse(Map response) { ResponseEntity> responseEntity = new ResponseEntity<>(response, HttpStatus.OK); Converter> requestEntityConverter = mock(Converter.class); diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/userinfo/DefaultReactiveOAuth2UserServiceTests.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/userinfo/DefaultReactiveOAuth2UserServiceTests.java index c9989ae320..68aa1a31d3 100644 --- a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/userinfo/DefaultReactiveOAuth2UserServiceTests.java +++ b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/userinfo/DefaultReactiveOAuth2UserServiceTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2024 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. @@ -165,6 +165,46 @@ public class DefaultReactiveOAuth2UserServiceTests { assertThatNoException().isThrownBy(() -> this.userService.loadUser(oauth2UserRequest()).block()); } + @Test + public void loadUserWhenNestedUserInfoSuccessThenReturnUser() { + // @formatter:off + String userInfoResponse = "{\n" + + " \"user\": {\"user-name\": \"user1\"},\n" + + " \"first-name\": \"first\",\n" + + " \"last-name\": \"last\",\n" + + " \"middle-name\": \"middle\",\n" + + " \"address\": \"address\",\n" + + " \"email\": \"user1@example.com\"\n" + + "}\n"; + // @formatter:on + enqueueApplicationJsonBody(userInfoResponse); + String userInfoUri = this.server.url("/user").toString(); + ClientRegistration clientRegistration = this.clientRegistration.userInfoUri(userInfoUri) + .userInfoAuthenticationMethod(AuthenticationMethod.HEADER) + .userNameAttributeName("user-name") + .build(); + DefaultReactiveOAuth2UserService userService = new DefaultReactiveOAuth2UserService(); + userService.setAttributesConverter((request) -> (attributes) -> { + Map user = (Map) attributes.get("user"); + attributes.put("user-name", user.get("user-name")); + return attributes; + }); + OAuth2User user = userService.loadUser(new OAuth2UserRequest(clientRegistration, this.accessToken)).block(); + assertThat(user.getName()).isEqualTo("user1"); + assertThat(user.getAttributes()).hasSize(7); + assertThat(((Map) user.getAttribute("user")).get("user-name")).isEqualTo("user1"); + assertThat((String) user.getAttribute("first-name")).isEqualTo("first"); + assertThat((String) user.getAttribute("last-name")).isEqualTo("last"); + assertThat((String) user.getAttribute("middle-name")).isEqualTo("middle"); + assertThat((String) user.getAttribute("address")).isEqualTo("address"); + assertThat((String) user.getAttribute("email")).isEqualTo("user1@example.com"); + assertThat(user.getAuthorities()).hasSize(1); + assertThat(user.getAuthorities().iterator().next()).isInstanceOf(OAuth2UserAuthority.class); + OAuth2UserAuthority userAuthority = (OAuth2UserAuthority) user.getAuthorities().iterator().next(); + assertThat(userAuthority.getAuthority()).isEqualTo("OAUTH2_USER"); + assertThat(userAuthority.getAttributes()).isEqualTo(user.getAttributes()); + } + // gh-5500 @Test public void loadUserWhenAuthenticationMethodHeaderSuccessResponseThenHttpMethodGet() throws Exception { @@ -290,6 +330,12 @@ public class DefaultReactiveOAuth2UserServiceTests { + "response contains invalid content type 'text/plain'"); } + @Test + public void setAttributesConverterWhenNullThenException() { + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> this.userService.setAttributesConverter(null)); + } + private DefaultReactiveOAuth2UserService withMockResponse(Map body) { WebClient real = WebClient.builder().build(); WebClient.RequestHeadersUriSpec spec = spy(real.post());