Pārlūkot izejas kodu

Externalize coercion in ClaimAccessor

Fixes gh-6245
Joe Grandja 6 gadi atpakaļ
vecāks
revīzija
aa767ec8bf
20 mainītis faili ar 1430 papildinājumiem un 200 dzēšanām
  1. 61 2
      oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/OidcIdTokenDecoderFactory.java
  2. 61 2
      oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/ReactiveOidcIdTokenDecoderFactory.java
  3. 65 5
      oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/userinfo/OidcReactiveOAuth2UserService.java
  4. 60 2
      oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/userinfo/OidcUserService.java
  5. 40 0
      oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/authentication/OidcIdTokenDecoderFactoryTests.java
  6. 40 0
      oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/authentication/ReactiveOidcIdTokenDecoderFactoryTests.java
  7. 42 4
      oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/userinfo/OidcReactiveOAuth2UserServiceTests.java
  8. 57 10
      oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/userinfo/OidcUserServiceTests.java
  9. 48 39
      oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/ClaimAccessor.java
  10. 72 0
      oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/converter/ClaimConversionService.java
  11. 67 0
      oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/converter/ClaimTypeConverter.java
  12. 45 0
      oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/converter/ObjectToBooleanConverter.java
  13. 63 0
      oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/converter/ObjectToInstantConverter.java
  14. 74 0
      oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/converter/ObjectToListStringConverter.java
  15. 62 0
      oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/converter/ObjectToMapStringObjectConverter.java
  16. 39 0
      oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/converter/ObjectToStringConverter.java
  17. 52 0
      oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/converter/ObjectToURLConverter.java
  18. 227 0
      oauth2/oauth2-core/src/test/java/org/springframework/security/oauth2/core/converter/ClaimConversionServiceTests.java
  19. 170 0
      oauth2/oauth2-core/src/test/java/org/springframework/security/oauth2/core/converter/ClaimTypeConverterTests.java
  20. 85 136
      oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/MappedJwtClaimSetConverter.java

+ 61 - 2
oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/OidcIdTokenDecoderFactory.java

@@ -15,11 +15,17 @@
  */
 package org.springframework.security.oauth2.client.oidc.authentication;
 
+import org.springframework.core.convert.TypeDescriptor;
+import org.springframework.core.convert.converter.Converter;
 import org.springframework.security.oauth2.client.registration.ClientRegistration;
 import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
 import org.springframework.security.oauth2.core.OAuth2Error;
 import org.springframework.security.oauth2.core.OAuth2TokenValidator;
+import org.springframework.security.oauth2.core.converter.ClaimConversionService;
+import org.springframework.security.oauth2.core.converter.ClaimTypeConverter;
+import org.springframework.security.oauth2.core.oidc.IdTokenClaimNames;
 import org.springframework.security.oauth2.core.oidc.OidcIdToken;
+import org.springframework.security.oauth2.core.oidc.StandardClaimNames;
 import org.springframework.security.oauth2.jose.jws.JwsAlgorithm;
 import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
 import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
@@ -31,7 +37,10 @@ import org.springframework.util.Assert;
 import org.springframework.util.StringUtils;
 
 import javax.crypto.spec.SecretKeySpec;
+import java.net.URL;
 import java.nio.charset.StandardCharsets;
+import java.time.Instant;
+import java.util.Collection;
 import java.util.HashMap;
 import java.util.Map;
 import java.util.concurrent.ConcurrentHashMap;
@@ -61,17 +70,55 @@ public final class OidcIdTokenDecoderFactory implements JwtDecoderFactory<Client
 			put(MacAlgorithm.HS512, "HmacSHA512");
 		}
 	};
+	private static final Converter<Map<String, Object>, Map<String, Object>> DEFAULT_CLAIM_TYPE_CONVERTER =
+			new ClaimTypeConverter(createDefaultClaimTypeConverters());
 	private final Map<String, JwtDecoder> jwtDecoders = new ConcurrentHashMap<>();
 	private Function<ClientRegistration, OAuth2TokenValidator<Jwt>> jwtValidatorFactory = OidcIdTokenValidator::new;
 	private Function<ClientRegistration, JwsAlgorithm> jwsAlgorithmResolver = clientRegistration -> SignatureAlgorithm.RS256;
+	private Function<ClientRegistration, Converter<Map<String, Object>, Map<String, Object>>> claimTypeConverterFactory =
+			clientRegistration -> DEFAULT_CLAIM_TYPE_CONVERTER;
+
+	/**
+	 * Returns the default {@link Converter}'s used for type conversion of claim values for an {@link OidcIdToken}.
+	 *
+	 * @return a {@link Map} of {@link Converter}'s keyed by {@link IdTokenClaimNames claim name}
+	 */
+	public static Map<String, Converter<Object, ?>> createDefaultClaimTypeConverters() {
+		Converter<Object, ?> booleanConverter = getConverter(TypeDescriptor.valueOf(Boolean.class));
+		Converter<Object, ?> instantConverter = getConverter(TypeDescriptor.valueOf(Instant.class));
+		Converter<Object, ?> urlConverter = getConverter(TypeDescriptor.valueOf(URL.class));
+		Converter<Object, ?> collectionStringConverter = getConverter(
+				TypeDescriptor.collection(Collection.class, TypeDescriptor.valueOf(String.class)));
+
+		Map<String, Converter<Object, ?>> claimTypeConverters = new HashMap<>();
+		claimTypeConverters.put(IdTokenClaimNames.ISS, urlConverter);
+		claimTypeConverters.put(IdTokenClaimNames.AUD, collectionStringConverter);
+		claimTypeConverters.put(IdTokenClaimNames.EXP, instantConverter);
+		claimTypeConverters.put(IdTokenClaimNames.IAT, instantConverter);
+		claimTypeConverters.put(IdTokenClaimNames.AUTH_TIME, instantConverter);
+		claimTypeConverters.put(IdTokenClaimNames.AMR, collectionStringConverter);
+		claimTypeConverters.put(StandardClaimNames.EMAIL_VERIFIED, booleanConverter);
+		claimTypeConverters.put(StandardClaimNames.PHONE_NUMBER_VERIFIED, booleanConverter);
+		claimTypeConverters.put(StandardClaimNames.UPDATED_AT, instantConverter);
+		return claimTypeConverters;
+	}
+
+	private static Converter<Object, ?> getConverter(TypeDescriptor targetDescriptor) {
+		final TypeDescriptor sourceDescriptor = TypeDescriptor.valueOf(Object.class);
+		return source -> ClaimConversionService.getSharedInstance().convert(source, sourceDescriptor, targetDescriptor);
+	}
 
 	@Override
 	public JwtDecoder createDecoder(ClientRegistration clientRegistration) {
 		Assert.notNull(clientRegistration, "clientRegistration cannot be null");
 		return this.jwtDecoders.computeIfAbsent(clientRegistration.getRegistrationId(), key -> {
 			NimbusJwtDecoder jwtDecoder = buildDecoder(clientRegistration);
-			OAuth2TokenValidator<Jwt> jwtValidator = this.jwtValidatorFactory.apply(clientRegistration);
-			jwtDecoder.setJwtValidator(jwtValidator);
+			jwtDecoder.setJwtValidator(this.jwtValidatorFactory.apply(clientRegistration));
+			Converter<Map<String, Object>, Map<String, Object>> claimTypeConverter =
+					this.claimTypeConverterFactory.apply(clientRegistration);
+			if (claimTypeConverter != null) {
+				jwtDecoder.setClaimSetConverter(claimTypeConverter);
+			}
 			return jwtDecoder;
 		});
 	}
@@ -163,4 +210,16 @@ public final class OidcIdTokenDecoderFactory implements JwtDecoderFactory<Client
 		Assert.notNull(jwsAlgorithmResolver, "jwsAlgorithmResolver cannot be null");
 		this.jwsAlgorithmResolver = jwsAlgorithmResolver;
 	}
+
+	/**
+	 * Sets the factory that provides a {@link Converter} used for type conversion of claim values for an {@link OidcIdToken}.
+	 * The default is {@link ClaimTypeConverter} for all {@link ClientRegistration clients}.
+	 *
+	 * @param claimTypeConverterFactory the factory that provides a {@link Converter} used for type conversion
+	 *                                  of claim values for a specific {@link ClientRegistration client}
+	 */
+	public final void setClaimTypeConverterFactory(Function<ClientRegistration, Converter<Map<String, Object>, Map<String, Object>>> claimTypeConverterFactory) {
+		Assert.notNull(claimTypeConverterFactory, "claimTypeConverterFactory cannot be null");
+		this.claimTypeConverterFactory = claimTypeConverterFactory;
+	}
 }

+ 61 - 2
oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/ReactiveOidcIdTokenDecoderFactory.java

@@ -15,11 +15,17 @@
  */
 package org.springframework.security.oauth2.client.oidc.authentication;
 
+import org.springframework.core.convert.TypeDescriptor;
+import org.springframework.core.convert.converter.Converter;
 import org.springframework.security.oauth2.client.registration.ClientRegistration;
 import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
 import org.springframework.security.oauth2.core.OAuth2Error;
 import org.springframework.security.oauth2.core.OAuth2TokenValidator;
+import org.springframework.security.oauth2.core.converter.ClaimConversionService;
+import org.springframework.security.oauth2.core.converter.ClaimTypeConverter;
+import org.springframework.security.oauth2.core.oidc.IdTokenClaimNames;
 import org.springframework.security.oauth2.core.oidc.OidcIdToken;
+import org.springframework.security.oauth2.core.oidc.StandardClaimNames;
 import org.springframework.security.oauth2.jose.jws.JwsAlgorithm;
 import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
 import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
@@ -31,7 +37,10 @@ import org.springframework.util.Assert;
 import org.springframework.util.StringUtils;
 
 import javax.crypto.spec.SecretKeySpec;
+import java.net.URL;
 import java.nio.charset.StandardCharsets;
+import java.time.Instant;
+import java.util.Collection;
 import java.util.HashMap;
 import java.util.Map;
 import java.util.concurrent.ConcurrentHashMap;
@@ -61,17 +70,55 @@ public final class ReactiveOidcIdTokenDecoderFactory implements ReactiveJwtDecod
 			put(MacAlgorithm.HS512, "HmacSHA512");
 		}
 	};
+	private static final Converter<Map<String, Object>, Map<String, Object>> DEFAULT_CLAIM_TYPE_CONVERTER =
+			new ClaimTypeConverter(createDefaultClaimTypeConverters());
 	private final Map<String, ReactiveJwtDecoder> jwtDecoders = new ConcurrentHashMap<>();
 	private Function<ClientRegistration, OAuth2TokenValidator<Jwt>> jwtValidatorFactory = OidcIdTokenValidator::new;
 	private Function<ClientRegistration, JwsAlgorithm> jwsAlgorithmResolver = clientRegistration -> SignatureAlgorithm.RS256;
