diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/OidcIdTokenDecoderFactory.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/OidcIdTokenDecoderFactory.java index 37876d84895..4ac42d2d79d 100644 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/OidcIdTokenDecoderFactory.java +++ b/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.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, Map> DEFAULT_CLAIM_TYPE_CONVERTER = + new ClaimTypeConverter(createDefaultClaimTypeConverters()); private final Map jwtDecoders = new ConcurrentHashMap<>(); private Function> jwtValidatorFactory = OidcIdTokenValidator::new; private Function jwsAlgorithmResolver = clientRegistration -> SignatureAlgorithm.RS256; + private Function, Map>> 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> createDefaultClaimTypeConverters() { + Converter booleanConverter = getConverter(TypeDescriptor.valueOf(Boolean.class)); + Converter instantConverter = getConverter(TypeDescriptor.valueOf(Instant.class)); + Converter urlConverter = getConverter(TypeDescriptor.valueOf(URL.class)); + Converter collectionStringConverter = getConverter( + TypeDescriptor.collection(Collection.class, TypeDescriptor.valueOf(String.class))); + + Map> 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 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 jwtValidator = this.jwtValidatorFactory.apply(clientRegistration); - jwtDecoder.setJwtValidator(jwtValidator); + jwtDecoder.setJwtValidator(this.jwtValidatorFactory.apply(clientRegistration)); + Converter, Map> claimTypeConverter = + this.claimTypeConverterFactory.apply(clientRegistration); + if (claimTypeConverter != null) { + jwtDecoder.setClaimSetConverter(claimTypeConverter); + } return jwtDecoder; }); } @@ -163,4 +210,16 @@ public final void setJwsAlgorithmResolver(Function, Map>> claimTypeConverterFactory) { + Assert.notNull(claimTypeConverterFactory, "claimTypeConverterFactory cannot be null"); + this.claimTypeConverterFactory = claimTypeConverterFactory; + } } diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/ReactiveOidcIdTokenDecoderFactory.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/ReactiveOidcIdTokenDecoderFactory.java index d5b65722be5..ff51b5a82b5 100644 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/ReactiveOidcIdTokenDecoderFactory.java +++ b/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.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> DEFAULT_CLAIM_TYPE_CONVERTER = + new ClaimTypeConverter(createDefaultClaimTypeConverters()); private final Map jwtDecoders = new ConcurrentHashMap<>(); private Function> jwtValidatorFactory = OidcIdTokenValidator::new; private Function jwsAlgorithmResolver = clientRegistration -> SignatureAlgorithm.RS256; + private Function, Map>> 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> createDefaultClaimTypeConverters() { + Converter booleanConverter = getConverter(TypeDescriptor.valueOf(Boolean.class)); + Converter instantConverter = getConverter(TypeDescriptor.valueOf(Instant.class)); + Converter urlConverter = getConverter(TypeDescriptor.valueOf(URL.class)); + Converter collectionStringConverter = getConverter( + TypeDescriptor.collection(Collection.class, TypeDescriptor.valueOf(String.class))); + + Map> 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 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 jwtValidator = this.jwtValidatorFactory.apply(clientRegistration); - jwtDecoder.setJwtValidator(jwtValidator); + jwtDecoder.setJwtValidator(this.jwtValidatorFactory.apply(clientRegistration)); + Converter, Map> claimTypeConverter = + this.claimTypeConverterFactory.apply(clientRegistration); + if (claimTypeConverter != null) { + jwtDecoder.setClaimSetConverter(claimTypeConverter); + } return jwtDecoder; }); } @@ -163,4 +210,16 @@ public final void setJwsAlgorithmResolver(Function, Map>> claimTypeConverterFactory) { + Assert.notNull(claimTypeConverterFactory, "claimTypeConverterFactory cannot be null"); + this.claimTypeConverterFactory = claimTypeConverterFactory; + } } diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/userinfo/OidcReactiveOAuth2UserService.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/userinfo/OidcReactiveOAuth2UserService.java index e68ef51e9ac..fadc5f8b922 100644 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/userinfo/OidcReactiveOAuth2UserService.java +++ b/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> DEFAULT_CLAIM_TYPE_CONVERTER = + new ClaimTypeConverter(createDefaultClaimTypeConverters()); + private ReactiveOAuth2UserService oauth2UserService = new DefaultReactiveOAuth2UserService(); + private Function, Map>> 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> createDefaultClaimTypeConverters() { + Converter booleanConverter = getConverter(TypeDescriptor.valueOf(Boolean.class)); + Converter instantConverter = getConverter(TypeDescriptor.valueOf(Instant.class)); + + Map> 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 getConverter(TypeDescriptor targetDescriptor) { + final TypeDescriptor sourceDescriptor = TypeDescriptor.valueOf(Object.class); + return source -> ClaimConversionService.getSharedInstance().convert(source, sourceDescriptor, targetDescriptor); + } + @Override public Mono loadUser(OidcUserRequest userRequest) throws OAuth2AuthenticationException { Assert.notNull(userRequest, "userRequest cannot be null"); @@ -76,8 +113,10 @@ private Mono getUserInfo(OidcUserRequest userRequest) { 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 @@ private Mono getUserInfo(OidcUserRequest userRequest) { }); } + private Map convertClaims(Map claims, ClientRegistration clientRegistration) { + Converter, Map> claimTypeConverter = + this.claimTypeConverterFactory.apply(clientRegistration); + return claimTypeConverter != null ? + claimTypeConverter.convert(claims) : + DEFAULT_CLAIM_TYPE_CONVERTER.convert(claims); + } + public void setOauth2UserService(ReactiveOAuth2UserService 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, Map>> claimTypeConverterFactory) { + Assert.notNull(claimTypeConverterFactory, "claimTypeConverterFactory cannot be null"); + this.claimTypeConverterFactory = claimTypeConverterFactory; + } } diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/userinfo/OidcUserService.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/userinfo/OidcUserService.java index 859cc1b5341..d97532298db 100644 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/userinfo/OidcUserService.java +++ b/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.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 @@ */ public class OidcUserService implements OAuth2UserService { private static final String INVALID_USER_INFO_RESPONSE_ERROR_CODE = "invalid_user_info_response"; + private static final Converter, Map> DEFAULT_CLAIM_TYPE_CONVERTER = + new ClaimTypeConverter(createDefaultClaimTypeConverters()); private final Set userInfoScopes = new HashSet<>( Arrays.asList(OidcScopes.PROFILE, OidcScopes.EMAIL, OidcScopes.ADDRESS, OidcScopes.PHONE)); private OAuth2UserService oauth2UserService = new DefaultOAuth2UserService(); + private Function, Map>> 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> createDefaultClaimTypeConverters() { + Converter booleanConverter = getConverter(TypeDescriptor.valueOf(Boolean.class)); + Converter instantConverter = getConverter(TypeDescriptor.valueOf(Instant.class)); + + Map> 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 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 OidcUser loadUser(OidcUserRequest userRequest) throws OAuth2Authenticatio OidcUserInfo userInfo = null; if (this.shouldRetrieveUserInfo(userRequest)) { OAuth2User oauth2User = this.oauth2UserService.loadUser(userRequest); - userInfo = new OidcUserInfo(oauth2User.getAttributes()); + + Map claims; + Converter, Map> 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 final void setOauth2UserService(OAuth2UserService, Map>> claimTypeConverterFactory) { + Assert.notNull(claimTypeConverterFactory, "claimTypeConverterFactory cannot be null"); + this.claimTypeConverterFactory = claimTypeConverterFactory; + } } diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/authentication/OidcIdTokenDecoderFactoryTests.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/authentication/OidcIdTokenDecoderFactoryTests.java index b8da6af4af7..b2f22f22322 100644 --- a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/authentication/OidcIdTokenDecoderFactoryTests.java +++ b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/authentication/OidcIdTokenDecoderFactoryTests.java @@ -17,15 +17,20 @@ 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 void setUp() { this.idTokenDecoderFactory = new OidcIdTokenDecoderFactory(); } + @Test + public void createDefaultClaimTypeConvertersWhenCalledThenDefaultsAreCorrect() { + Map> 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 void setJwsAlgorithmResolverWhenNullThenThrowIllegalArgumentException() { .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 void createDecoderWhenCustomJwsAlgorithmResolverSetThenApplied() { verify(customJwsAlgorithmResolver).apply(same(clientRegistration)); } + + @Test + public void createDecoderWhenCustomClaimTypeConverterFactorySetThenApplied() { + Function, Map>> 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)); + } } diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/authentication/ReactiveOidcIdTokenDecoderFactoryTests.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/authentication/ReactiveOidcIdTokenDecoderFactoryTests.java index f1d15284e3b..388ba79c9aa 100644 --- a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/authentication/ReactiveOidcIdTokenDecoderFactoryTests.java +++ b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/authentication/ReactiveOidcIdTokenDecoderFactoryTests.java @@ -17,15 +17,20 @@ 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 void setUp() { this.idTokenDecoderFactory = new ReactiveOidcIdTokenDecoderFactory(); } + @Test + public void createDefaultClaimTypeConvertersWhenCalledThenDefaultsAreCorrect() { + Map> 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 void setJwsAlgorithmResolverWhenNullThenThrowIllegalArgumentException() { .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 void createDecoderWhenCustomJwsAlgorithmResolverSetThenApplied() { verify(customJwsAlgorithmResolver).apply(same(clientRegistration)); } + + @Test + public void createDecoderWhenCustomClaimTypeConverterFactorySetThenApplied() { + Function, Map>> 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)); + } } diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/userinfo/OidcReactiveOAuth2UserServiceTests.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/userinfo/OidcReactiveOAuth2UserServiceTests.java index 15d9574aae4..cf800b5a605 100644 --- a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/userinfo/OidcReactiveOAuth2UserServiceTests.java +++ b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/userinfo/OidcReactiveOAuth2UserServiceTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-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.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.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.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 void setup() { this.userService.setOauth2UserService(this.oauth2UserService); } + @Test + public void createDefaultClaimTypeConvertersWhenCalledThenDefaultsAreCorrect() { + Map> 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 void loadUserWhenOAuth2UserAndUser() { assertThat(this.userService.loadUser(userRequest()).block().getName()).isEqualTo("rob"); } + @Test + public void loadUserWhenCustomClaimTypeConverterFactorySetThenApplied() { + Map 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, Map>> 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); } diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/userinfo/OidcUserServiceTests.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/userinfo/OidcUserServiceTests.java index 15d4015a5e4..b562099f6df 100644 --- a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/userinfo/OidcUserServiceTests.java +++ b/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.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.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.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 void cleanup() throws Exception { this.server.shutdown(); } + @Test + public void createDefaultClaimTypeConvertersWhenCalledThenDefaultsAreCorrect() { + Map> 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 void loadUserWhenAuthenticationMethodFormSuccessResponseThenHttpMethodPos 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, Map>> 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) diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/ClaimAccessor.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/ClaimAccessor.java index 90280c2235b..afc3000b42b 100644 --- a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/ClaimAccessor.java +++ b/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 @@ default Boolean containsClaim(String claim) { * @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 @@ default String getClaimAsString(String claim) { * @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 @@ default Boolean getClaimAsBoolean(String claim) { * @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 @@ default Instant getClaimAsInstant(String claim) { * @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 @@ default URL getClaimAsURL(String claim) { * @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 getClaimAsMap(String claim) { - if (!this.containsClaim(claim) || !Map.class.isAssignableFrom(this.getClaims().get(claim).getClass())) { + if (!containsClaim(claim)) { return null; } - Map 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 convertedValue = (Map) 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 @@ default Map getClaimAsMap(String claim) { * @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 getClaimAsStringList(String claim) { - if (!this.containsClaim(claim) || !List.class.isAssignableFrom(this.getClaims().get(claim).getClass())) { + if (!containsClaim(claim)) { return null; } - List 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 convertedValue = (List) 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; } } diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/converter/ClaimConversionService.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/converter/ClaimConversionService.java new file mode 100644 index 00000000000..a8715375346 --- /dev/null +++ b/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()); + } +} diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/converter/ClaimTypeConverter.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/converter/ClaimTypeConverter.java new file mode 100644 index 00000000000..098cb86a01b --- /dev/null +++ b/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> { + private final Map> 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> 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 convert(Map claims) { + if (CollectionUtils.isEmpty(claims)) { + return claims; + } + + Map 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; + } +} diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/converter/ObjectToBooleanConverter.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/converter/ObjectToBooleanConverter.java new file mode 100644 index 00000000000..82c22433088 --- /dev/null +++ b/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 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()); + } +} diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/converter/ObjectToInstantConverter.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/converter/ObjectToInstantConverter.java new file mode 100644 index 00000000000..65ddaae9d5c --- /dev/null +++ b/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 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; + } +} diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/converter/ObjectToListStringConverter.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/converter/ObjectToListStringConverter.java new file mode 100644 index 00000000000..54564b0c606 --- /dev/null +++ b/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 getConvertibleTypes() { + Set 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()); + } +} diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/converter/ObjectToMapStringObjectConverter.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/converter/ObjectToMapStringObjectConverter.java new file mode 100644 index 00000000000..6db09f9cf17 --- /dev/null +++ b/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 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 result = new HashMap<>(); + sourceMap.forEach((k, v) -> result.put(k.toString(), v)); + return result; + } +} diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/converter/ObjectToStringConverter.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/converter/ObjectToStringConverter.java new file mode 100644 index 00000000000..8a73b71e749 --- /dev/null +++ b/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 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(); + } +} diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/converter/ObjectToURLConverter.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/converter/ObjectToURLConverter.java new file mode 100644 index 00000000000..f24020a3788 --- /dev/null +++ b/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 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; + } +} diff --git a/oauth2/oauth2-core/src/test/java/org/springframework/security/oauth2/core/converter/ClaimConversionServiceTests.java b/oauth2/oauth2-core/src/test/java/org/springframework/security/oauth2/core/converter/ClaimConversionServiceTests.java new file mode 100644 index 00000000000..39a41fd33e9 --- /dev/null +++ b/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 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 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 mapStringObject = new HashMap() { + { + put("key1", "value1"); + put("key2", "value2"); + put("key3", "value3"); + } + }; + assertThat(this.conversionService.convert(mapStringObject, Map.class)).isSameAs(mapStringObject); + } + + @Test + public void convertMapStringObjectWhenMapIntegerObjectThenConverts() { + Map mapStringObject = new HashMap() { + { + put("1", "value1"); + put("2", "value2"); + put("3", "value3"); + } + }; + Map mapIntegerObject = new HashMap() { + { + put(1, "value1"); + put(2, "value2"); + put(3, "value3"); + } + }; + assertThat(this.conversionService.convert(mapIntegerObject, Map.class)).isEqualTo(mapStringObject); + } + + @Test + public void convertMapStringObjectWhenNotConvertibleThenReturnNull() { + List notConvertibleList = Lists.list("1", "2", "3", "4"); + assertThat(this.conversionService.convert(notConvertibleList, Map.class)).isNull(); + } +} diff --git a/oauth2/oauth2-core/src/test/java/org/springframework/security/oauth2/core/converter/ClaimTypeConverterTests.java b/oauth2/oauth2-core/src/test/java/org/springframework/security/oauth2/core/converter/ClaimTypeConverterTests.java new file mode 100644 index 00000000000..fee193f8e4e --- /dev/null +++ b/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 stringConverter = getConverter(TypeDescriptor.valueOf(String.class)); + Converter booleanConverter = getConverter(TypeDescriptor.valueOf(Boolean.class)); + Converter instantConverter = getConverter(TypeDescriptor.valueOf(Instant.class)); + Converter urlConverter = getConverter(TypeDescriptor.valueOf(URL.class)); + Converter collectionStringConverter = getConverter( + TypeDescriptor.collection(Collection.class, TypeDescriptor.valueOf(String.class))); + Converter listStringConverter = getConverter( + TypeDescriptor.collection(List.class, TypeDescriptor.valueOf(String.class))); + Converter mapStringObjectConverter = getConverter( + TypeDescriptor.map(Map.class, TypeDescriptor.valueOf(String.class), TypeDescriptor.valueOf(Object.class))); + + Map> 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 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> claimTypeConverters = new HashMap<>(); + claimTypeConverters.put("claim1", null); + assertThatThrownBy(() -> new ClaimTypeConverter(claimTypeConverters)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void convertWhenClaimsEmptyThenReturnSame() { + Map 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 listNumber = Lists.list(1, 2, 3, 4); + List listString = Lists.list("1", "2", "3", "4"); + Map mapIntegerObject = new HashMap<>(); + mapIntegerObject.put(1, "value1"); + Map mapStringObject = new HashMap<>(); + mapStringObject.put("1", "value1"); + + Map 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 listString = Lists.list("1", "2", "3", "4"); + Map mapStringObject = new HashMap<>(); + mapStringObject.put("1", "value1"); + + Map 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 claims = new HashMap<>(); + claims.put("claim1", "value1"); + + claims = this.claimTypeConverter.convert(claims); + + assertThat(claims.get("claim1")).isSameAs("value1"); + } +} diff --git a/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/MappedJwtClaimSetConverter.java b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/MappedJwtClaimSetConverter.java index 7550c3a8630..79d5f6d5121 100644 --- a/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/MappedJwtClaimSetConverter.java +++ b/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,43 @@ package org.springframework.security.oauth2.jwt; +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> { - - private static final Converter> AUDIENCE_CONVERTER = new AudienceConverter(); - private static final Converter ISSUER_CONVERTER = new IssuerConverter(); - private static final Converter STRING_CONVERTER = new StringConverter(); - private static final Converter TEMPORAL_CONVERTER = new InstantConverter(); - - private final Map> claimConverters; +public final class MappedJwtClaimSetConverter implements Converter, Map> { + private final Map> claimTypeConverters; + private final Converter, Map> 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> claimConverters) { - Assert.notNull(claimConverters, "claimConverters cannot be null"); - this.claimConverters = new HashMap<>(claimConverters); + public MappedJwtClaimSetConverter(Map> claimTypeConverters) { + Assert.notNull(claimTypeConverters, "claimTypeConverters cannot be null"); + this.claimTypeConverters = claimTypeConverters; + this.delegate = new ClaimTypeConverter(claimTypeConverters); } /** @@ -78,29 +74,71 @@ public MappedJwtClaimSetConverter(Map> claimConvert * Collections.singletonMap(JwtClaimNames.SUB, new UserDetailsServiceJwtSubjectConverter())); * * - * 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> claimConverters) { - Assert.notNull(claimConverters, "claimConverters cannot be null"); + public static MappedJwtClaimSetConverter withDefaults(Map> claimTypeConverters) { + Assert.notNull(claimTypeConverters, "claimTypeConverters cannot be null"); + + Converter stringConverter = getConverter(TypeDescriptor.valueOf(String.class)); + Converter collectionStringConverter = getConverter( + TypeDescriptor.collection(Collection.class, TypeDescriptor.valueOf(String.class))); Map> 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 getConverter(TypeDescriptor targetDescriptor) { + final TypeDescriptor sourceDescriptor = TypeDescriptor.valueOf(Object.class); + return source -> ClaimConversionService.getSharedInstance().convert(source, sourceDescriptor, targetDescriptor); + } + + private static Instant convertInstant(Object source) { + if (source == null) { + return null; + } + final TypeDescriptor objectType = TypeDescriptor.valueOf(Object.class); + final TypeDescriptor instantType = TypeDescriptor.valueOf(Instant.class); + Instant result = (Instant) ClaimConversionService.getSharedInstance().convert(source, objectType, instantType); + 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; + } + final TypeDescriptor objectType = TypeDescriptor.valueOf(Object.class); + final TypeDescriptor urlType = TypeDescriptor.valueOf(URL.class); + URL result = (URL) ClaimConversionService.getSharedInstance().convert(source, objectType, urlType); + 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); + } + } + final TypeDescriptor stringType = TypeDescriptor.valueOf(String.class); + return (String) ClaimConversionService.getSharedInstance().convert(source, objectType, stringType); + } + /** * {@inheritDoc} */ @@ -108,17 +146,10 @@ public MappedJwtClaimSetConverter(Map> claimConvert public Map convert(Map claims) { Assert.notNull(claims, "claims cannot be null"); - Map mappedClaims = new HashMap<>(claims); + Map mappedClaims = this.delegate.convert(claims); - for (Map.Entry> entry : this.claimConverters.entrySet()) { - String claimName = entry.getKey(); - Converter 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 Map convert(Map claims) { return mappedClaims; } - /** - * Coerces an Audience claim - * into a {@link Collection}, ignoring null values, and throwing an error if its coercion efforts fail. - */ - private static class AudienceConverter implements Converter> { - - @Override - public Collection 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 Issuer claim - * into a {@link URL}, ignoring null values, and throwing an error if its coercion efforts fail. - */ - private static class IssuerConverter implements Converter { - - @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 { - @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 removeClaims(Map 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 { - @Override - public String convert(Object source) { - if (source == null) { - return null; - } - - return source.toString(); - } + private Map addClaims(Map claims) { + Map 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; } }