|
@@ -22,112 +22,206 @@ import com.nimbusds.oauth2.sdk.http.HTTPResponse;
|
|
|
import com.nimbusds.oauth2.sdk.token.BearerAccessToken;
|
|
|
import com.nimbusds.openid.connect.sdk.UserInfoErrorResponse;
|
|
|
import com.nimbusds.openid.connect.sdk.UserInfoRequest;
|
|
|
+import org.springframework.beans.BeanWrapper;
|
|
|
+import org.springframework.beans.PropertyAccessorFactory;
|
|
|
import org.springframework.http.MediaType;
|
|
|
-import org.springframework.http.client.ClientHttpResponse;
|
|
|
+import org.springframework.http.converter.HttpMessageConverter;
|
|
|
+import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
|
|
|
import org.springframework.security.authentication.AuthenticationServiceException;
|
|
|
-import org.springframework.security.core.AuthenticatedPrincipal;
|
|
|
+import org.springframework.security.core.GrantedAuthority;
|
|
|
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationException;
|
|
|
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
|
|
|
import org.springframework.security.oauth2.client.registration.ClientRegistration;
|
|
|
import org.springframework.security.oauth2.client.user.OAuth2UserService;
|
|
|
import org.springframework.security.oauth2.core.OAuth2Error;
|
|
|
+import org.springframework.security.oauth2.core.user.DefaultOAuth2User;
|
|
|
import org.springframework.security.oauth2.core.user.OAuth2User;
|
|
|
-import org.springframework.security.oauth2.oidc.user.UserInfo;
|
|
|
+import org.springframework.security.oauth2.core.user.OAuth2UserAuthority;
|
|
|
+import org.springframework.security.oauth2.oidc.core.UserInfo;
|
|
|
+import org.springframework.security.oauth2.oidc.core.user.DefaultOidcUser;
|
|
|
+import org.springframework.security.oauth2.oidc.core.user.OidcUser;
|
|
|
+import org.springframework.security.oauth2.oidc.core.user.OidcUserAuthority;
|
|
|
import org.springframework.util.Assert;
|
|
|
|
|
|
import java.io.IOException;
|
|
|
import java.net.URI;
|
|
|
-import java.util.HashMap;
|
|
|
-import java.util.Map;
|
|
|
-import java.util.function.Function;
|
|
|
+import java.util.*;
|
|
|
|
|
|
/**
|
|
|
* An implementation of an {@link OAuth2UserService} that uses the <b>Nimbus OAuth 2.0 SDK</b> internally.
|
|
|
*
|
|
|
* <p>
|
|
|
- * This implementation uses a <code>Map</code> of converter's <i>keyed</i> by <code>URI</code>.
|
|
|
- * The <code>URI</code> represents the <i>UserInfo Endpoint</i> address and the mapped <code>Function</code>
|
|
|
- * is capable of converting the <i>UserInfo Response</i> to either an
|
|
|
- * {@link OAuth2User} (for a standard <i>OAuth 2.0 Provider</i>) or
|
|
|
- * {@link UserInfo} (for an <i>OpenID Connect 1.0 Provider</i>).
|
|
|
+ * This implementation may be configured with a <code>Map</code> of custom {@link OAuth2User} types
|
|
|
+ * <i>keyed</i> by <code>URI</code>, which represents the <i>UserInfo Endpoint</i> address.
|
|
|
+ *
|
|
|
+ * <p>
|
|
|
+ * For {@link OAuth2User}'s registered at a standard <i>OAuth 2.0 Provider</i>, the attribute name
|
|
|
+ * for the "user's name" is required. This can be supplied via {@link #setUserNameAttributeNames(Map)},
|
|
|
+ * <i>keyed</i> by <code>URI</code>, which represents the <i>UserInfo Endpoint</i> address.
|
|
|
*
|
|
|
* @author Joe Grandja
|
|
|
* @since 5.0
|
|
|
* @see OAuth2AuthenticationToken
|
|
|
- * @see AuthenticatedPrincipal
|
|
|
* @see OAuth2User
|
|
|
+ * @see OidcUser
|
|
|
* @see UserInfo
|
|
|
* @see <a target="_blank" href="https://connect2id.com/products/nimbus-oauth-openid-connect-sdk">Nimbus OAuth 2.0 SDK</a>
|
|
|
*/
|
|
|
public class NimbusOAuth2UserService implements OAuth2UserService {
|
|
|
private static final String INVALID_USER_INFO_RESPONSE_ERROR_CODE = "invalid_user_info_response";
|
|
|
- private final Map<URI, Function<ClientHttpResponse, ? extends OAuth2User>> userInfoTypeConverters;
|
|
|
+ private final HttpMessageConverter jackson2HttpMessageConverter = new MappingJackson2HttpMessageConverter();
|
|
|
+ private Map<URI, String> userNameAttributeNames = Collections.unmodifiableMap(Collections.emptyMap());
|
|
|
+ private Map<URI, Class<? extends OAuth2User>> customUserTypes = Collections.unmodifiableMap(Collections.emptyMap());
|
|
|
|
|
|
- public NimbusOAuth2UserService(Map<URI, Function<ClientHttpResponse, ? extends OAuth2User>> userInfoTypeConverters) {
|
|
|
- Assert.notEmpty(userInfoTypeConverters, "userInfoTypeConverters cannot be empty");
|
|
|
- this.userInfoTypeConverters = new HashMap<>(userInfoTypeConverters);
|
|
|
+ public NimbusOAuth2UserService() {
|
|
|
}
|
|
|
|
|
|
@Override
|
|
|
- public OAuth2User loadUser(OAuth2AuthenticationToken token) throws OAuth2AuthenticationException {
|
|
|
+ public final OAuth2User loadUser(OAuth2AuthenticationToken token) throws OAuth2AuthenticationException {
|
|
|
+ URI userInfoUri = this.getUserInfoUri(token);
|
|
|
+
|
|
|
+ if (this.getCustomUserTypes().containsKey(userInfoUri)) {
|
|
|
+ return this.loadCustomUser(token);
|
|
|
+ }
|
|
|
+ if (token.getIdToken() != null) {
|
|
|
+ return this.loadOidcUser(token);
|
|
|
+ }
|
|
|
+
|
|
|
+ return this.loadOAuth2User(token);
|
|
|
+ }
|
|
|
+
|
|
|
+ protected OAuth2User loadOidcUser(OAuth2AuthenticationToken token) throws OAuth2AuthenticationException {
|
|
|
+ // TODO Retrieving the UserInfo should be optional. Need to add the capability for opting in/out
|
|
|
+ Map<String, Object> userAttributes = this.getUserInfo(token);
|
|
|
+ UserInfo userInfo = new UserInfo(userAttributes);
|
|
|
+
|
|
|
+ GrantedAuthority authority = new OidcUserAuthority(token.getIdToken(), userInfo);
|
|
|
+ Set<GrantedAuthority> authorities = new HashSet<>();
|
|
|
+ authorities.add(authority);
|
|
|
+
|
|
|
+ return new DefaultOidcUser(authorities, token.getIdToken(), userInfo);
|
|
|
+ }
|
|
|
+
|
|
|
+ protected OAuth2User loadOAuth2User(OAuth2AuthenticationToken token) throws OAuth2AuthenticationException {
|
|
|
+ URI userInfoUri = this.getUserInfoUri(token);
|
|
|
+ if (!this.getUserNameAttributeNames().containsKey(userInfoUri)) {
|
|
|
+ throw new IllegalArgumentException("The attribute name for the \"user's name\" is required for the OAuth2User " +
|
|
|
+ " retrieved from the UserInfo Endpoint -> " + userInfoUri.toString());
|
|
|
+ }
|
|
|
+ String userNameAttributeName = this.getUserNameAttributeNames().get(userInfoUri);
|
|
|
+
|
|
|
+ Map<String, Object> userAttributes = this.getUserInfo(token);
|
|
|
+
|
|
|
+ GrantedAuthority authority = new OAuth2UserAuthority(userAttributes);
|
|
|
+ Set<GrantedAuthority> authorities = new HashSet<>();
|
|
|
+ authorities.add(authority);
|
|
|
+
|
|
|
+ return new DefaultOAuth2User(authorities, userAttributes, userNameAttributeName);
|
|
|
+ }
|
|
|
+
|
|
|
+ protected OAuth2User loadCustomUser(OAuth2AuthenticationToken token) throws OAuth2AuthenticationException {
|
|
|
+ URI userInfoUri = this.getUserInfoUri(token);
|
|
|
+ Class<? extends OAuth2User> customUserType = this.getCustomUserTypes().get(userInfoUri);
|
|
|
+
|
|
|
OAuth2User user;
|
|
|
+ try {
|
|
|
+ user = customUserType.newInstance();
|
|
|
+ } catch (ReflectiveOperationException ex) {
|
|
|
+ throw new IllegalArgumentException("An error occurred while attempting to instantiate the custom OAuth2User \"" +
|
|
|
+ customUserType.getName() + "\" -> " + ex.getMessage(), ex);
|
|
|
+ }
|
|
|
+
|
|
|
+ Map<String, Object> userAttributes = this.getUserInfo(token);
|
|
|
+ if (token.getIdToken() != null) {
|
|
|
+ userAttributes.putAll(token.getIdToken().getClaims());
|
|
|
+ }
|
|
|
+
|
|
|
+ BeanWrapper wrapper = PropertyAccessorFactory.forBeanPropertyAccess(user);
|
|
|
+ wrapper.setAutoGrowNestedPaths(true);
|
|
|
+ wrapper.setPropertyValues(userAttributes);
|
|
|
+
|
|
|
+ return user;
|
|
|
+ }
|
|
|
+
|
|
|
+ protected Map<String, Object> getUserInfo(OAuth2AuthenticationToken token) throws OAuth2AuthenticationException {
|
|
|
+ URI userInfoUri = this.getUserInfoUri(token);
|
|
|
+
|
|
|
+ BearerAccessToken accessToken = new BearerAccessToken(token.getAccessToken().getTokenValue());
|
|
|
+
|
|
|
+ UserInfoRequest userInfoRequest = new UserInfoRequest(userInfoUri, accessToken);
|
|
|
+ HTTPRequest httpRequest = userInfoRequest.toHTTPRequest();
|
|
|
+ httpRequest.setAccept(MediaType.APPLICATION_JSON_VALUE);
|
|
|
+ HTTPResponse httpResponse;
|
|
|
|
|
|
try {
|
|
|
- ClientRegistration clientRegistration = token.getClientRegistration();
|
|
|
+ httpResponse = httpRequest.send();
|
|
|
+ } catch (IOException ex) {
|
|
|
+ throw new AuthenticationServiceException("An error occurred while sending the UserInfo Request: " +
|
|
|
+ ex.getMessage(), ex);
|
|
|
+ }
|
|
|
|
|
|
- URI userInfoUri;
|
|
|
+ if (httpResponse.getStatusCode() != HTTPResponse.SC_OK) {
|
|
|
+ UserInfoErrorResponse userInfoErrorResponse;
|
|
|
try {
|
|
|
- userInfoUri = new URI(clientRegistration.getProviderDetails().getUserInfoUri());
|
|
|
- } catch (Exception ex) {
|
|
|
- throw new IllegalArgumentException("An error occurred parsing the userInfo URI: " +
|
|
|
- clientRegistration.getProviderDetails().getUserInfoUri(), ex);
|
|
|
+ userInfoErrorResponse = UserInfoErrorResponse.parse(httpResponse);
|
|
|
+ } catch (ParseException ex) {
|
|
|
+ OAuth2Error oauth2Error = new OAuth2Error(INVALID_USER_INFO_RESPONSE_ERROR_CODE,
|
|
|
+ "An error occurred parsing the UserInfo Error response: " + ex.getMessage(), null);
|
|
|
+ throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString(), ex);
|
|
|
}
|
|
|
-
|
|
|
- Function<ClientHttpResponse, ? extends OAuth2User> userInfoConverter = this.userInfoTypeConverters.get(userInfoUri);
|
|
|
- if (userInfoConverter == null) {
|
|
|
- throw new IllegalArgumentException("There is no available User Info converter for " + userInfoUri.toString());
|
|
|
+ ErrorObject errorObject = userInfoErrorResponse.getErrorObject();
|
|
|
+
|
|
|
+ StringBuilder errorDescription = new StringBuilder();
|
|
|
+ errorDescription.append("An error occurred while attempting to access the UserInfo Endpoint -> ");
|
|
|
+ errorDescription.append("Error details: [");
|
|
|
+ errorDescription.append("UserInfo Uri: ").append(userInfoUri.toString());
|
|
|
+ errorDescription.append(", Http Status: ").append(errorObject.getHTTPStatusCode());
|
|
|
+ if (errorObject.getCode() != null) {
|
|
|
+ errorDescription.append(", Error Code: ").append(errorObject.getCode());
|
|
|
}
|
|
|
-
|
|
|
- BearerAccessToken accessToken = new BearerAccessToken(token.getAccessToken().getTokenValue());
|
|
|
-
|
|
|
- // Request the User Info
|
|
|
- UserInfoRequest userInfoRequest = new UserInfoRequest(userInfoUri, accessToken);
|
|
|
- HTTPRequest httpRequest = userInfoRequest.toHTTPRequest();
|
|
|
- httpRequest.setAccept(MediaType.APPLICATION_JSON_VALUE);
|
|
|
- HTTPResponse httpResponse = httpRequest.send();
|
|
|
-
|
|
|
- if (httpResponse.getStatusCode() != HTTPResponse.SC_OK) {
|
|
|
- UserInfoErrorResponse userInfoErrorResponse = UserInfoErrorResponse.parse(httpResponse);
|
|
|
- ErrorObject errorObject = userInfoErrorResponse.getErrorObject();
|
|
|
-
|
|
|
- StringBuilder errorDescription = new StringBuilder();
|
|
|
- errorDescription.append("An error occurred while attempting to access the UserInfo Endpoint -> ");
|
|
|
- errorDescription.append("Error details: [");
|
|
|
- errorDescription.append("UserInfo Uri: ").append(userInfoUri.toString());
|
|
|
- errorDescription.append(", Http Status: ").append(errorObject.getHTTPStatusCode());
|
|
|
- if (errorObject.getCode() != null) {
|
|
|
- errorDescription.append(", Error Code: ").append(errorObject.getCode());
|
|
|
- }
|
|
|
- if (errorObject.getDescription() != null) {
|
|
|
- errorDescription.append(", Error Description: ").append(errorObject.getDescription());
|
|
|
- }
|
|
|
- errorDescription.append("]");
|
|
|
-
|
|
|
- OAuth2Error oauth2Error = new OAuth2Error(INVALID_USER_INFO_RESPONSE_ERROR_CODE, errorDescription.toString(), null);
|
|
|
- throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
|
|
|
+ if (errorObject.getDescription() != null) {
|
|
|
+ errorDescription.append(", Error Description: ").append(errorObject.getDescription());
|
|
|
}
|
|
|
+ errorDescription.append("]");
|
|
|
|
|
|
- user = userInfoConverter.apply(new NimbusClientHttpResponse(httpResponse));
|
|
|
+ OAuth2Error oauth2Error = new OAuth2Error(INVALID_USER_INFO_RESPONSE_ERROR_CODE, errorDescription.toString(), null);
|
|
|
+ throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
|
|
|
+ }
|
|
|
|
|
|
- } catch (ParseException ex) {
|
|
|
- // This error occurs if the User Info Response is not well-formed or invalid
|
|
|
- throw new OAuth2AuthenticationException(new OAuth2Error(INVALID_USER_INFO_RESPONSE_ERROR_CODE), ex);
|
|
|
+ try {
|
|
|
+ return (Map<String, Object>) this.jackson2HttpMessageConverter.read(Map.class, new NimbusClientHttpResponse(httpResponse));
|
|
|
} catch (IOException ex) {
|
|
|
- // This error occurs when there is a network-related issue
|
|
|
- throw new AuthenticationServiceException("An error occurred while sending the User Info Request: " +
|
|
|
- ex.getMessage(), ex);
|
|
|
+ OAuth2Error oauth2Error = new OAuth2Error(INVALID_USER_INFO_RESPONSE_ERROR_CODE,
|
|
|
+ "An error occurred reading the UserInfo Success response: " + ex.getMessage(), null);
|
|
|
+ throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString(), ex);
|
|
|
}
|
|
|
+ }
|
|
|
|
|
|
- return user;
|
|
|
+ protected Map<URI, String> getUserNameAttributeNames() {
|
|
|
+ return this.userNameAttributeNames;
|
|
|
+ }
|
|
|
+
|
|
|
+ public final void setUserNameAttributeNames(Map<URI, String> userNameAttributeNames) {
|
|
|
+ Assert.notEmpty(userNameAttributeNames, "userNameAttributeNames cannot be empty");
|
|
|
+ this.userNameAttributeNames = Collections.unmodifiableMap(new HashMap<>(userNameAttributeNames));
|
|
|
+ }
|
|
|
+
|
|
|
+ protected Map<URI, Class<? extends OAuth2User>> getCustomUserTypes() {
|
|
|
+ return this.customUserTypes;
|
|
|
+ }
|
|
|
+
|
|
|
+ public final void setCustomUserTypes(Map<URI, Class<? extends OAuth2User>> customUserTypes) {
|
|
|
+ Assert.notEmpty(customUserTypes, "customUserTypes cannot be empty");
|
|
|
+ this.customUserTypes = Collections.unmodifiableMap(new HashMap<>(customUserTypes));
|
|
|
+ }
|
|
|
+
|
|
|
+ private URI getUserInfoUri(OAuth2AuthenticationToken token) {
|
|
|
+ ClientRegistration clientRegistration = token.getClientRegistration();
|
|
|
+ try {
|
|
|
+ return new URI(clientRegistration.getProviderDetails().getUserInfoUri());
|
|
|
+ } catch (Exception ex) {
|
|
|
+ throw new IllegalArgumentException("An error occurred parsing the UserInfo URI: " +
|
|
|
+ clientRegistration.getProviderDetails().getUserInfoUri(), ex);
|
|
|
+ }
|
|
|
}
|
|
|
}
|