+	private Function<ClientRegistration, Converter<Map<String, Object>, Map<String, Object>>> claimTypeConverterFactory =
+			clientRegistration -> DEFAULT_CLAIM_TYPE_CONVERTER;
+
+	/**
+	 * Returns the default {@link Converter}'s used for type conversion of claim values for an {@link OidcIdToken}.
+	 *
+	 * @return a {@link Map} of {@link Converter}'s keyed by {@link IdTokenClaimNames claim name}
+	 */
+	public static Map<String, Converter<Object, ?>> createDefaultClaimTypeConverters() {
+		Converter<Object, ?> booleanConverter = getConverter(TypeDescriptor.valueOf(Boolean.class));
+		Converter<Object, ?> instantConverter = getConverter(TypeDescriptor.valueOf(Instant.class));
+		Converter<Object, ?> urlConverter = getConverter(TypeDescriptor.valueOf(URL.class));
+		Converter<Object, ?> collectionStringConverter = getConverter(
+				TypeDescriptor.collection(Collection.class, TypeDescriptor.valueOf(String.class)));
+
+		Map<String, Converter<Object, ?>> claimTypeConverters = new HashMap<>();
+		claimTypeConverters.put(IdTokenClaimNames.ISS, urlConverter);
+		claimTypeConverters.put(IdTokenClaimNames.AUD, collectionStringConverter);
+		claimTypeConverters.put(IdTokenClaimNames.EXP, instantConverter);
+		claimTypeConverters.put(IdTokenClaimNames.IAT, instantConverter);
+		claimTypeConverters.put(IdTokenClaimNames.AUTH_TIME, instantConverter);
+		claimTypeConverters.put(IdTokenClaimNames.AMR, collectionStringConverter);
+		claimTypeConverters.put(StandardClaimNames.EMAIL_VERIFIED, booleanConverter);
+		claimTypeConverters.put(StandardClaimNames.PHONE_NUMBER_VERIFIED, booleanConverter);
+		claimTypeConverters.put(StandardClaimNames.UPDATED_AT, instantConverter);
+		return claimTypeConverters;
+	}
+
+	private static Converter<Object, ?> getConverter(TypeDescriptor targetDescriptor) {
+		final TypeDescriptor sourceDescriptor = TypeDescriptor.valueOf(Object.class);
+		return source -> ClaimConversionService.getSharedInstance().convert(source, sourceDescriptor, targetDescriptor);
+	}
 
 	@Override
 	public ReactiveJwtDecoder createDecoder(ClientRegistration clientRegistration) {
 		Assert.notNull(clientRegistration, "clientRegistration cannot be null");
 		return this.jwtDecoders.computeIfAbsent(clientRegistration.getRegistrationId(), key -> {
 			NimbusReactiveJwtDecoder jwtDecoder = buildDecoder(clientRegistration);
-			OAuth2TokenValidator<Jwt> jwtValidator = this.jwtValidatorFactory.apply(clientRegistration);
-			jwtDecoder.setJwtValidator(jwtValidator);
+			jwtDecoder.setJwtValidator(this.jwtValidatorFactory.apply(clientRegistration));
+			Converter<Map<String, Object>, Map<String, Object>> claimTypeConverter =
+					this.claimTypeConverterFactory.apply(clientRegistration);
+			if (claimTypeConverter != null) {
+				jwtDecoder.setClaimSetConverter(claimTypeConverter);
+			}
 			return jwtDecoder;
 		});
 	}
@@ -163,4 +210,16 @@ public final class ReactiveOidcIdTokenDecoderFactory implements ReactiveJwtDecod
 		Assert.notNull(jwsAlgorithmResolver, "jwsAlgorithmResolver cannot be null");
 		this.jwsAlgorithmResolver = jwsAlgorithmResolver;
 	}
+
+	/**
+	 * Sets the factory that provides a {@link Converter} used for type conversion of claim values for an {@link OidcIdToken}.
+	 * The default is {@link ClaimTypeConverter} for all {@link ClientRegistration clients}.
+	 *
+	 * @param claimTypeConverterFactory the factory that provides a {@link Converter} used for type conversion
+	 *                                  of claim values for a specific {@link ClientRegistration client}
+	 */
+	public final void setClaimTypeConverterFactory(Function<ClientRegistration, Converter<Map<String, Object>, Map<String, Object>>> claimTypeConverterFactory) {
+		Assert.notNull(claimTypeConverterFactory, "claimTypeConverterFactory cannot be null");
+		this.claimTypeConverterFactory = claimTypeConverterFactory;
+	}
 }

+ 65 - 5
oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/userinfo/OidcReactiveOAuth2UserService.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2002-2018 the original author or authors.
+ * 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.
@@ -15,25 +15,34 @@
  */
 package org.springframework.security.oauth2.client.oidc.userinfo;
 
-import java.util.HashSet;
-import java.util.Set;
-
+import org.springframework.core.convert.TypeDescriptor;
+import org.springframework.core.convert.converter.Converter;
 import org.springframework.security.core.GrantedAuthority;
+import org.springframework.security.oauth2.client.registration.ClientRegistration;
 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.OAuth2AuthenticationException;
 import org.springframework.security.oauth2.core.OAuth2Error;
+import org.springframework.security.oauth2.core.converter.ClaimConversionService;
+import org.springframework.security.oauth2.core.converter.ClaimTypeConverter;
 import org.springframework.security.oauth2.core.oidc.OidcUserInfo;
+import org.springframework.security.oauth2.core.oidc.StandardClaimNames;
 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.core.user.OAuth2User;
 import org.springframework.util.Assert;
 import org.springframework.util.StringUtils;
-
 import reactor.core.publisher.Mono;
 
+import java.time.Instant;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Function;
+
 /**
  * An implementation of an {@link ReactiveOAuth2UserService} that supports OpenID Connect 1.0 Provider's.
  *
@@ -50,8 +59,36 @@ public class OidcReactiveOAuth2UserService implements
 
 	private static final String INVALID_USER_INFO_RESPONSE_ERROR_CODE = "invalid_user_info_response";
 
+	private static final Converter<Map<String, Object>, Map<String, Object>> DEFAULT_CLAIM_TYPE_CONVERTER =
+			new ClaimTypeConverter(createDefaultClaimTypeConverters());
+
 	private ReactiveOAuth2UserService<OAuth2UserRequest, OAuth2User> oauth2UserService = new DefaultReactiveOAuth2UserService();
 
+	private Function<ClientRegistration, Converter<Map<String, Object>, Map<String, Object>>> claimTypeConverterFactory =
+			clientRegistration -> DEFAULT_CLAIM_TYPE_CONVERTER;
+
+	/**
+	 * Returns the default {@link Converter}'s used for type conversion of claim values for an {@link OidcUserInfo}.
+
+	 * @since 5.2
+	 * @return a {@link Map} of {@link Converter}'s keyed by {@link StandardClaimNames claim name}
+	 */
+	public static Map<String, Converter<Object, ?>> createDefaultClaimTypeConverters() {
+		Converter<Object, ?> booleanConverter = getConverter(TypeDescriptor.valueOf(Boolean.class));
+		Converter<Object, ?> instantConverter = getConverter(TypeDescriptor.valueOf(Instant.class));
+
+		Map<String, Converter<Object, ?>> claimTypeConverters = new HashMap<>();
+		claimTypeConverters.put(StandardClaimNames.EMAIL_VERIFIED, booleanConverter);
+		claimTypeConverters.put(StandardClaimNames.PHONE_NUMBER_VERIFIED, booleanConverter);
+		claimTypeConverters.put(StandardClaimNames.UPDATED_AT, instantConverter);
+		return claimTypeConverters;
+	}
+
+	private static Converter<Object, ?> getConverter(TypeDescriptor targetDescriptor) {
+		final TypeDescriptor sourceDescriptor = TypeDescriptor.valueOf(Object.class);
+		return source -> ClaimConversionService.getSharedInstance().convert(source, sourceDescriptor, targetDescriptor);
+	}
+
 	@Override
 	public Mono<OidcUser> loadUser(OidcUserRequest userRequest) throws OAuth2AuthenticationException {
 		Assert.notNull(userRequest, "userRequest cannot be null");
@@ -76,8 +113,10 @@ public class OidcReactiveOAuth2UserService implements
 		if (!OidcUserRequestUtils.shouldRetrieveUserInfo(userRequest)) {
 			return Mono.empty();
 		}
+
 		return this.oauth2UserService.loadUser(userRequest)
 			.map(OAuth2User::getAttributes)
+			.map(claims -> convertClaims(claims, userRequest.getClientRegistration()))
 			.map(OidcUserInfo::new)
 			.doOnNext(userInfo -> {
 				String subject = userInfo.getSubject();
@@ -88,8 +127,29 @@ public class OidcReactiveOAuth2UserService implements
 			});
 	}
 
+	private Map<String, Object> convertClaims(Map<String, Object> claims, ClientRegistration clientRegistration) {
+		Converter<Map<String, Object>, Map<String, Object>> claimTypeConverter =
+				this.claimTypeConverterFactory.apply(clientRegistration);
+		return claimTypeConverter != null ?
+				claimTypeConverter.convert(claims) :
+				DEFAULT_CLAIM_TYPE_CONVERTER.convert(claims);
+	}
+
 	public void setOauth2UserService(ReactiveOAuth2UserService<OAuth2UserRequest, OAuth2User> oauth2UserService) {
 		Assert.notNull(oauth2UserService, "oauth2UserService cannot be null");
 		this.oauth2UserService = oauth2UserService;
 	}
+
+	/**
+	 * Sets the factory that provides a {@link Converter} used for type conversion of claim values for an {@link OidcUserInfo}.
+	 * The default is {@link ClaimTypeConverter} for all {@link ClientRegistration clients}.
+	 *
+	 * @since 5.2
+	 * @param claimTypeConverterFactory the factory that provides a {@link Converter} used for type conversion
+	 *                                  of claim values for a specific {@link ClientRegistration client}
+	 */
+	public final void setClaimTypeConverterFactory(Function<ClientRegistration, Converter<Map<String, Object>, Map<String, Object>>> claimTypeConverterFactory) {
+		Assert.notNull(claimTypeConverterFactory, "claimTypeConverterFactory cannot be null");
+		this.claimTypeConverterFactory = claimTypeConverterFactory;
+	}
 }

+ 60 - 2
oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/userinfo/OidcUserService.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2002-2018 the original author or authors.
+ * 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.
@@ -15,15 +15,21 @@
  */
 package org.springframework.security.oauth2.client.oidc.userinfo;
 
+import org.springframework.core.convert.TypeDescriptor;
+import org.springframework.core.convert.converter.Converter;
 import org.springframework.security.core.GrantedAuthority;
+import org.springframework.security.oauth2.client.registration.ClientRegistration;
 import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
 import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
 import org.springframework.security.oauth2.client.userinfo.OAuth2UserService;
 import org.springframework.security.oauth2.core.AuthorizationGrantType;
 import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
 import org.springframework.security.oauth2.core.OAuth2Error;
+import org.springframework.security.oauth2.core.converter.ClaimConversionService;
+import org.springframework.security.oauth2.core.converter.ClaimTypeConverter;
 import org.springframework.security.oauth2.core.oidc.OidcScopes;
 import org.springframework.security.oauth2.core.oidc.OidcUserInfo;
+import org.springframework.security.oauth2.core.oidc.StandardClaimNames;
 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;
@@ -32,10 +38,14 @@ import org.springframework.util.Assert;
 import org.springframework.util.CollectionUtils;
 import org.springframework.util.StringUtils;
 
+import java.time.Instant;
 import java.util.Arrays;
 import java.util.Collections;
+import java.util.HashMap;
 import java.util.HashSet;
+import java.util.Map;
 import java.util.Set;
