Skip to content

Commit ef59929

Browse files
committed
Add support for nested user-name-attribute using dot notation
Implement the ability to use dot notation in user-name-attribute to access nested properties in OAuth2 user info responses. Closes gh-16390 Signed-off-by: yybmion <[email protected]>
1 parent 455a2ec commit ef59929

File tree

4 files changed

+217
-5
lines changed

4 files changed

+217
-5
lines changed

oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/userinfo/DefaultOAuth2UserService.java

+48-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2024 the original author or authors.
2+
* Copyright 2002-2025 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -17,6 +17,7 @@
1717
package org.springframework.security.oauth2.client.userinfo;
1818

1919
import java.util.Collection;
20+
import java.util.HashMap;
2021
import java.util.LinkedHashSet;
2122
import java.util.Map;
2223

@@ -95,6 +96,16 @@ public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2Authentic
9596
ResponseEntity<Map<String, Object>> response = getResponse(userRequest, request);
9697
OAuth2AccessToken token = userRequest.getAccessToken();
9798
Map<String, Object> attributes = this.attributesConverter.convert(userRequest).convert(response.getBody());
99+
100+
if (userNameAttributeName.contains(".") && !attributes.containsKey(userNameAttributeName)) {
101+
Object nestedValue = extractNestedAttribute(attributes, userNameAttributeName);
102+
if (nestedValue != null) {
103+
Map<String, Object> enhancedAttributes = new HashMap<>(attributes);
104+
enhancedAttributes.put(userNameAttributeName, nestedValue);
105+
attributes = enhancedAttributes;
106+
}
107+
}
108+
98109
Collection<GrantedAuthority> authorities = getAuthorities(token, attributes, userNameAttributeName);
99110
return new DefaultOAuth2User(authorities, attributes, userNameAttributeName);
100111
}
@@ -197,6 +208,42 @@ private Collection<GrantedAuthority> getAuthorities(OAuth2AccessToken token, Map
197208
return authorities;
198209
}
199210

