Describe the bug
DelegatingOAuth2UserService stream mechanism that can cause oauth2 exceptions
To Reproduce
DelegatingOAuth2UserService:
this.userServices.stream().map((userService) -> userService.loadUser(userRequest))
.filter(Objects::nonNull)
.findFirst()
.orElse(null);
u see , the first stream element maybe not be the right one. when unsuited OAuth2UserService that will cause some exception like that
[missing_openid_attribute] Missing required "some param" attribute in UserInfoEndpoint for Client Registration : some provider
and it will request all IDP user endpoint:
try wrong 1 try wrong 2 try wrong 3 oh right here
Expected behavior
accurate OAuth2UserService
Comment From: sjohnr
@NotFound403, thanks for reaching out. I'm not quite clear on what your current vs expected behavior is. For example, are you receiving an error after the first IDP user info endpoint is tried, and the next one is not tried?
Can you perhaps provide a MockMvc test that demonstrates the issue? Or could you provide a suggestion for how DelegatingOAuth2UserService can be improved that would help clarify the issue?
Comment From: NotFound403
okey, this a the mocktest
/***
* just like loop
*/
@Test
public void streamAlwaysOrdered() {
// idp1 service
OAuth2UserService<OAuth2UserRequest, OAuth2User> idp1CustomService = userRequest -> {
String registrationId = userRequest.getClientRegistration().getRegistrationId();
// request to auth server
if (!registrationId.equals("idp1")) {
// not suitable request
// this is a simulation . required param missing http 405
throw new RuntimeException(" this is an exception in auth server ");
}
List<GrantedAuthority> authorities = AuthorityUtils.createAuthorityList("USER");
Map<String, Object> attributes = new HashMap<>();
attributes.put("username", "idp1");
attributes.put("age", 22);
attributes.put("avatar", "https://avatars.githubusercontent.com/u/22664375?s=80&v=4");
String nameAttributeKey = "username";
return new DefaultOAuth2User(authorities, attributes, nameAttributeKey);
};
// idp2 service
OAuth2UserService<OAuth2UserRequest, OAuth2User> idp2CustomService = userRequest -> {
String registrationId = userRequest.getClientRegistration().getRegistrationId();
// request to auth server
if (!registrationId.equals("idp2")) {
// not suitable request
// this is a simulation required param missing http 405
throw new RuntimeException(" this is an exception in auth server ");
}
List<GrantedAuthority> authorities = AuthorityUtils.createAuthorityList("USER");
Map<String, Object> attributes = new HashMap<>();
attributes.put("username", "idp2");
attributes.put("age", 22);
attributes.put("avatar", "https://avatars.githubusercontent.com/u/22664375?s=80&v=4");
String nameAttributeKey = "username";
return new DefaultOAuth2User(authorities, attributes, nameAttributeKey);
};
// default service
DefaultOAuth2UserService defaultOAuth2UserService = new DefaultOAuth2UserService();
List<OAuth2UserService<OAuth2UserRequest, OAuth2User>> oAuth2UserServices = Arrays.asList(idp1CustomService,
idp2CustomService,
defaultOAuth2UserService);
OAuth2UserService<OAuth2UserRequest, OAuth2User> oAuth2UserService = new DelegatingOAuth2UserService<>(oAuth2UserServices);
Instant now = Instant.now();
OAuth2AccessToken accessToken = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER,"xxxxx", now, now.plusSeconds(180));
// when i use idp1’s userRequest ignore some params
ClientRegistration idp1 = ClientRegistration.withRegistrationId("idp1")
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.clientId("idp_client")
.clientSecret("noop")
.redirectUri("{baseUrl}/login/oauth2/code/{registrationId}")
.authorizationUri("https://idp1.com/authorize/oauth2")
.tokenUri("https://idp1.com/token/oauth2")
.userInfoUri("https://idp1.com/user/oauth2")
.build();
OAuth2UserRequest idp1Request = new OAuth2UserRequest(idp1, accessToken);
Assertions.assertNotNull(oAuth2UserService.loadUser(idp1Request));
// when i use idp2’s userRequest ignore some params
ClientRegistration idp2 = ClientRegistration.withRegistrationId("idp2")
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.clientId("idp_client")
.clientSecret("noop")
.redirectUri("{baseUrl}/login/oauth2/code/{registrationId}")
.authorizationUri("https://idp2.com/authorize/oauth2")
.tokenUri("https://idp2.com/token/oauth2")
.userInfoUri("https://idp2.com/user/oauth2")
.build();
OAuth2UserRequest idp2Request = new OAuth2UserRequest(idp2, accessToken);
// always
Assertions.assertThrows(RuntimeException.class,()->oAuth2UserService.loadUser(idp2Request));
}
Comment From: NotFound403
this is my delegate,just like DelegatingPasswordEncoder.
public class DelegatingOAuth2UserService<R extends OAuth2UserRequest, U extends OAuth2User>
implements OAuth2UserService<R, U> {
private final OAuth2UserService<OAuth2UserRequest, OAuth2User> defaultOAuth2UserService = new DefaultOAuth2UserService();
private final List<OAuth2UserService<R, U>> userServices;
private final Map<String, OAuth2UserService<R, U>> userServiceMap;
/**
* Constructs a {@code DelegatingOAuth2UserService} using the provided parameters.
* @deprecated
* @param userServices a {@code List} of {@link OAuth2UserService}(s)
*/
@Deprecated
public DelegatingOAuth2UserService(List<OAuth2UserService<R, U>> userServices) {
Assert.notEmpty(userServices, "userServices cannot be empty");
this.userServices = Collections.unmodifiableList(new ArrayList<>(userServices));
this.userServiceMap = Collections.emptyMap();
}
/**
* Constructs a {@code DelegatingOAuth2UserService} using the provided parameters.
*
* @param userServiceMap a {@code Map} of {@link OAuth2UserService}(s) k -> registrationId ,v-> {@code OAuth2UserService}
*/
public DelegatingOAuth2UserService(Map<String, OAuth2UserService<R, U>> userServiceMap) {
Assert.notEmpty(userServiceMap, "userServiceMap cannot be empty");
this.userServiceMap = Collections.unmodifiableMap(userServiceMap);
this.userServices = Collections.emptyList();
}
@Override
public U loadUser(R userRequest) throws OAuth2AuthenticationException {
Assert.notNull(userRequest, "userRequest cannot be null");
if (CollectionUtils.isEmpty(userServiceMap)) {
// @formatter:off
return this.userServices.stream()
.map((userService) -> userService.loadUser(userRequest))
.filter(Objects::nonNull)
.findFirst()
.orElse(null);
// @formatter:on
} else {
String registrationId = userRequest.getClientRegistration().getRegistrationId();
OAuth2UserService<R, U> oAuth2UserService = userServiceMap.get(registrationId);
if (oAuth2UserService == null) {
oAuth2UserService = (OAuth2UserService<R, U>) defaultOAuth2UserService;
}
return oAuth2UserService.loadUser(userRequest);
}
}
}
Comment From: sjohnr
@NotFound403, thanks so much for the mock test, that is extremely helpful in illustrating your reported issue. Here are my thoughts on this:
The DelegatingOAuth2UserService is a very simple composite implementation that tries a list of delegates in order, and simply expects null as the return value. This is a pretty simple contract to understand: as long as your requests succeed in such a way that the delegate provides null, you will not have an issue.
When the requests being issued through the composite are not valid for some of the providers, a legitimate OAuth2 error could be returned. I'm not sure it would make sense for this implementation to try and suppress or handle those errors. In this case, I feel it would be up to your application to provide adequate error handling for your use case. For example, you could wrap your delegates with another delegate that catches and handles the exception triggered by the 405 response, if that is appropriate. You can log it or do whatever you like, and then return null. Something like:
public class ErrorHandlingOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
private final OAuth2UserService<OAuth2UserRequest, OAuth2User> delegate;
public ErrorHandlingOAuth2UserService(OAuth2UserService<OAuth2UserRequest, OAuth2User> delegate) {
this.delegate = delegate;
}
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
try {
return this.delegate.loadUser(userRequest);
} catch (Exception ex) {
// TODO: Handle exception...
return null;
}
}
}
What do you think of this approach?
Comment From: NotFound403
@sjohnr thanks for ur reply.
In this way it seems to be working properly, but we just handled the exception and didn't stop it. Why? The IDP1 said that the IDP2's request wasn't mine and i had to respond it, so i responded an exception.
I think we should skip the inappropriate OAuth2UserService, because that:
- The deleagate needs to try it many times, but only one is actually needed.
- Mismatched requests may carry sensitive parameters to inappropriate servers, such as bearer token or oauth2 client registration etc, that's not safe.
- In addition, the setting of returning 'null' is an implicit condition that is difficult to be found.
Expectly
idp2 oauth2 userinfo request -> idp2 try
Not
idp2 oauth2 userinfo request -> idp1 try (invalid predictably), idp2 try
The best way, DelegatingOAuth2UserService can checkout a suitable OAuth2UserService for the request, if not, provides a default one or throw an exception with there is no suitable OAuth2UserService implementation. We shouldnt to give attempt for each OAuth2UserService that delegated.
What's your opinion?
Comment From: sjohnr
Thanks for the dialogue and feedback, @NotFound403!
I think I see what you're saying, and I agree that in your situation, the DelegatingOAuth2UserService is probably not suitable. However, you provided your own implementation above that could be simplified to only use the registrationId to lookup a suitable OAuth2UserService from a Map. This seems to be the way your use case needs it to work. I'd recommend just using that implementation as a custom composite OAuth2UserService. The interface is pretty easy to implement, as you've already done it! I would actually advise using that same pattern of building composites and using delegation whenever you need to in Spring Security, there's nothing wrong with that (in my opinion).
Having said all of that, I'm not sure it's extremely important for that example implementation you provided to be contributed back into Spring Security, nor does it seem desirable to change the existing DelegatingOAuth2UserService to work that way as it unnecessarily complicates the existing code.
I think I've gathered enough information to close this issue, as the recommended solution is to build your own delegating implementation. I'm hoping that this conversation will be helpful to others looking to do something similar. As always, if I've missed anything or you have more to add, please add additional comments to keep the conversation going.