+import java.util.function.Function;
 
 /**
  * An implementation of an {@link OAuth2UserService} that supports OpenID Connect 1.0 Provider's.
@@ -50,9 +60,35 @@ import java.util.Set;
  */
 public class OidcUserService implements OAuth2UserService<OidcUserRequest, OidcUser> {
 	private static final String INVALID_USER_INFO_RESPONSE_ERROR_CODE = "invalid_user_info_response";
+	private static final Converter<Map<String, Object>, Map<String, Object>> DEFAULT_CLAIM_TYPE_CONVERTER =
+			new ClaimTypeConverter(createDefaultClaimTypeConverters());
 	private final Set<String> userInfoScopes = new HashSet<>(
 		Arrays.asList(OidcScopes.PROFILE, OidcScopes.EMAIL, OidcScopes.ADDRESS, OidcScopes.PHONE));
 	private OAuth2UserService<OAuth2UserRequest, OAuth2User> oauth2UserService = new DefaultOAuth2UserService();
+	private Function<ClientRegistration, Converter<Map<String, Object>, Map<String, Object>>> claimTypeConverterFactory =
+			clientRegistration -> DEFAULT_CLAIM_TYPE_CONVERTER;
+
+	/**
+	 * Returns the default {@link Converter}'s used for type conversion of claim values for an {@link OidcUserInfo}.
+
+	 * @since 5.2
+	 * @return a {@link Map} of {@link Converter}'s keyed by {@link StandardClaimNames claim name}
+	 */
+	public static Map<String, Converter<Object, ?>> createDefaultClaimTypeConverters() {
+		Converter<Object, ?> booleanConverter = getConverter(TypeDescriptor.valueOf(Boolean.class));
+		Converter<Object, ?> instantConverter = getConverter(TypeDescriptor.valueOf(Instant.class));
+
+		Map<String, Converter<Object, ?>> claimTypeConverters = new HashMap<>();
+		claimTypeConverters.put(StandardClaimNames.EMAIL_VERIFIED, booleanConverter);
+		claimTypeConverters.put(StandardClaimNames.PHONE_NUMBER_VERIFIED, booleanConverter);
+		claimTypeConverters.put(StandardClaimNames.UPDATED_AT, instantConverter);
+		return claimTypeConverters;
+	}
+
+	private static Converter<Object, ?> getConverter(TypeDescriptor targetDescriptor) {
+		final TypeDescriptor sourceDescriptor = TypeDescriptor.valueOf(Object.class);
+		return source -> ClaimConversionService.getSharedInstance().convert(source, sourceDescriptor, targetDescriptor);
+	}
 
 	@Override
 	public OidcUser loadUser(OidcUserRequest userRequest) throws OAuth2AuthenticationException {
@@ -60,7 +96,16 @@ public class OidcUserService implements OAuth2UserService<OidcUserRequest, OidcU
 		OidcUserInfo userInfo = null;
 		if (this.shouldRetrieveUserInfo(userRequest)) {
 			OAuth2User oauth2User = this.oauth2UserService.loadUser(userRequest);
-			userInfo = new OidcUserInfo(oauth2User.getAttributes());
+
+			Map<String, Object> claims;
+			Converter<Map<String, Object>, Map<String, Object>> claimTypeConverter =
+					this.claimTypeConverterFactory.apply(userRequest.getClientRegistration());
+			if (claimTypeConverter != null) {
+				claims = claimTypeConverter.convert(oauth2User.getAttributes());
+			} else {
+				claims = DEFAULT_CLAIM_TYPE_CONVERTER.convert(oauth2User.getAttributes());
+			}
+			userInfo = new OidcUserInfo(claims);
 
 			// https://openid.net/specs/openid-connect-core-1_0.html#UserInfoResponse
 
@@ -132,4 +177,17 @@ public class OidcUserService implements OAuth2UserService<OidcUserRequest, OidcU
 		Assert.notNull(oauth2UserService, "oauth2UserService cannot be null");
 		this.oauth2UserService = oauth2UserService;
 	}
+
+	/**
+	 * Sets the factory that provides a {@link Converter} used for type conversion of claim values for an {@link OidcUserInfo}.
+	 * The default is {@link ClaimTypeConverter} for all {@link ClientRegistration clients}.
+	 *
+	 * @since 5.2
+	 * @param claimTypeConverterFactory the factory that provides a {@link Converter} used for type conversion
+	 *                                  of claim values for a specific {@link ClientRegistration client}
+	 */
+	public final void setClaimTypeConverterFactory(Function<ClientRegistration, Converter<Map<String, Object>, Map<String, Object>>> claimTypeConverterFactory) {
+		Assert.notNull(claimTypeConverterFactory, "claimTypeConverterFactory cannot be null");
+		this.claimTypeConverterFactory = claimTypeConverterFactory;
+	}
 }

+ 40 - 0
oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/authentication/OidcIdTokenDecoderFactoryTests.java

@@ -17,15 +17,20 @@ package org.springframework.security.oauth2.client.oidc.authentication;
 
 import org.junit.Before;
 import org.junit.Test;
+import org.springframework.core.convert.converter.Converter;
 import org.springframework.security.oauth2.client.registration.ClientRegistration;
 import org.springframework.security.oauth2.client.registration.TestClientRegistrations;
 import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
 import org.springframework.security.oauth2.core.OAuth2TokenValidator;
+import org.springframework.security.oauth2.core.converter.ClaimTypeConverter;
+import org.springframework.security.oauth2.core.oidc.IdTokenClaimNames;
+import org.springframework.security.oauth2.core.oidc.StandardClaimNames;
 import org.springframework.security.oauth2.jose.jws.JwsAlgorithm;
 import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
 import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
 import org.springframework.security.oauth2.jwt.Jwt;
 
+import java.util.Map;
 import java.util.function.Function;
 
 import static org.assertj.core.api.Assertions.assertThat;
@@ -49,6 +54,20 @@ public class OidcIdTokenDecoderFactoryTests {
 		this.idTokenDecoderFactory = new OidcIdTokenDecoderFactory();
 	}
 
+	@Test
+	public void createDefaultClaimTypeConvertersWhenCalledThenDefaultsAreCorrect() {
+		Map<String, Converter<Object, ?>> claimTypeConverters = OidcIdTokenDecoderFactory.createDefaultClaimTypeConverters();
+		assertThat(claimTypeConverters).containsKey(IdTokenClaimNames.ISS);
+		assertThat(claimTypeConverters).containsKey(IdTokenClaimNames.AUD);
+		assertThat(claimTypeConverters).containsKey(IdTokenClaimNames.EXP);
+		assertThat(claimTypeConverters).containsKey(IdTokenClaimNames.IAT);
+		assertThat(claimTypeConverters).containsKey(IdTokenClaimNames.AUTH_TIME);
+		assertThat(claimTypeConverters).containsKey(IdTokenClaimNames.AMR);
+		assertThat(claimTypeConverters).containsKey(StandardClaimNames.EMAIL_VERIFIED);
+		assertThat(claimTypeConverters).containsKey(StandardClaimNames.PHONE_NUMBER_VERIFIED);
+		assertThat(claimTypeConverters).containsKey(StandardClaimNames.UPDATED_AT);
+	}
+
 	@Test
 	public void setJwtValidatorFactoryWhenNullThenThrowIllegalArgumentException() {
 		assertThatThrownBy(() -> this.idTokenDecoderFactory.setJwtValidatorFactory(null))
@@ -61,6 +80,12 @@ public class OidcIdTokenDecoderFactoryTests {
 				.isInstanceOf(IllegalArgumentException.class);
 	}
 
+	@Test
+	public void setClaimTypeConverterFactoryWhenNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> this.idTokenDecoderFactory.setClaimTypeConverterFactory(null))
+				.isInstanceOf(IllegalArgumentException.class);
+	}
+
 	@Test
 	public void createDecoderWhenClientRegistrationNullThenThrowIllegalArgumentException() {
 		assertThatThrownBy(() -> this.idTokenDecoderFactory.createDecoder(null))
@@ -141,4 +166,19 @@ public class OidcIdTokenDecoderFactoryTests {
 
 		verify(customJwsAlgorithmResolver).apply(same(clientRegistration));
 	}
+
+	@Test
+	public void createDecoderWhenCustomClaimTypeConverterFactorySetThenApplied() {
+		Function<ClientRegistration, Converter<Map<String, Object>, Map<String, Object>>> customClaimTypeConverterFactory = mock(Function.class);
+		this.idTokenDecoderFactory.setClaimTypeConverterFactory(customClaimTypeConverterFactory);
+
+		ClientRegistration clientRegistration = this.registration.build();
+
+		when(customClaimTypeConverterFactory.apply(same(clientRegistration)))
+				.thenReturn(new ClaimTypeConverter(OidcIdTokenDecoderFactory.createDefaultClaimTypeConverters()));
+
+		this.idTokenDecoderFactory.createDecoder(clientRegistration);
+
+		verify(customClaimTypeConverterFactory).apply(same(clientRegistration));
+	}
 }

+ 40 - 0
oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/authentication/ReactiveOidcIdTokenDecoderFactoryTests.java

@@ -17,15 +17,20 @@ package org.springframework.security.oauth2.client.oidc.authentication;
 
 import org.junit.Before;
 import org.junit.Test;
+import org.springframework.core.convert.converter.Converter;
 import org.springframework.security.oauth2.client.registration.ClientRegistration;
 import org.springframework.security.oauth2.client.registration.TestClientRegistrations;
 import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
 import org.springframework.security.oauth2.core.OAuth2TokenValidator;
+import org.springframework.security.oauth2.core.converter.ClaimTypeConverter;
+import org.springframework.security.oauth2.core.oidc.IdTokenClaimNames;
+import org.springframework.security.oauth2.core.oidc.StandardClaimNames;
 import org.springframework.security.oauth2.jose.jws.JwsAlgorithm;
 import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
 import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
 import org.springframework.security.oauth2.jwt.Jwt;
 
+import java.util.Map;
 import java.util.function.Function;
 
 import static org.assertj.core.api.Assertions.assertThat;
@@ -49,6 +54,20 @@ public class ReactiveOidcIdTokenDecoderFactoryTests {
 		this.idTokenDecoderFactory = new ReactiveOidcIdTokenDecoderFactory();
 	}
 
+	@Test
+	public void createDefaultClaimTypeConvertersWhenCalledThenDefaultsAreCorrect() {
+		Map<String, Converter<Object, ?>> claimTypeConverters = ReactiveOidcIdTokenDecoderFactory.createDefaultClaimTypeConverters();
+		assertThat(claimTypeConverters).containsKey(IdTokenClaimNames.ISS);
+		assertThat(claimTypeConverters).containsKey(IdTokenClaimNames.AUD);
+		assertThat(claimTypeConverters).containsKey(IdTokenClaimNames.EXP);
+		assertThat(claimTypeConverters).containsKey(IdTokenClaimNames.IAT);
+		assertThat(claimTypeConverters).containsKey(IdTokenClaimNames.AUTH_TIME);
+		assertThat(claimTypeConverters).containsKey(IdTokenClaimNames.AMR);
+		assertThat(claimTypeConverters).containsKey(StandardClaimNames.EMAIL_VERIFIED);
+		assertThat(claimTypeConverters).containsKey(StandardClaimNames.PHONE_NUMBER_VERIFIED);
+		assertThat(claimTypeConverters).containsKey(StandardClaimNames.UPDATED_AT);
+	}
+
 	@Test
 	public void setJwtValidatorFactoryWhenNullThenThrowIllegalArgumentException() {
 		assertThatThrownBy(() -> this.idTokenDecoderFactory.setJwtValidatorFactory(null))
@@ -61,6 +80,12 @@ public class ReactiveOidcIdTokenDecoderFactoryTests {
 				.isInstanceOf(IllegalArgumentException.class);
 	}
 
+	@Test
+	public void setClaimTypeConverterFactoryWhenNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> this.idTokenDecoderFactory.setClaimTypeConverterFactory(null))
+				.isInstanceOf(IllegalArgumentException.class);
+	}
+
 	@Test
 	public void createDecoderWhenClientRegistrationNullThenThrowIllegalArgumentException() {
 		assertThatThrownBy(() -> this.idTokenDecoderFactory.createDecoder(null))
@@ -141,4 +166,19 @@ public class ReactiveOidcIdTokenDecoderFactoryTests {
 
 		verify(customJwsAlgorithmResolver).apply(same(clientRegistration));
 	}
+
+	@Test
+	public void createDecoderWhenCustomClaimTypeConverterFactorySetThenApplied() {
+		Function<ClientRegistration, Converter<Map<String, Object>, Map<String, Object>>> customClaimTypeConverterFactory = mock(Function.class);
+		this.idTokenDecoderFactory.setClaimTypeConverterFactory(customClaimTypeConverterFactory);
+
+		ClientRegistration clientRegistration = this.registration.build();
+
+		when(customClaimTypeConverterFactory.apply(same(clientRegistration)))
+				.thenReturn(new ClaimTypeConverter(OidcIdTokenDecoderFactory.createDefaultClaimTypeConverters()));
+
+		this.idTokenDecoderFactory.createDecoder(clientRegistration);
+
+		verify(customClaimTypeConverterFactory).apply(same(clientRegistration));
+	}
 }