211+
/**
212+
* Extract a value from nested attributes using a dot-notation path. For example,
213+
* "data.username" would extract the "username" field from the "data" object.
214+
* @param attributes the map of attributes
215+
* @param attributePath the attribute path in dot notation
216+
* @return the value at the specified path, or null if not found
217+
*/
218+
private Object extractNestedAttribute(Map<String, Object> attributes, String attributePath) {
219+
if (attributes == null || attributePath == null) {
220+
return null;
221+
}
222+
223+
if (!attributePath.contains(".")) {
224+
return attributes.get(attributePath);
225+
}
226+
227+
String[] pathParts = attributePath.split("\\.");
228+
Object currentValue = attributes;
229+
230+
for (String part : pathParts) {
231+
if (!(currentValue instanceof Map)) {
232+
return null;
233+
}
234+
235+
@SuppressWarnings("unchecked")
236+
Map<String, Object> currentMap = (Map<String, Object>) currentValue;
237+
currentValue = currentMap.get(part);
238+
239+
if (currentValue == null) {
240+
return null;
241+
}
242+
}
243+
244+
return currentValue;
245+
}
246+
200247
/**
201248
* Sets the {@link Converter} used for converting the {@link OAuth2UserRequest} to a
202249
* {@link RequestEntity} representation of the UserInfo Request.

oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/userinfo/DefaultReactiveOAuth2UserService.java

+51-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2024 the original author or authors.
2+
* Copyright 2002-2025 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -16,6 +16,7 @@
1616

1717
package org.springframework.security.oauth2.client.userinfo;
1818

19+
import java.util.HashMap;
1920
import java.util.HashSet;
2021
import java.util.Map;
2122
import java.util.Set;
@@ -128,7 +129,19 @@ public Mono<OAuth2User> loadUser(OAuth2UserRequest userRequest) throws OAuth2Aut
128129
})
129130
)
130131
.bodyToMono(DefaultReactiveOAuth2UserService.STRING_OBJECT_MAP)
131-
.mapNotNull((attributes) -> this.attributesConverter.convert(userRequest).convert(attributes));
132+
.mapNotNull((attributes) -> {
133+
Map<String, Object> convertedAttributes = this.attributesConverter.convert(userRequest).convert(attributes);
134+
if (userNameAttributeName.contains(".") && !convertedAttributes.containsKey(userNameAttributeName)) {
135+
Object nestedValue = extractNestedAttribute(convertedAttributes, userNameAttributeName);
136+
if (nestedValue != null) {
137+
Map<String, Object> enhancedAttributes = new HashMap<>(convertedAttributes);
138+
enhancedAttributes.put(userNameAttributeName, nestedValue);
139+
return enhancedAttributes;
140+
}
141+
}
142+
143+
return convertedAttributes;
144+
});
132145
return userAttributes.map((attrs) -> {
133146
GrantedAuthority authority = new OAuth2UserAuthority(attrs, userNameAttributeName);
134147
Set<GrantedAuthority> authorities = new HashSet<>();
@@ -189,6 +202,42 @@ private WebClient.RequestHeadersSpec<?> getRequestHeaderSpec(OAuth2UserRequest u
189202
// @formatter:on
190203
}
191204

205+
/**
206+
* Extract a value from nested attributes using a dot-notation path. For example,
207+
* "data.username" would extract the "username" field from the "data" object.
208+
* @param attributes the map of attributes
209+
* @param attributePath the attribute path in dot notation
210+
* @return the value at the specified path, or null if not found
211+
*/
212+
private Object extractNestedAttribute(Map<String, Object> attributes, String attributePath) {
213+
if (attributes == null || attributePath == null) {
214+
return null;
215+
}
216+
217+
if (!attributePath.contains(".")) {
218+
return attributes.get(attributePath);
219+
}
220+
221+
String[] pathParts = attributePath.split("\\.");
222+
Object currentValue = attributes;
223+
224+
for (String part : pathParts) {
225+
if (!(currentValue instanceof Map)) {
226+
return null;
227+
}
228+
229+
@SuppressWarnings("unchecked")
230+
Map<String, Object> currentMap = (Map<String, Object>) currentValue;
231+
currentValue = currentMap.get(part);
232+
233+
if (currentValue == null) {
234+
return null;
235+
}
236+
}
237+
238+
return currentValue;
239+
}
240+
192241
/**
193242
* Use this strategy to adapt user attributes into a format understood by Spring
194243
* Security; by default, the original attributes are preserved.

oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/userinfo/DefaultOAuth2UserServiceTests.java

+58-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2024 the original author or authors.
2+
* Copyright 2002-2025 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -200,6 +200,63 @@ public void loadUserWhenNestedUserInfoSuccessThenReturnUser() {
200200
assertThat(userAuthority.getUserNameAttributeName()).isEqualTo("user-name");
201201
}
202202

203+
@Test
204+
public void loadUserWhenUserNameAttributeIsNestedThenExtractCorrectly() {
205+
// @formatter:off
206+
String userInfoResponse = "{\n"
207+
+ " \"data\": {\n"
208+
+ " \"id\": \"2244994945\",\n"
209+
+ " \"username\": \"testuser\",\n"
210+
+ " \"name\": \"Test User\"\n"
211+
+ " },\n"
212+
+ " \"meta\": {\n"
213+
+ " \"version\": \"1.0\"\n"
214+
+ " }\n"
215+
+ "}\n";
216+
// @formatter:on
217+
this.server.enqueue(jsonResponse(userInfoResponse));
218+
String userInfoUri = this.server.url("/user").toString();
219+
ClientRegistration clientRegistration = this.clientRegistrationBuilder.userInfoUri(userInfoUri)
220+
.userInfoAuthenticationMethod(AuthenticationMethod.HEADER)
221+
.userNameAttributeName("data.username")
222+
.build();
223+
OAuth2User user = this.userService.loadUser(new OAuth2UserRequest(clientRegistration, this.accessToken));
224+
assertThat(user.getName()).isEqualTo("testuser");
225+
assertThat(user.getAttributes()).containsKey("data");
226+
Map<String, Object> data = (Map<String, Object>) user.getAttributes().get("data");
227+
assertThat(data).containsEntry("username", "testuser");
228+
assertThat(user.getAttributes()).containsKey("data.username");
229+
assertThat(user.getAttributes().get("data.username")).isEqualTo("testuser");
230+
}
231+
232+
@Test
233+
public void loadUserWhenUserNameAttributeIsMultiLevelNestedThenExtractCorrectly() {
234+
// @formatter:off
235+
String userInfoResponse = "{\n"
236+
+ " \"response\": {\n"
237+
+ " \"user\": {\n"
238+
+ " \"profile\": {\n"
239+
+ " \"id\": \"12345\",\n"
240+
+ " \"login\": \"deepnested\",\n"
241+
+ " \"email\": \"[email protected]\"\n"
242+
+ " }\n"
243+
+ " }\n"
244+
+ " },\n"
245+
+ " \"status\": \"success\"\n"
246+
+ "}\n";
247+
// @formatter:on
248+
this.server.enqueue(jsonResponse(userInfoResponse));
249+
String userInfoUri = this.server.url("/user").toString();
250+
ClientRegistration clientRegistration = this.clientRegistrationBuilder.userInfoUri(userInfoUri)
251+
.userInfoAuthenticationMethod(AuthenticationMethod.HEADER)
252+
.userNameAttributeName("response.user.profile.login")
253+
.build();
254+
OAuth2User user = this.userService.loadUser(new OAuth2UserRequest(clientRegistration, this.accessToken));
255+
assertThat(user.getName()).isEqualTo("deepnested");
256+
assertThat(user.getAttributes()).containsKey("response.user.profile.login");
257+
assertThat(user.getAttributes().get("response.user.profile.login")).isEqualTo("deepnested");
258+
}
259+
203260
@Test
204261
public void loadUserWhenUserInfoSuccessResponseInvalidThenThrowOAuth2AuthenticationException() {
205262
// @formatter:off

oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/userinfo/DefaultReactiveOAuth2UserServiceTests.java

+60-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2024 the original author or authors.
2+
* Copyright 2002-2025 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -207,6 +207,65 @@ public void loadUserWhenNestedUserInfoSuccessThenReturnUser() {
207207
assertThat(userAuthority.getUserNameAttributeName()).isEqualTo("user-name");
208208
}
209209

210+
@Test
211+
public void loadUserWhenUserNameAttributeIsNestedThenExtractCorrectly() {
212+
// @formatter:off
213+
String userInfoResponse = "{\n"
214+
+ " \"data\": {\n"
215+
+ " \"id\": \"2244994945\",\n"
216+
+ " \"username\": \"testuser\",\n"
217+
+ " \"name\": \"Test User\"\n"
218+
+ " },\n"
219+
+ " \"meta\": {\n"
220+
+ " \"version\": \"1.0\"\n"
221+
+ " }\n"
222+
+ "}\n";
223+
// @formatter:on
224+
enqueueApplicationJsonBody(userInfoResponse);
225+
String userInfoUri = this.server.url("/user").toString();
226+
ClientRegistration clientRegistration = this.clientRegistration.userInfoUri(userInfoUri)
227+
.userInfoAuthenticationMethod(AuthenticationMethod.HEADER)
228+
.userNameAttributeName("data.username")
229+
.build();
230+
OAuth2User user = this.userService.loadUser(new OAuth2UserRequest(clientRegistration, this.accessToken))
231+
.block();
232+
assertThat(user.getName()).isEqualTo("testuser");
233+
assertThat(user.getAttributes()).containsKey("data");
234+
Map<String, Object> data = (Map<String, Object>) user.getAttributes().get("data");
235+
assertThat(data).containsEntry("username", "testuser");
236+
assertThat(user.getAttributes()).containsKey("data.username");
237+
assertThat(user.getAttributes().get("data.username")).isEqualTo("testuser");
238+
}
239+
240+
@Test
241+
public void loadUserWhenUserNameAttributeIsMultiLevelNestedThenExtractCorrectly() {
242+
// @formatter:off
243+
String userInfoResponse = "{\n"
244+
+ " \"response\": {\n"
245+
+ " \"user\": {\n"
246+
+ " \"profile\": {\n"
247+
+ " \"id\": \"12345\",\n"
248+
+ " \"login\": \"deepnested\",\n"
249+
+ " \"email\": \"[email protected]\"\n"
250+
+ " }\n"
251+
+ " }\n"
252+
+ " },\n"
253+
+ " \"status\": \"success\"\n"
254+
+ "}\n";
255+
// @formatter:on
256+
enqueueApplicationJsonBody(userInfoResponse);
257+
String userInfoUri = this.server.url("/user").toString();
258+
ClientRegistration clientRegistration = this.clientRegistration.userInfoUri(userInfoUri)
259+
.userInfoAuthenticationMethod(AuthenticationMethod.HEADER)
260+
.userNameAttributeName("response.user.profile.login")
261+
.build();
262+
OAuth2User user = this.userService.loadUser(new OAuth2UserRequest(clientRegistration, this.accessToken))
263+
.block();
264+
assertThat(user.getName()).isEqualTo("deepnested");
265+
assertThat(user.getAttributes()).containsKey("response.user.profile.login");
266+
assertThat(user.getAttributes().get("response.user.profile.login")).isEqualTo("deepnested");
267+
}
268+
210269
// gh-5500
211270
@Test
212271
public void loadUserWhenAuthenticationMethodHeaderSuccessResponseThenHttpMethodGet() throws Exception {

0 commit comments

Comments
 (0)