diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/core/oidc/http/converter/OidcUserInfoHttpMessageConverter.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/core/oidc/http/converter/OidcUserInfoHttpMessageConverter.java new file mode 100644 index 00000000..f74f5170 --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/core/oidc/http/converter/OidcUserInfoHttpMessageConverter.java @@ -0,0 +1,169 @@ +/* + * Copyright 2020 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.oidc.http.converter; + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.core.convert.TypeDescriptor; +import org.springframework.core.convert.converter.Converter; +import org.springframework.http.HttpInputMessage; +import org.springframework.http.HttpOutputMessage; +import org.springframework.http.MediaType; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.converter.GenericHttpMessageConverter; +import org.springframework.http.converter.HttpMessageNotWritableException; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.http.converter.AbstractHttpMessageConverter; +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.util.Assert; + +import java.util.HashMap; +import java.util.Map; + +/** + * A {@link HttpMessageConverter} for an {@link OidcUserInfo OIDC User Info Response}. + * + * @author Ido Salomon + * @see AbstractHttpMessageConverter + * @see OidcUserInfo + * @since 0.1.1 + */ +public class OidcUserInfoHttpMessageConverter extends AbstractHttpMessageConverter { + + private static final ParameterizedTypeReference> STRING_OBJECT_MAP = + new ParameterizedTypeReference>() { + }; + + private final GenericHttpMessageConverter jsonMessageConverter = HttpMessageConverters.getJsonMessageConverter(); + + private Converter, OidcUserInfo> oidcUserInfoConverter = new OidcUserInfoConverter(); + private Converter> oidcUserInfoParametersConverter = OidcUserInfo::getClaims; + + public OidcUserInfoHttpMessageConverter() { + super(MediaType.APPLICATION_JSON, new MediaType("application", "*+json")); + } + + @Override + protected boolean supports(Class clazz) { + return OidcUserInfo.class.isAssignableFrom(clazz); + } + + @Override + @SuppressWarnings("unchecked") + protected OidcUserInfo readInternal(Class clazz, HttpInputMessage inputMessage) + throws HttpMessageNotReadableException { + try { + Map oidcUserInfoParameters = + (Map) this.jsonMessageConverter.read(STRING_OBJECT_MAP.getType(), null, inputMessage); + return this.oidcUserInfoConverter.convert(oidcUserInfoParameters); + } catch (Exception ex) { + throw new HttpMessageNotReadableException( + "An error occurred reading the OIDC User Info: " + ex.getMessage(), ex, inputMessage); + } + } + + @Override + protected void writeInternal(OidcUserInfo oidcUserInfo, HttpOutputMessage outputMessage) + throws HttpMessageNotWritableException { + try { + Map oidcUserInfoResponseParameters = + this.oidcUserInfoParametersConverter.convert(oidcUserInfo); + this.jsonMessageConverter.write( + oidcUserInfoResponseParameters, + STRING_OBJECT_MAP.getType(), + MediaType.APPLICATION_JSON, + outputMessage + ); + } catch (Exception ex) { + throw new HttpMessageNotWritableException( + "An error occurred writing the OIDC User Info response: " + ex.getMessage(), ex); + } + } + + /** + * Sets the {@link Converter} used for converting the OIDC User Info parameters + * to an {@link OidcUserInfo}. + * + * @param oidcUserInfoConverter the {@link Converter} used for converting to an + * {@link OidcUserInfo} + */ + public final void setOidcUserInfoConverter(Converter, OidcUserInfo> oidcUserInfoConverter) { + Assert.notNull(oidcUserInfoConverter, "oidcUserInfoConverter cannot be null"); + this.oidcUserInfoConverter = oidcUserInfoConverter; + } + + /** + * Sets the {@link Converter} used for converting the {@link OidcUserInfo} to a + * {@code Map} representation of the OIDC User Info. + * + * @param oidcUserInfoParametersConverter the {@link Converter} used for converting to a + * {@code Map} representation of the OIDC User Info + */ + public final void setOidcUserInfoParametersConverter( + Converter> oidcUserInfoParametersConverter) { + Assert.notNull(oidcUserInfoParametersConverter, "oidcUserInfoParametersConverter cannot be null"); + this.oidcUserInfoParametersConverter = oidcUserInfoParametersConverter; + } + + private static final class OidcUserInfoConverter implements Converter, OidcUserInfo> { + private static final ClaimConversionService CLAIM_CONVERSION_SERVICE = ClaimConversionService.getSharedInstance(); + private static final TypeDescriptor OBJECT_TYPE_DESCRIPTOR = TypeDescriptor.valueOf(Object.class); + private static final TypeDescriptor BOOLEAN_TYPE_DESCRIPTOR = TypeDescriptor.valueOf(Boolean.class); + private static final TypeDescriptor STRING_TYPE_DESCRIPTOR = TypeDescriptor.valueOf(String.class); + private final ClaimTypeConverter claimTypeConverter; + + private OidcUserInfoConverter() { + Converter stringConverter = getConverter(STRING_TYPE_DESCRIPTOR); + Converter booleanConverter = getConverter(BOOLEAN_TYPE_DESCRIPTOR); + + Map> claimConverters = new HashMap<>(); + claimConverters.put(StandardClaimNames.SUB, stringConverter); + claimConverters.put(StandardClaimNames.PROFILE, stringConverter); + claimConverters.put(StandardClaimNames.ADDRESS, stringConverter); + claimConverters.put(StandardClaimNames.BIRTHDATE, stringConverter); + claimConverters.put(StandardClaimNames.EMAIL, stringConverter); + claimConverters.put(StandardClaimNames.EMAIL_VERIFIED, booleanConverter); + claimConverters.put(StandardClaimNames.NAME, stringConverter); + claimConverters.put(StandardClaimNames.GIVEN_NAME, stringConverter); + claimConverters.put(StandardClaimNames.MIDDLE_NAME, stringConverter); + claimConverters.put(StandardClaimNames.FAMILY_NAME, stringConverter); + claimConverters.put(StandardClaimNames.NICKNAME, stringConverter); + claimConverters.put(StandardClaimNames.PREFERRED_USERNAME, stringConverter); + claimConverters.put(StandardClaimNames.LOCALE, stringConverter); + claimConverters.put(StandardClaimNames.GENDER, stringConverter); + claimConverters.put(StandardClaimNames.PHONE_NUMBER, stringConverter); + claimConverters.put(StandardClaimNames.PHONE_NUMBER_VERIFIED, stringConverter); + claimConverters.put(StandardClaimNames.PICTURE, stringConverter); + claimConverters.put(StandardClaimNames.ZONEINFO, stringConverter); + claimConverters.put(StandardClaimNames.WEBSITE, stringConverter); + claimConverters.put(StandardClaimNames.UPDATED_AT, stringConverter); + + this.claimTypeConverter = new ClaimTypeConverter(claimConverters); + } + + @Override + public OidcUserInfo convert(Map source) { + Map parsedClaims = this.claimTypeConverter.convert(source); + return new OidcUserInfo(parsedClaims); + } + + private static Converter getConverter(TypeDescriptor targetDescriptor) { + return (source) -> CLAIM_CONVERSION_SERVICE.convert(source, OBJECT_TYPE_DESCRIPTOR, targetDescriptor); + } + } +} diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/DefaultUserInfoClaimsMapper.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/DefaultUserInfoClaimsMapper.java new file mode 100644 index 00000000..4d3d4a2c --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/DefaultUserInfoClaimsMapper.java @@ -0,0 +1,11 @@ +package org.springframework.security.oauth2.server.authorization.oidc; + +import org.springframework.security.oauth2.core.oidc.OidcUserInfo; + +public class DefaultUserInfoClaimsMapper implements UserInfoClaimsMapper { + + public OidcUserInfo map(Object principal) { + return null; // TODO + } + +} diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/UserInfoClaimsMapper.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/UserInfoClaimsMapper.java new file mode 100644 index 00000000..48590a2d --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/UserInfoClaimsMapper.java @@ -0,0 +1,9 @@ +package org.springframework.security.oauth2.server.authorization.oidc; + +import org.springframework.security.oauth2.core.oidc.OidcUserInfo; + +public interface UserInfoClaimsMapper { + + OidcUserInfo map(Object principal); + +} diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/web/OidcUserInfoEndpointFilter.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/web/OidcUserInfoEndpointFilter.java new file mode 100644 index 00000000..fb39e2cb --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/web/OidcUserInfoEndpointFilter.java @@ -0,0 +1,143 @@ +/* + * Copyright 2020 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.server.authorization.oidc.web; + +import java.io.IOException; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.server.ServletServerHttpResponse; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +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.http.converter.OidcUserInfoHttpMessageConverter; +import org.springframework.security.oauth2.server.authorization.oidc.UserInfoClaimsMapper; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.security.web.util.matcher.OrRequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatcher; +import org.springframework.web.filter.OncePerRequestFilter; + +/** + * A {@code Filter} that processes OpenID User Info requests. + * + * @author Ido Salomon + * @see OidcUserInfo + * @see 5.3.1. UserInfo Request + * @since 0.1.1 + */ +public class OidcUserInfoEndpointFilter extends OncePerRequestFilter { + + /** + * The default endpoint {@code URI} for OpenID User Info requests. + */ + public static final String DEFAULT_OIDC_USER_INFO_ENDPOINT_URI = "/userinfo"; + + private final RequestMatcher requestMatcher; + private final OidcUserInfoHttpMessageConverter oidcUserInfoHttpMessageConverter = + new OidcUserInfoHttpMessageConverter(); + private final UserInfoClaimsMapper userInfoClaimsMapper; + + public OidcUserInfoEndpointFilter(UserInfoClaimsMapper userInfoClaimsMapper) { + AntPathRequestMatcher userInfoGetMatcher = new AntPathRequestMatcher( + DEFAULT_OIDC_USER_INFO_ENDPOINT_URI, + HttpMethod.GET.name() + ); + AntPathRequestMatcher userInfoPostMatcher = new AntPathRequestMatcher( + DEFAULT_OIDC_USER_INFO_ENDPOINT_URI, + HttpMethod.POST.name() + ); + this.requestMatcher = new OrRequestMatcher(userInfoGetMatcher, userInfoPostMatcher); + this.userInfoClaimsMapper = userInfoClaimsMapper; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + + if (!this.requestMatcher.matches(request)) { + filterChain.doFilter(request, response); + return; + } + + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + Object authenticationDetails = authentication.getDetails(); + Object principal = authentication.getPrincipal(); + OidcUserInfo oidcUserInfo = userInfoClaimsMapper.map(principal); + + if (authenticationDetails instanceof OAuth2AccessToken) { + oidcUserInfo = getUserInfoClaimsRequestedByScope(oidcUserInfo, ((OAuth2AccessToken) authenticationDetails).getScopes()); + } else { + oidcUserInfo = OidcUserInfo.builder() + .subject(oidcUserInfo.getSubject()) + .build(); + } + + ServletServerHttpResponse httpResponse = new ServletServerHttpResponse(response); + this.oidcUserInfoHttpMessageConverter.write( + oidcUserInfo, MediaType.APPLICATION_JSON, httpResponse); + } + + private OidcUserInfo getUserInfoClaimsRequestedByScope(OidcUserInfo userInfo, Set scopes) { + Set scopeRequestedClaimNames = getScopeRequestedClaimNames(scopes); + + Map scopeRequestedClaims = userInfo.getClaims().entrySet().stream() + .filter(claim -> scopeRequestedClaimNames.contains(claim.getKey())) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + + return new OidcUserInfo(scopeRequestedClaims); + } + + private Set getScopeRequestedClaimNames(Set scopes) { + Set scopeRequestedClaimNames = new HashSet<>(Arrays.asList(StandardClaimNames.SUB)); + Set profileClaimNames = new HashSet<>(Arrays.asList(StandardClaimNames.NAME, + StandardClaimNames.FAMILY_NAME, StandardClaimNames.GIVEN_NAME, StandardClaimNames.MIDDLE_NAME, + StandardClaimNames.NICKNAME, StandardClaimNames.PREFERRED_USERNAME, StandardClaimNames.PROFILE, + StandardClaimNames.PICTURE, StandardClaimNames.WEBSITE, StandardClaimNames.GENDER, + StandardClaimNames.BIRTHDATE, StandardClaimNames.ZONEINFO, StandardClaimNames.LOCALE, StandardClaimNames.UPDATED_AT)); + Set emailClaimNames = new HashSet<>(Arrays.asList(StandardClaimNames.EMAIL, StandardClaimNames.EMAIL_VERIFIED)); + String addressClaimName = StandardClaimNames.ADDRESS; + Set phoneClaimNames = new HashSet<>(Arrays.asList(StandardClaimNames.PHONE_NUMBER, StandardClaimNames.PHONE_NUMBER_VERIFIED)); + + if (scopes.contains(OidcScopes.ADDRESS)) { + scopeRequestedClaimNames.add(addressClaimName); + } + if (scopes.contains(OidcScopes.EMAIL)) { + scopeRequestedClaimNames.addAll(emailClaimNames); + } + if (scopes.contains(OidcScopes.PHONE)) { + scopeRequestedClaimNames.addAll(phoneClaimNames); + } + if (scopes.contains(OidcScopes.PROFILE)) { + scopeRequestedClaimNames.addAll(profileClaimNames); + } + + return scopeRequestedClaimNames; + } + +}