+ 42 - 4
oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/userinfo/OidcReactiveOAuth2UserServiceTests.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2002-2018 the original author or authors.
+ * 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.
@@ -21,6 +21,7 @@ import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.mockito.Mock;
 import org.mockito.junit.MockitoJUnitRunner;
+import org.springframework.core.convert.converter.Converter;
 import org.springframework.security.core.authority.AuthorityUtils;
 import org.springframework.security.oauth2.client.registration.ClientRegistration;
 import org.springframework.security.oauth2.client.registration.TestClientRegistrations;
@@ -28,6 +29,7 @@ import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
 import org.springframework.security.oauth2.client.userinfo.ReactiveOAuth2UserService;
 import org.springframework.security.oauth2.core.OAuth2AccessToken;
 import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
+import org.springframework.security.oauth2.core.converter.ClaimTypeConverter;
 import org.springframework.security.oauth2.core.oidc.IdTokenClaimNames;
 import org.springframework.security.oauth2.core.oidc.OidcIdToken;
 import org.springframework.security.oauth2.core.oidc.StandardClaimNames;
@@ -41,11 +43,11 @@ import java.time.Instant;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.Map;
+import java.util.function.Function;
 
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.*;
 import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.Mockito.when;
+import static org.mockito.Mockito.*;
 
 /**
  * @author Rob Winch
@@ -76,6 +78,20 @@ public class OidcReactiveOAuth2UserServiceTests {
 		this.userService.setOauth2UserService(this.oauth2UserService);
 	}
 
+	@Test
+	public void createDefaultClaimTypeConvertersWhenCalledThenDefaultsAreCorrect() {
+		Map<String, Converter<Object, ?>> claimTypeConverters = OidcReactiveOAuth2UserService.createDefaultClaimTypeConverters();
+		assertThat(claimTypeConverters).containsKey(StandardClaimNames.EMAIL_VERIFIED);
+		assertThat(claimTypeConverters).containsKey(StandardClaimNames.PHONE_NUMBER_VERIFIED);
+		assertThat(claimTypeConverters).containsKey(StandardClaimNames.UPDATED_AT);
+	}
+
+	@Test
+	public void setClaimTypeConverterFactoryWhenNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> this.userService.setClaimTypeConverterFactory(null))
+				.isInstanceOf(IllegalArgumentException.class);
+	}
+
 	@Test
 	public void loadUserWhenUserInfoUriNullThenUserInfoNotRetrieved() {
 		this.registration.userInfoUri(null);
@@ -141,6 +157,28 @@ public class OidcReactiveOAuth2UserServiceTests {
 		assertThat(this.userService.loadUser(userRequest()).block().getName()).isEqualTo("rob");
 	}
 
+	@Test
+	public void loadUserWhenCustomClaimTypeConverterFactorySetThenApplied() {
+		Map<String, Object> attributes = new HashMap<>();
+		attributes.put(StandardClaimNames.SUB, "sub123");
+		attributes.put("user", "rob");
+		OAuth2User oauth2User = new DefaultOAuth2User(AuthorityUtils.createAuthorityList("ROLE_USER"),
+				attributes, "user");
+		when(this.oauth2UserService.loadUser(any())).thenReturn(Mono.just(oauth2User));
+
+		OidcUserRequest userRequest = userRequest();
+
+		Function<ClientRegistration, Converter<Map<String, Object>, Map<String, Object>>> customClaimTypeConverterFactory = mock(Function.class);
+		this.userService.setClaimTypeConverterFactory(customClaimTypeConverterFactory);
+
+		when(customClaimTypeConverterFactory.apply(same(userRequest.getClientRegistration())))
+				.thenReturn(new ClaimTypeConverter(OidcReactiveOAuth2UserService.createDefaultClaimTypeConverters()));
+
+		this.userService.loadUser(userRequest).block().getUserInfo();
+
+		verify(customClaimTypeConverterFactory).apply(same(userRequest.getClientRegistration()));
+	}
+
 	private OidcUserRequest userRequest() {
 		return new OidcUserRequest(this.registration.build(), this.accessToken, this.idToken);
 	}

+ 57 - 10
oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/userinfo/OidcUserServiceTests.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2002-2018 the original author or authors.
+ * 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.
@@ -15,14 +15,6 @@
  */
 package org.springframework.security.oauth2.client.oidc.userinfo;
 
-import java.time.Instant;
-import java.util.Arrays;
-import java.util.HashMap;
-import java.util.LinkedHashSet;
-import java.util.Map;
-import java.util.Set;
-import java.util.concurrent.TimeUnit;
-
 import okhttp3.mockwebserver.MockResponse;
 import okhttp3.mockwebserver.MockWebServer;
 import okhttp3.mockwebserver.RecordedRequest;
@@ -31,7 +23,7 @@ import org.junit.Before;
 import org.junit.Rule;
 import org.junit.Test;
 import org.junit.rules.ExpectedException;
-
+import org.springframework.core.convert.converter.Converter;
 import org.springframework.http.HttpHeaders;
 import org.springframework.http.HttpMethod;
 import org.springframework.http.MediaType;
@@ -40,6 +32,7 @@ import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserServ
 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.converter.ClaimTypeConverter;
 import org.springframework.security.oauth2.core.oidc.IdTokenClaimNames;
 import org.springframework.security.oauth2.core.oidc.OidcIdToken;
 import org.springframework.security.oauth2.core.oidc.OidcScopes;
@@ -47,9 +40,20 @@ import org.springframework.security.oauth2.core.oidc.StandardClaimNames;
 import org.springframework.security.oauth2.core.oidc.user.OidcUser;
 import org.springframework.security.oauth2.core.oidc.user.OidcUserAuthority;
 
+import java.time.Instant;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.LinkedHashSet;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Function;
+
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
 import static org.hamcrest.CoreMatchers.containsString;
+import static org.mockito.ArgumentMatchers.same;
+import static org.mockito.Mockito.*;
 import static org.springframework.security.oauth2.client.registration.TestClientRegistrations.clientRegistration;
 import static org.springframework.security.oauth2.core.TestOAuth2AccessTokens.scopes;
 
@@ -92,12 +96,26 @@ public class OidcUserServiceTests {
 		this.server.shutdown();
 	}
 
+	@Test
+	public void createDefaultClaimTypeConvertersWhenCalledThenDefaultsAreCorrect() {
+		Map<String, Converter<Object, ?>> claimTypeConverters = OidcUserService.createDefaultClaimTypeConverters();
+		assertThat(claimTypeConverters).containsKey(StandardClaimNames.EMAIL_VERIFIED);
+		assertThat(claimTypeConverters).containsKey(StandardClaimNames.PHONE_NUMBER_VERIFIED);
+		assertThat(claimTypeConverters).containsKey(StandardClaimNames.UPDATED_AT);
+	}
+
 	@Test
 	public void setOauth2UserServiceWhenNullThenThrowIllegalArgumentException() {
 		assertThatThrownBy(() -> this.userService.setOauth2UserService(null))
 				.isInstanceOf(IllegalArgumentException.class);
 	}
 
+	@Test
+	public void setClaimTypeConverterFactoryWhenNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> this.userService.setClaimTypeConverterFactory(null))
+				.isInstanceOf(IllegalArgumentException.class);
+	}
+
 	@Test
 	public void loadUserWhenUserRequestIsNullThenThrowIllegalArgumentException() {
 		this.exception.expect(IllegalArgumentException.class);
@@ -355,6 +373,35 @@ public class OidcUserServiceTests {
 		assertThat(request.getBody().readUtf8()).isEqualTo("access_token=" + this.accessToken.getTokenValue());
 	}
 
+	@Test
+	public void loadUserWhenCustomClaimTypeConverterFactorySetThenApplied() {
+		String userInfoResponse = "{\n" +
+				"	\"sub\": \"subject1\",\n" +
+				"   \"name\": \"first last\",\n" +
+				"   \"given_name\": \"first\",\n" +
+				"   \"family_name\": \"last\",\n" +
+				"   \"preferred_username\": \"user1\",\n" +
+				"   \"email\": \"user1@example.com\"\n" +
+				"}\n";
+		this.server.enqueue(jsonResponse(userInfoResponse));
+
+		String userInfoUri = this.server.url("/user").toString();
+
+		ClientRegistration clientRegistration = this.clientRegistrationBuilder
+				.userInfoUri(userInfoUri)
+				.build();
+
+		Function<ClientRegistration, Converter<Map<String, Object>, Map<String, Object>>> customClaimTypeConverterFactory = mock(Function.class);
+		this.userService.setClaimTypeConverterFactory(customClaimTypeConverterFactory);
+
+		when(customClaimTypeConverterFactory.apply(same(clientRegistration)))
+				.thenReturn(new ClaimTypeConverter(OidcUserService.createDefaultClaimTypeConverters()));
+
+		this.userService.loadUser(new OidcUserRequest(clientRegistration, this.accessToken, this.idToken));
+
+		verify(customClaimTypeConverterFactory).apply(same(clientRegistration));
+	}
+
 	private MockResponse jsonResponse(String json) {
 		return new MockResponse()
 				.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)

+ 48 - 39
oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/ClaimAccessor.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2002-2018 the original author or authors.
+ * 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.
@@ -15,14 +15,12 @@
  */
 package org.springframework.security.oauth2.core;
 
+import org.springframework.core.convert.TypeDescriptor;
+import org.springframework.security.oauth2.core.converter.ClaimConversionService;
 import org.springframework.util.Assert;
 
-import java.net.MalformedURLException;
 import java.net.URL;
 import java.time.Instant;
-import java.util.ArrayList;
-import java.util.Date;
-import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 
@@ -49,7 +47,7 @@ public interface ClaimAccessor {
 	 */
 	default Boolean containsClaim(String claim) {
 		Assert.notNull(claim, "claim cannot be null");
-		return this.getClaims().containsKey(claim);
+		return getClaims().containsKey(claim);
 	}
 
 	/**
@@ -59,11 +57,8 @@ public interface ClaimAccessor {
 	 * @return the claim value or {@code null} if it does not exist or is equal to {@code null}
 	 */
 	default String getClaimAsString(String claim) {
-		if (!this.containsClaim(claim)) {
-			return null;
-		}
-		Object claimValue = this.getClaims().get(claim);
-		return (claimValue != null ? claimValue.toString() : null);
+		return !containsClaim(claim) ? null :
+				ClaimConversionService.getSharedInstance().convert(getClaims().get(claim), String.class);
 	}
 
 	/**
@@ -73,7 +68,8 @@ public interface ClaimAccessor {
 	 * @return the claim value or {@code null} if it does not exist
 	 */
 	default Boolean getClaimAsBoolean(String claim) {
-		return (this.containsClaim(claim) ? Boolean.valueOf(this.getClaimAsString(claim)) : null);
+		return !containsClaim(claim) ? null :
+				ClaimConversionService.getSharedInstance().convert(getClaims().get(claim), Boolean.class);
 	}
 
 	/**
@@ -83,23 +79,16 @@ public interface ClaimAccessor {
 	 * @return the claim value or {@code null} if it does not exist
 	 */
 	default Instant getClaimAsInstant(String claim) {
-		if (!this.containsClaim(claim)) {
+		if (!containsClaim(claim)) {
 			return null;
 		}
-		Object claimValue = this.getClaims().get(claim);
-		if (Long.class.isAssignableFrom(claimValue.getClass()) ||
-				Integer.class.isAssignableFrom(claimValue.getClass()) ||
-				Double.class.isAssignableFrom(claimValue.getClass())) {
-			return Instant.ofEpochSecond(((Number) claimValue).longValue());
-		}
-		if (Date.class.isAssignableFrom(claimValue.getClass())) {
-			return ((Date) claimValue).toInstant();
+		Object claimValue = getClaims().get(claim);
+		Instant convertedValue = ClaimConversionService.getSharedInstance().convert(claimValue, Instant.class);
+		if (convertedValue == null) {
+			throw new IllegalArgumentException("Unable to convert claim '" + claim +
+					"' of type '" + claimValue.getClass() + "' to Instant.");
 		}
-		if (Instant.class.isAssignableFrom(claimValue.getClass())) {
-			return (Instant) claimValue;
-		}
-		throw new IllegalArgumentException("Unable to convert claim '" + claim +
-				"' of type '" + claimValue.getClass() + "' to Instant.");
+		return convertedValue;
 	}
 
 	/**
@@ -109,14 +98,16 @@ public interface ClaimAccessor {
 	 * @return the claim value or {@code null} if it does not exist
 	 */
 	default URL getClaimAsURL(String claim) {
-		if (!this.containsClaim(claim)) {
+		if (!containsClaim(claim)) {
 			return null;
 		}
-		try {
-			return new URL(this.getClaimAsString(claim));
-		} catch (MalformedURLException ex) {
-			throw new IllegalArgumentException("Unable to convert claim '" + claim + "' to URL: " + ex.getMessage(), ex);
+		Object claimValue = getClaims().get(claim);
+		URL convertedValue = ClaimConversionService.getSharedInstance().convert(claimValue, URL.class);
+		if (convertedValue == null) {
+			throw new IllegalArgumentException("Unable to convert claim '" + claim +
+					"' of type '" + claimValue.getClass() + "' to URL.");
 		}
+		return convertedValue;
 	}
 
 	/**
@@ -126,13 +117,22 @@ public interface ClaimAccessor {
 	 * @param claim the name of the claim
 	 * @return the claim value or {@code null} if it does not exist or cannot be assigned to a {@code Map}
 	 */
+	@SuppressWarnings("unchecked")
 	default Map<String, Object> getClaimAsMap(String claim) {
-		if (!this.containsClaim(claim) || !Map.class.isAssignableFrom(this.getClaims().get(claim).getClass())) {
+		if (!containsClaim(claim)) {
 			return null;
 		}
-		Map<String, Object> claimValues = new HashMap<>();
-		((Map<?, ?>) this.getClaims().get(claim)).forEach((k, v) -> claimValues.put(k.toString(), v));
-		return claimValues;
+		final TypeDescriptor sourceDescriptor = TypeDescriptor.valueOf(Object.class);
+		final TypeDescriptor targetDescriptor = TypeDescriptor.map(
+				Map.class, TypeDescriptor.valueOf(String.class), TypeDescriptor.valueOf(Object.class));
+		Object claimValue = getClaims().get(claim);
+		Map<String, Object> convertedValue = (Map<String, Object>) ClaimConversionService.getSharedInstance().convert(
+				claimValue, sourceDescriptor, targetDescriptor);
+		if (convertedValue == null) {
+			throw new IllegalArgumentException("Unable to convert claim '" + claim +
+					"' of type '" + claimValue.getClass() + "' to Map.");
+		}
+		return convertedValue;
 	}
 
 	/**
@@ -142,12 +142,21 @@ public interface ClaimAccessor {
 	 * @param claim the name of the claim
 	 * @return the claim value or {@code null} if it does not exist or cannot be assigned to a {@code List}
 	 */
+	@SuppressWarnings("unchecked")
 	default List<String> getClaimAsStringList(String claim) {
-		if (!this.containsClaim(claim) || !List.class.isAssignableFrom(this.getClaims().get(claim).getClass())) {
+		if (!containsClaim(claim)) {
 			return null;
 		}
-		List<String> claimValues = new ArrayList<>();
-		((List<?>) this.getClaims().get(claim)).forEach(e -> claimValues.add(e.toString()));
-		return claimValues;
+		final TypeDescriptor sourceDescriptor = TypeDescriptor.valueOf(Object.class);
+		final TypeDescriptor targetDescriptor = TypeDescriptor.collection(
+				List.class, TypeDescriptor.valueOf(String.class));
+		Object claimValue = getClaims().get(claim);
+		List<String> convertedValue = (List<String>) ClaimConversionService.getSharedInstance().convert(
+				claimValue, sourceDescriptor, targetDescriptor);
+		if (convertedValue == null) {
+			throw new IllegalArgumentException("Unable to convert claim '" + claim +
+					"' of type '" + claimValue.getClass() + "' to List.");
+		}
+		return convertedValue;
 	}
 }

+ 72 - 0
oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/converter/ClaimConversionService.java

@@ -0,0 +1,72 @@
+/*
+ * 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.oauth2.core.converter;
+
+import org.springframework.core.convert.ConversionService;
+import org.springframework.core.convert.converter.ConverterRegistry;
+import org.springframework.core.convert.support.GenericConversionService;
+import org.springframework.security.oauth2.core.ClaimAccessor;
+
+/**
+ * A {@link ConversionService} configured with converters
+ * that provide type conversion for claim values.
+ *
+ * @author Joe Grandja
+ * @since 5.2
+ * @see GenericConversionService
+ * @see ClaimAccessor
+ */
+public final class ClaimConversionService extends GenericConversionService {
+	private static volatile ClaimConversionService sharedInstance;
+
+	private ClaimConversionService() {
+		addConverters(this);
+	}
+
+	/**
+	 * Returns a shared instance of {@code ClaimConversionService}.
+	 *
+	 * @return a shared instance of {@code ClaimConversionService}
+	 */
+	public static ClaimConversionService getSharedInstance() {
+		ClaimConversionService sharedInstance = ClaimConversionService.sharedInstance;
+		if (sharedInstance == null) {
+			synchronized (ClaimConversionService.class) {
+				sharedInstance = ClaimConversionService.sharedInstance;
+				if (sharedInstance == null) {
+					sharedInstance = new ClaimConversionService();
+					ClaimConversionService.sharedInstance = sharedInstance;
+				}
+			}
+		}
+		return sharedInstance;
+	}
+
+	/**
+	 * Adds the converters that provide type conversion for claim values
+	 * to the provided {@link ConverterRegistry}.
+	 *
+	 * @param converterRegistry the registry of converters to add to
+	 */
+	public static void addConverters(ConverterRegistry converterRegistry) {
+		converterRegistry.addConverter(new ObjectToStringConverter());
+		converterRegistry.addConverter(new ObjectToBooleanConverter());
+		converterRegistry.addConverter(new ObjectToInstantConverter());
+		converterRegistry.addConverter(new ObjectToURLConverter());
+		converterRegistry.addConverter(new ObjectToListStringConverter());
+		converterRegistry.addConverter(new ObjectToMapStringObjectConverter());
+	}
+}

+ 67 - 0
oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/converter/ClaimTypeConverter.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 org.springframework.security.oauth2.core.converter;
+
+import org.springframework.core.convert.converter.Converter;
+import org.springframework.util.Assert;
+import org.springframework.util.CollectionUtils;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * A {@link Converter} that provides type conversion for claim values.
+ *
+ * @author Joe Grandja
+ * @since 5.2
+ * @see Converter
+ */
+public final class ClaimTypeConverter implements Converter<Map<String, Object>, Map<String, Object>> {
+	private final Map<String, Converter<Object, ?>> claimTypeConverters;
+
+	/**
+	 * Constructs a {@code ClaimTypeConverter} using the provided parameters.
+	 *
+	 * @param claimTypeConverters a {@link Map} of {@link Converter}(s) keyed by claim name
+	 */
+	public ClaimTypeConverter(Map<String, Converter<Object, ?>> claimTypeConverters) {
+		Assert.notEmpty(claimTypeConverters, "claimTypeConverters cannot be empty");
+		Assert.noNullElements(claimTypeConverters.values().toArray(), "Converter(s) cannot be null");
+		this.claimTypeConverters = Collections.unmodifiableMap(new LinkedHashMap<>(claimTypeConverters));
+	}
+
+	@Override
+	public Map<String, Object> convert(Map<String, Object> claims) {
+		if (CollectionUtils.isEmpty(claims)) {
+			return claims;
+		}
+
+		Map<String, Object> result = new HashMap<>(claims);
+		this.claimTypeConverters.forEach((claimName, typeConverter) -> {
+			if (claims.containsKey(claimName)) {
+				Object claim = claims.get(claimName);
+				Object mappedClaim = typeConverter.convert(claim);
+				if (mappedClaim != null) {
+					result.put(claimName, mappedClaim);
+				}
+			}
+		});
+
+		return result;
+	}
+}

+ 45 - 0
oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/converter/ObjectToBooleanConverter.java

@@ -0,0 +1,45 @@
+/*
+ * 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.oauth2.core.converter;
+
+import org.springframework.core.convert.TypeDescriptor;
+import org.springframework.core.convert.converter.GenericConverter;
+
+import java.util.Collections;
+import java.util.Set;
+
+/**
+ * @author Joe Grandja
+ * @since 5.2
+ */
+final class ObjectToBooleanConverter implements GenericConverter {
+
+	@Override
+	public Set<ConvertiblePair> getConvertibleTypes() {
+		return Collections.singleton(new ConvertiblePair(Object.class, Boolean.class));
+	}
+
+	@Override
+	public Object convert(Object source, TypeDescriptor sourceType, TypeDescriptor targetType) {
+		if (source == null) {
+			return null;
+		}
+		if (source instanceof Boolean) {
+			return source;
+		}
+		return Boolean.valueOf(source.toString());
+	}
+}

+ 63 - 0
oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/converter/ObjectToInstantConverter.java

@@ -0,0 +1,63 @@
+/*
+ * 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.oauth2.core.converter;
+
+import org.springframework.core.convert.TypeDescriptor;
+import org.springframework.core.convert.converter.GenericConverter;
+
+import java.time.Instant;
+import java.util.Collections;
+import java.util.Date;
+import java.util.Set;
+
+/**
+ * @author Joe Grandja
+ * @since 5.2
+ */
+final class ObjectToInstantConverter implements GenericConverter {
+
+	@Override
+	public Set<ConvertiblePair> getConvertibleTypes() {
+		return Collections.singleton(new ConvertiblePair(Object.class, Instant.class));
+	}
+
+	@Override
+	public Object convert(Object source, TypeDescriptor sourceType, TypeDescriptor targetType) {
+		if (source == null) {
+			return null;
+		}
+		if (source instanceof Instant) {
+			return source;
+		}
+		if (source instanceof Date) {
+			return ((Date) source).toInstant();
+		}
+		if (source instanceof Number) {
+			return Instant.ofEpochSecond(((Number) source).longValue());
+		}
+		try {
+			return Instant.ofEpochSecond(Long.parseLong(source.toString()));
+		} catch (Exception ex) {
+			// Ignore
+		}
+		try {
+			return Instant.parse(source.toString());
+		} catch (Exception ex) {
+			// Ignore
+		}
+		return null;
+	}
+}

+ 74 - 0
oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/converter/ObjectToListStringConverter.java

@@ -0,0 +1,74 @@
+/*
+ * 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.oauth2.core.converter;
+
+import org.springframework.core.convert.TypeDescriptor;
+import org.springframework.core.convert.converter.ConditionalGenericConverter;
+import org.springframework.util.ClassUtils;
+
+import java.util.Collection;
+import java.util.Collections;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * @author Joe Grandja
+ * @since 5.2
+ */
+final class ObjectToListStringConverter implements ConditionalGenericConverter {
+
+	@Override
+	public Set<ConvertiblePair> getConvertibleTypes() {
+		Set<ConvertiblePair> convertibleTypes = new LinkedHashSet<>();
+		convertibleTypes.add(new ConvertiblePair(Object.class, List.class));
+		convertibleTypes.add(new ConvertiblePair(Object.class, Collection.class));
+		return convertibleTypes;
+	}
+
+	@Override
+	public boolean matches(TypeDescriptor sourceType, TypeDescriptor targetType) {
+		if (targetType.getElementTypeDescriptor() == null ||
+				targetType.getElementTypeDescriptor().getType().equals(String.class) ||
+				sourceType == null ||
+				ClassUtils.isAssignable(sourceType.getType(), targetType.getElementTypeDescriptor().getType())) {
+			return true;
+		}
+		return false;
+	}
+
+	@Override
+	public Object convert(Object source, TypeDescriptor sourceType, TypeDescriptor targetType) {
+		if (source == null) {
+			return null;
+		}
+		if (source instanceof List) {
+			List<?> sourceList = (List<?>) source;
+			if (!sourceList.isEmpty() && sourceList.get(0) instanceof String) {
+				return source;
+			}
+		}
+		if (source instanceof Collection) {
+			return ((Collection<?>) source).stream()
+					.filter(Objects::nonNull)
+					.map(Objects::toString)
+					.collect(Collectors.toList());
+		}
+		return Collections.singletonList(source.toString());
+	}
+}

+ 62 - 0
oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/converter/ObjectToMapStringObjectConverter.java

@@ -0,0 +1,62 @@
+/*
+ * 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.oauth2.core.converter;
+
+import org.springframework.core.convert.TypeDescriptor;
+import org.springframework.core.convert.converter.ConditionalGenericConverter;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * @author Joe Grandja
+ * @since 5.2
+ */
+final class ObjectToMapStringObjectConverter implements ConditionalGenericConverter {
+
+	@Override
+	public Set<ConvertiblePair> getConvertibleTypes() {
+		return Collections.singleton(new ConvertiblePair(Object.class, Map.class));
+	}
+
+	@Override
+	public boolean matches(TypeDescriptor sourceType, TypeDescriptor targetType) {
+		if (targetType.getElementTypeDescriptor() == null ||
+				targetType.getMapKeyTypeDescriptor().getType().equals(String.class)) {
+			return true;
+		}
+		return false;
+	}
+
+	@Override
+	public Object convert(Object source, TypeDescriptor sourceType, TypeDescriptor targetType) {
+		if (source == null) {
+			return null;
+		}
+		if (!(source instanceof Map)) {
+			return null;
+		}
+		Map<?, ?> sourceMap = (Map<?, ?>) source;
+		if (!sourceMap.isEmpty() && sourceMap.keySet().iterator().next() instanceof String) {
+			return source;
+		}
+		Map<String, Object> result = new HashMap<>();
+		sourceMap.forEach((k, v) -> result.put(k.toString(), v));
+		return result;
+	}
+}

+ 39 - 0
oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/converter/ObjectToStringConverter.java

@@ -0,0 +1,39 @@
+/*
+ * 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.oauth2.core.converter;
+
+import org.springframework.core.convert.TypeDescriptor;
+import org.springframework.core.convert.converter.GenericConverter;
+
+import java.util.Collections;
+import java.util.Set;
+
+/**
+ * @author Joe Grandja
+ * @since 5.2
+ */
+final class ObjectToStringConverter implements GenericConverter {
+
+	@Override
+	public Set<ConvertiblePair> getConvertibleTypes() {
+		return Collections.singleton(new ConvertiblePair(Object.class, String.class));
+	}
+
+	@Override
+	public Object convert(Object source, TypeDescriptor sourceType, TypeDescriptor targetType) {
+		return source == null ? null : source.toString();
+	}
+}

+ 52 - 0
oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/converter/ObjectToURLConverter.java

@@ -0,0 +1,52 @@
+/*
+ * 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.oauth2.core.converter;
+
+import org.springframework.core.convert.TypeDescriptor;
+import org.springframework.core.convert.converter.GenericConverter;
+
+import java.net.URI;
+import java.net.URL;
+import java.util.Collections;
+import java.util.Set;
+
+/**
+ * @author Joe Grandja
+ * @since 5.2
+ */
+final class ObjectToURLConverter implements GenericConverter {
+
+	@Override
+	public Set<ConvertiblePair> getConvertibleTypes() {
+		return Collections.singleton(new ConvertiblePair(Object.class, URL.class));
+	}
+
+	@Override
+	public Object convert(Object source, TypeDescriptor sourceType, TypeDescriptor targetType) {
+		if (source == null) {
+			return null;
+		}
+		if (source instanceof URL) {
+			return source;
+		}
+		try {
+			return new URI(source.toString()).toURL();
+		} catch (Exception ex) {
+			// Ignore
+		}
+		return null;
+	}
+}

+ 227 - 0
oauth2/oauth2-core/src/test/java/org/springframework/security/oauth2/core/converter/ClaimConversionServiceTests.java

@@ -0,0 +1,227 @@
+/*
+ * 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.oauth2.core.converter;
+
+import org.assertj.core.util.Lists;
+import org.junit.Test;
+import org.springframework.core.convert.ConversionService;
+
+import java.net.URL;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link ClaimConversionService}.
+ *
+ * @author Joe Grandja
+ * @since 5.2
+ */
+public class ClaimConversionServiceTests {
+	private final ConversionService conversionService = ClaimConversionService.getSharedInstance();
+
+	@Test
+	public void convertStringWhenNullThenReturnNull() {
+		assertThat(this.conversionService.convert(null, String.class)).isNull();
+	}
+
+	@Test
+	public void convertStringWhenStringThenReturnSame() {
+		assertThat(this.conversionService.convert("string", String.class)).isSameAs("string");
+	}
+
+	@Test
+	public void convertStringWhenNumberThenConverts() {
+		assertThat(this.conversionService.convert(1234, String.class)).isEqualTo("1234");
+	}
+
+	@Test
+	public void convertBooleanWhenNullThenReturnNull() {
+		assertThat(this.conversionService.convert(null, Boolean.class)).isNull();
+	}
+
+	@Test
+	public void convertBooleanWhenBooleanThenReturnSame() {
+		assertThat(this.conversionService.convert(Boolean.TRUE, Boolean.class)).isSameAs(Boolean.TRUE);
+	}
+
+	@Test
+	public void convertBooleanWhenStringTrueThenConverts() {
+		assertThat(this.conversionService.convert("true", Boolean.class)).isEqualTo(Boolean.TRUE);
+	}
+
+	@Test
+	public void convertBooleanWhenNotConvertibleThenReturnBooleanFalse() {
+		assertThat(this.conversionService.convert("not-convertible-boolean", Boolean.class)).isEqualTo(Boolean.FALSE);
+	}
+
+	@Test
+	public void convertInstantWhenNullThenReturnNull() {
+		assertThat(this.conversionService.convert(null, Instant.class)).isNull();
+	}
+
+	@Test
+	public void convertInstantWhenInstantThenReturnSame() {
+		Instant instant = Instant.now();
+		assertThat(this.conversionService.convert(instant, Instant.class)).isSameAs(instant);
+	}
+
+	@Test
+	public void convertInstantWhenDateThenConverts() {
+		Instant instant = Instant.now();
+		assertThat(this.conversionService.convert(Date.from(instant), Instant.class)).isEqualTo(instant);
+	}
+
+	@Test
+	public void convertInstantWhenNumberThenConverts() {
+		Instant instant = Instant.now();
+		assertThat(this.conversionService.convert(instant.getEpochSecond(), Instant.class))
+				.isEqualTo(instant.truncatedTo(ChronoUnit.SECONDS));
+	}
+
+	@Test
+	public void convertInstantWhenStringThenConverts() {
+		Instant instant = Instant.now();
+		assertThat(this.conversionService.convert(String.valueOf(instant.getEpochSecond()), Instant.class))
+				.isEqualTo(instant.truncatedTo(ChronoUnit.SECONDS));
+		assertThat(this.conversionService.convert(String.valueOf(instant.toString()), Instant.class)).isEqualTo(instant);
+	}
+
+	@Test
+	public void convertInstantWhenNotConvertibleThenReturnNull() {
+		assertThat(this.conversionService.convert("not-convertible-instant", Instant.class)).isNull();
+	}
+
+	@Test
+	public void convertUrlWhenNullThenReturnNull() {
+		assertThat(this.conversionService.convert(null, URL.class)).isNull();
+	}
+
+	@Test
+	public void convertUrlWhenUrlThenReturnSame() throws Exception {
+		URL url = new URL("https://localhost");
+		assertThat(this.conversionService.convert(url, URL.class)).isSameAs(url);
+	}
+
+	@Test
+	public void convertUrlWhenStringThenConverts() throws Exception {
+		String urlString = "https://localhost";
+		URL url = new URL(urlString);
+		assertThat(this.conversionService.convert(urlString, URL.class)).isEqualTo(url);
+	}
+
+	@Test
+	public void convertUrlWhenNotConvertibleThenReturnNull() {
+		assertThat(this.conversionService.convert("not-convertible-url", URL.class)).isNull();
+	}
+
+	@Test
+	public void convertCollectionStringWhenNullThenReturnNull() {
+		assertThat(this.conversionService.convert(null, Collection.class)).isNull();
+	}
+
+	@Test
+	public void convertCollectionStringWhenListStringThenReturnSame() {
+		List<String> list = Lists.list("1", "2", "3", "4");
+		assertThat(this.conversionService.convert(list, Collection.class)).isSameAs(list);
+	}
+
+	@Test
+	public void convertCollectionStringWhenListNumberThenConverts() {
+		assertThat(this.conversionService.convert(Lists.list(1, 2, 3, 4), Collection.class))
+				.isEqualTo(Lists.list("1", "2", "3", "4"));
+	}
+
+	@Test
+	public void convertCollectionStringWhenNotConvertibleThenReturnSingletonList() {
+		String string = "not-convertible-collection";
+		assertThat(this.conversionService.convert(string, Collection.class))
+				.isEqualTo(Collections.singletonList(string));
+	}
+
+	@Test
+	public void convertListStringWhenNullThenReturnNull() {
+		assertThat(this.conversionService.convert(null, List.class)).isNull();
+	}
+
+	@Test
+	public void convertListStringWhenListStringThenReturnSame() {
+		List<String> list = Lists.list("1", "2", "3", "4");
+		assertThat(this.conversionService.convert(list, List.class)).isSameAs(list);
+	}
+
+	@Test
+	public void convertListStringWhenListNumberThenConverts() {
+		assertThat(this.conversionService.convert(Lists.list(1, 2, 3, 4), List.class))
+				.isEqualTo(Lists.list("1", "2", "3", "4"));
+	}
+
+	@Test
+	public void convertListStringWhenNotConvertibleThenReturnSingletonList() {
+		String string = "not-convertible-list";
+		assertThat(this.conversionService.convert(string, List.class))
+				.isEqualTo(Collections.singletonList(string));
+	}
+
+	@Test
+	public void convertMapStringObjectWhenNullThenReturnNull() {
+		assertThat(this.conversionService.convert(null, Map.class)).isNull();
+	}
+
+	@Test
+	public void convertMapStringObjectWhenMapStringObjectThenReturnSame() {
+		Map<String, Object> mapStringObject = new HashMap<String, Object>() {
+			{
+				put("key1", "value1");
+				put("key2", "value2");
+				put("key3", "value3");
+			}
+		};
+		assertThat(this.conversionService.convert(mapStringObject, Map.class)).isSameAs(mapStringObject);
+	}
+
+	@Test
+	public void convertMapStringObjectWhenMapIntegerObjectThenConverts() {
+		Map<String, Object> mapStringObject = new HashMap<String, Object>() {
+			{
+				put("1", "value1");
+				put("2", "value2");
+				put("3", "value3");
+			}
+		};
+		Map<Integer, Object> mapIntegerObject = new HashMap<Integer, Object>() {
+			{
+				put(1, "value1");
+				put(2, "value2");
+				put(3, "value3");
+			}
+		};
+		assertThat(this.conversionService.convert(mapIntegerObject, Map.class)).isEqualTo(mapStringObject);
+	}
+
+	@Test
+	public void convertMapStringObjectWhenNotConvertibleThenReturnNull() {
+		List<String> notConvertibleList = Lists.list("1", "2", "3", "4");
+		assertThat(this.conversionService.convert(notConvertibleList, Map.class)).isNull();
+	}
+}

+ 170 - 0
oauth2/oauth2-core/src/test/java/org/springframework/security/oauth2/core/converter/ClaimTypeConverterTests.java

@@ -0,0 +1,170 @@
+/*
+ * 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.oauth2.core.converter;
+
+import org.assertj.core.util.Lists;
+import org.junit.Before;
+import org.junit.Test;
+import org.springframework.core.convert.TypeDescriptor;
+import org.springframework.core.convert.converter.Converter;
+
+import java.net.URL;
+import java.time.Instant;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Tests for {@link ClaimTypeConverter}.
+ *
+ * @author Joe Grandja
+ * @since 5.2
+ */
+public class ClaimTypeConverterTests {
+	private static final String STRING_CLAIM = "string-claim";
+	private static final String BOOLEAN_CLAIM = "boolean-claim";
+	private static final String INSTANT_CLAIM = "instant-claim";
+	private static final String URL_CLAIM = "url-claim";
+	private static final String COLLECTION_STRING_CLAIM = "collection-string-claim";
+	private static final String LIST_STRING_CLAIM = "list-string-claim";
+	private static final String MAP_STRING_OBJECT_CLAIM = "map-string-object-claim";
+	private ClaimTypeConverter claimTypeConverter;
+
+	@Before
+	@SuppressWarnings("unchecked")
+	public void setup() {
+		Converter<Object, ?> stringConverter = getConverter(TypeDescriptor.valueOf(String.class));
+		Converter<Object, ?> booleanConverter = getConverter(TypeDescriptor.valueOf(Boolean.class));
+		Converter<Object, ?> instantConverter = getConverter(TypeDescriptor.valueOf(Instant.class));
+		Converter<Object, ?> urlConverter = getConverter(TypeDescriptor.valueOf(URL.class));
+		Converter<Object, ?> collectionStringConverter = getConverter(
+				TypeDescriptor.collection(Collection.class, TypeDescriptor.valueOf(String.class)));
+		Converter<Object, ?> listStringConverter = getConverter(
+				TypeDescriptor.collection(List.class, TypeDescriptor.valueOf(String.class)));
+		Converter<Object, ?> mapStringObjectConverter = getConverter(
+				TypeDescriptor.map(Map.class, TypeDescriptor.valueOf(String.class), TypeDescriptor.valueOf(Object.class)));
+
+		Map<String, Converter<Object, ?>> claimTypeConverters = new HashMap<>();
+		claimTypeConverters.put(STRING_CLAIM, stringConverter);
+		claimTypeConverters.put(BOOLEAN_CLAIM, booleanConverter);
+		claimTypeConverters.put(INSTANT_CLAIM, instantConverter);
+		claimTypeConverters.put(URL_CLAIM, urlConverter);
+		claimTypeConverters.put(COLLECTION_STRING_CLAIM, collectionStringConverter);
+		claimTypeConverters.put(LIST_STRING_CLAIM, listStringConverter);
+		claimTypeConverters.put(MAP_STRING_OBJECT_CLAIM, mapStringObjectConverter);
+		this.claimTypeConverter = new ClaimTypeConverter(claimTypeConverters);
+	}
+
+	private static Converter<Object, ?> getConverter(TypeDescriptor targetDescriptor) {
+		final TypeDescriptor sourceDescriptor = TypeDescriptor.valueOf(Object.class);
+		return source -> ClaimConversionService.getSharedInstance().convert(source, sourceDescriptor, targetDescriptor);
+	}
+
+	@Test
+	public void constructorWhenConvertersNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> new ClaimTypeConverter(null))
+				.isInstanceOf(IllegalArgumentException.class);
+	}
+
+	@Test
+	public void constructorWhenConvertersHasNullConverterThenThrowIllegalArgumentException() {
+		Map<String, Converter<Object, ?>> claimTypeConverters = new HashMap<>();
+		claimTypeConverters.put("claim1", null);
+		assertThatThrownBy(() -> new ClaimTypeConverter(claimTypeConverters))
+				.isInstanceOf(IllegalArgumentException.class);
+	}
+
+	@Test
+	public void convertWhenClaimsEmptyThenReturnSame() {
+		Map<String, Object> claims = new HashMap<>();
+		assertThat(this.claimTypeConverter.convert(claims)).isSameAs(claims);
+	}
+
+	@Test
+	public void convertWhenAllClaimsRequireConversionThenConvertAll() throws Exception {
+		Instant instant = Instant.now();
+		URL url = new URL("https://localhost");
+		List<Number> listNumber = Lists.list(1, 2, 3, 4);
+		List<String> listString = Lists.list("1", "2", "3", "4");
+		Map<Integer, Object> mapIntegerObject = new HashMap<>();
+		mapIntegerObject.put(1, "value1");
+		Map<String, Object> mapStringObject = new HashMap<>();
+		mapStringObject.put("1", "value1");
+
+		Map<String, Object> claims = new HashMap<>();
+		claims.put(STRING_CLAIM, Boolean.TRUE);
+		claims.put(BOOLEAN_CLAIM, "true");
+		claims.put(INSTANT_CLAIM, instant.toString());
+		claims.put(URL_CLAIM, url.toExternalForm());
+		claims.put(COLLECTION_STRING_CLAIM, listNumber);
+		claims.put(LIST_STRING_CLAIM, listNumber);
+		claims.put(MAP_STRING_OBJECT_CLAIM, mapIntegerObject);
+
+		claims = this.claimTypeConverter.convert(claims);
+
+		assertThat(claims.get(STRING_CLAIM)).isEqualTo("true");
+		assertThat(claims.get(BOOLEAN_CLAIM)).isEqualTo(Boolean.TRUE);
+		assertThat(claims.get(INSTANT_CLAIM)).isEqualTo(instant);
+		assertThat(claims.get(URL_CLAIM)).isEqualTo(url);
+		assertThat(claims.get(COLLECTION_STRING_CLAIM)).isEqualTo(listString);
+		assertThat(claims.get(LIST_STRING_CLAIM)).isEqualTo(listString);
+		assertThat(claims.get(MAP_STRING_OBJECT_CLAIM)).isEqualTo(mapStringObject);
+	}
+
+	@Test
+	public void convertWhenNoClaimsRequireConversionThenConvertNone() throws Exception {
+		String string = "value";
+		Boolean bool = Boolean.TRUE;
+		Instant instant = Instant.now();
+		URL url = new URL("https://localhost");
+		List<String> listString = Lists.list("1", "2", "3", "4");
+		Map<String, Object> mapStringObject = new HashMap<>();
+		mapStringObject.put("1", "value1");
+
+		Map<String, Object> claims = new HashMap<>();
+		claims.put(STRING_CLAIM, string);
+		claims.put(BOOLEAN_CLAIM, bool);
+		claims.put(INSTANT_CLAIM, instant);
+		claims.put(URL_CLAIM, url);
+		claims.put(COLLECTION_STRING_CLAIM, listString);
+		claims.put(LIST_STRING_CLAIM, listString);
+		claims.put(MAP_STRING_OBJECT_CLAIM, mapStringObject);
+
+		claims = this.claimTypeConverter.convert(claims);
+
+		assertThat(claims.get(STRING_CLAIM)).isSameAs(string);
+		assertThat(claims.get(BOOLEAN_CLAIM)).isSameAs(bool);
+		assertThat(claims.get(INSTANT_CLAIM)).isSameAs(instant);
+		assertThat(claims.get(URL_CLAIM)).isSameAs(url);
+		assertThat(claims.get(COLLECTION_STRING_CLAIM)).isSameAs(listString);
+		assertThat(claims.get(LIST_STRING_CLAIM)).isSameAs(listString);
+		assertThat(claims.get(MAP_STRING_OBJECT_CLAIM)).isSameAs(mapStringObject);
+	}
+
+	@Test
+	public void convertWhenConverterNotAvailableThenDoesNotConvert() {
+		Map<String, Object> claims = new HashMap<>();
+		claims.put("claim1", "value1");
+
+		claims = this.claimTypeConverter.convert(claims);
+
+		assertThat(claims.get("claim1")).isSameAs("value1");
+	}
+}

+ 85 - 136
oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/MappedJwtClaimSetConverter.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2002-2018 the original author or authors.
+ * 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.
@@ -16,47 +16,49 @@
 
 package org.springframework.security.oauth2.jwt;
 
+import org.springframework.core.convert.ConversionService;
+import org.springframework.core.convert.TypeDescriptor;
+import org.springframework.core.convert.converter.Converter;
+import org.springframework.security.oauth2.core.converter.ClaimConversionService;
+import org.springframework.security.oauth2.core.converter.ClaimTypeConverter;
+import org.springframework.util.Assert;
+
 import java.net.URI;
 import java.net.URL;
 import java.time.Instant;
-import java.util.Arrays;
 import java.util.Collection;
-import java.util.Date;
 import java.util.HashMap;
 import java.util.Map;
-import java.util.Objects;
 import java.util.stream.Collectors;
 
-import org.springframework.core.convert.converter.Converter;
-import org.springframework.util.Assert;
-
 /**
  * Converts a JWT claim set, claim by claim. Can be configured with custom converters
  * by claim name.
  *
  * @author Josh Cummings
  * @since 5.1
+ * @see ClaimTypeConverter
  */
-public final class MappedJwtClaimSetConverter
-		implements Converter<Map<String, Object>, Map<String, Object>> {
-
-	private static final Converter<Object, Collection<String>> AUDIENCE_CONVERTER = new AudienceConverter();
-	private static final Converter<Object, String> ISSUER_CONVERTER = new IssuerConverter();
-	private static final Converter<Object, String> STRING_CONVERTER = new StringConverter();
-	private static final Converter<Object, Instant> TEMPORAL_CONVERTER = new InstantConverter();
-
-	private final Map<String, Converter<Object, ?>> claimConverters;
+public final class MappedJwtClaimSetConverter implements Converter<Map<String, Object>, Map<String, Object>> {
+	private final static ConversionService CONVERSION_SERVICE = ClaimConversionService.getSharedInstance();
+	private final static TypeDescriptor OBJECT_TYPE_DESCRIPTOR = TypeDescriptor.valueOf(Object.class);
+	private final static TypeDescriptor STRING_TYPE_DESCRIPTOR = TypeDescriptor.valueOf(String.class);
+	private final static TypeDescriptor INSTANT_TYPE_DESCRIPTOR = TypeDescriptor.valueOf(Instant.class);
+	private final static TypeDescriptor URL_TYPE_DESCRIPTOR = TypeDescriptor.valueOf(URL.class);
+	private final Map<String, Converter<Object, ?>> claimTypeConverters;
+	private final Converter<Map<String, Object>, Map<String, Object>> delegate;
 
 	/**
 	 * Constructs a {@link MappedJwtClaimSetConverter} with the provided arguments
 	 *
 	 * This will completely replace any set of default converters.
 	 *
-	 * @param claimConverters The {@link Map} of converters to use
+	 * @param claimTypeConverters The {@link Map} of converters to use
 	 */
-	public MappedJwtClaimSetConverter(Map<String, Converter<Object, ?>> claimConverters) {
-		Assert.notNull(claimConverters, "claimConverters cannot be null");
-		this.claimConverters = new HashMap<>(claimConverters);
+	public MappedJwtClaimSetConverter(Map<String, Converter<Object, ?>> claimTypeConverters) {
+		Assert.notNull(claimTypeConverters, "claimTypeConverters cannot be null");
+		this.claimTypeConverters = claimTypeConverters;
+		this.delegate = new ClaimTypeConverter(claimTypeConverters);
 	}
 
 	/**
@@ -78,29 +80,65 @@ public final class MappedJwtClaimSetConverter
 	 * 		Collections.singletonMap(JwtClaimNames.SUB, new UserDetailsServiceJwtSubjectConverter()));
 	 * </pre>
 	 *
-	 * To completely replace the underlying {@link Map} of converters, {@see MappedJwtClaimSetConverter(Map)}.
+	 * To completely replace the underlying {@link Map} of converters, see {@link MappedJwtClaimSetConverter#MappedJwtClaimSetConverter(Map)}.
 	 *
-	 * @param claimConverters
+	 * @param claimTypeConverters
 	 * @return An instance of {@link MappedJwtClaimSetConverter} that contains the converters provided,
 	 *   plus any defaults that were not overridden.
 	 */
-	public static MappedJwtClaimSetConverter withDefaults
-			(Map<String, Converter<Object, ?>> claimConverters) {
-		Assert.notNull(claimConverters, "claimConverters cannot be null");
+	public static MappedJwtClaimSetConverter withDefaults(Map<String, Converter<Object, ?>> claimTypeConverters) {
+		Assert.notNull(claimTypeConverters, "claimTypeConverters cannot be null");
+
+		Converter<Object, ?> stringConverter = getConverter(STRING_TYPE_DESCRIPTOR);
+		Converter<Object, ?> collectionStringConverter = getConverter(
+				TypeDescriptor.collection(Collection.class, STRING_TYPE_DESCRIPTOR));
 
 		Map<String, Converter<Object, ?>> claimNameToConverter = new HashMap<>();
-		claimNameToConverter.put(JwtClaimNames.AUD, AUDIENCE_CONVERTER);
-		claimNameToConverter.put(JwtClaimNames.EXP, TEMPORAL_CONVERTER);
-		claimNameToConverter.put(JwtClaimNames.IAT, TEMPORAL_CONVERTER);
-		claimNameToConverter.put(JwtClaimNames.ISS, ISSUER_CONVERTER);
-		claimNameToConverter.put(JwtClaimNames.JTI, STRING_CONVERTER);
-		claimNameToConverter.put(JwtClaimNames.NBF, TEMPORAL_CONVERTER);
-		claimNameToConverter.put(JwtClaimNames.SUB, STRING_CONVERTER);
-		claimNameToConverter.putAll(claimConverters);
+		claimNameToConverter.put(JwtClaimNames.AUD, collectionStringConverter);
+		claimNameToConverter.put(JwtClaimNames.EXP, MappedJwtClaimSetConverter::convertInstant);
+		claimNameToConverter.put(JwtClaimNames.IAT, MappedJwtClaimSetConverter::convertInstant);
+		claimNameToConverter.put(JwtClaimNames.ISS, MappedJwtClaimSetConverter::convertIssuer);
+		claimNameToConverter.put(JwtClaimNames.JTI, stringConverter);
+		claimNameToConverter.put(JwtClaimNames.NBF, MappedJwtClaimSetConverter::convertInstant);
+		claimNameToConverter.put(JwtClaimNames.SUB, stringConverter);
+		claimNameToConverter.putAll(claimTypeConverters);
 
 		return new MappedJwtClaimSetConverter(claimNameToConverter);
 	}
 
+	private static Converter<Object, ?> getConverter(TypeDescriptor targetDescriptor) {
+		return source -> CONVERSION_SERVICE.convert(source, OBJECT_TYPE_DESCRIPTOR, targetDescriptor);
+	}
+
+	private static Instant convertInstant(Object source) {
+		if (source == null) {
+			return null;
+		}
+		Instant result = (Instant) CONVERSION_SERVICE.convert(source, OBJECT_TYPE_DESCRIPTOR, INSTANT_TYPE_DESCRIPTOR);
+		if (result == null) {
+			throw new IllegalStateException("Could not coerce " + source + " into an Instant");
+		}
+		return result;
+	}
+
+	private static String convertIssuer(Object source) {
+		if (source == null) {
+			return null;
+		}
+		URL result = (URL) CONVERSION_SERVICE.convert(source, OBJECT_TYPE_DESCRIPTOR, URL_TYPE_DESCRIPTOR);
+		if (result != null) {
+			return result.toExternalForm();
+		}
+		if (source instanceof String && ((String) source).contains(":")) {
+			try {
+				return new URI((String) source).toString();
+			} catch (Exception ex) {
+				throw new IllegalStateException("Could not coerce " + source + " into a URI String", ex);
+			}
+		}
+		return (String) CONVERSION_SERVICE.convert(source, OBJECT_TYPE_DESCRIPTOR, STRING_TYPE_DESCRIPTOR);
+	}
+
 	/**
 	 * {@inheritDoc}
 	 */
@@ -108,17 +146,10 @@ public final class MappedJwtClaimSetConverter
 	public Map<String, Object> convert(Map<String, Object> claims) {
 		Assert.notNull(claims, "claims cannot be null");
 
-		Map<String, Object> mappedClaims = new HashMap<>(claims);
+		Map<String, Object> mappedClaims = this.delegate.convert(claims);
 
-		for (Map.Entry<String, Converter<Object, ?>> entry : this.claimConverters.entrySet()) {
-			String claimName = entry.getKey();
-			Converter<Object, ?> converter = entry.getValue();
-			if (converter != null) {
-				Object claim = claims.get(claimName);
-				Object mappedClaim = converter.convert(claim);
-				mappedClaims.compute(claimName, (key, value) -> mappedClaim);
-			}
-		}
+		mappedClaims = removeClaims(mappedClaims);
+		mappedClaims = addClaims(mappedClaims);
 
 		Instant issuedAt = (Instant) mappedClaims.get(JwtClaimNames.IAT);
 		Instant expiresAt = (Instant) mappedClaims.get(JwtClaimNames.EXP);
@@ -129,100 +160,18 @@ public final class MappedJwtClaimSetConverter
 		return mappedClaims;
 	}
 
-	/**
-	 * Coerces an <a target="_blank" href="https://tools.ietf.org/html/rfc7519#section-4.1.3">Audience</a> claim
-	 * into a {@link Collection<String>}, ignoring null values, and throwing an error if its coercion efforts fail.
-	 */
-	private static class AudienceConverter implements Converter<Object, Collection<String>> {
-
-		@Override
-		public Collection<String> convert(Object source) {
-			if (source == null) {
-				return null;
-			}
-
-			if (source instanceof Collection) {
-				return ((Collection<?>) source).stream()
-						.filter(Objects::nonNull)
-						.map(Objects::toString)
-						.collect(Collectors.toList());
-			}
-
-			return Arrays.asList(source.toString());
-		}
-	}
-
-	/**
-	 * Coerces an <a target="_blank" href="https://tools.ietf.org/html/rfc7519#section-4.1.1">Issuer</a> claim
-	 * into a {@link URL}, ignoring null values, and throwing an error if its coercion efforts fail.
-	 */
-	private static class IssuerConverter implements Converter<Object, String> {
-
-		@Override
-		public String convert(Object source) {
-			if (source == null) {
-				return null;
-			}
-
-			if (source instanceof URL) {
-				return ((URL) source).toExternalForm();
-			}
-
-			if (source instanceof String && ((String) source).contains(":")) {
-				try {
-					return URI.create((String) source).toString();
-				} catch (Exception e) {
-					throw new IllegalStateException("Could not coerce " + source + " into a URI String", e);
-				}
-			}
-
-			return source.toString();
-		}
-	}
-
-	/**
-	 * Coerces a claim into an {@link Instant}, ignoring null values, and throwing an error
-	 * if its coercion efforts fail.
-	 */
-	private static class InstantConverter implements Converter<Object, Instant> {
-		@Override
-		public Instant convert(Object source) {
-			if (source == null) {
-				return null;
-			}
-
-			if (source instanceof Instant) {
-				return (Instant) source;
-			}
-
-			if (source instanceof Date) {
-				return ((Date) source).toInstant();
-			}
-
-			if (source instanceof Number) {
-				return Instant.ofEpochSecond(((Number) source).longValue());
-			}
-
-			try {
-				return Instant.ofEpochSecond(Long.parseLong(source.toString()));
-			} catch (Exception e) {
-				throw new IllegalStateException("Could not coerce " + source + " into an Instant", e);
-			}
-		}
+	private Map<String, Object> removeClaims(Map<String, Object> claims) {
+		return claims.entrySet().stream()
+				.filter(e -> e.getValue() != null)
+				.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
 	}
 
-	/**
-	 * Coerces a claim into a {@link String}, ignoring null values, and throwing an error if its
-	 * coercion efforts fail.
-	 */
-	private static class StringConverter implements Converter<Object, String> {
-		@Override
-		public String convert(Object source) {
-			if (source == null) {
-				return null;
-			}
-
-			return source.toString();
-		}
+	private Map<String, Object> addClaims(Map<String, Object> claims) {
+		Map<String, Object> result = new HashMap<>(claims);
+		this.claimTypeConverters.entrySet().stream()
+				.filter(e -> !claims.containsKey(e.getKey()))
+				.filter(e -> e.getValue().convert(null) != null)
+				.forEach(e -> result.put(e.getKey(), e.getValue().convert(null)));
+		return result;
 	}
 }