From 95bb23d40317c521f003e9858f4229ca9eb36865 Mon Sep 17 00:00:00 2001 From: exceptionfactory Date: Tue, 11 Jul 2023 12:43:10 -0500 Subject: [PATCH] NIFI-11781 Corrected OIDC Claim Identity Processing - Added StandardOidcUserService supporting fallback claim names - Updated StandardClientRegistrationProvider to use standard Subject claim - Updated OIDC Security Configuration to use customized OidcUserService for claim handling Signed-off-by: Joe Gresock This closes #7468. --- .../OidcSecurityConfiguration.java | 17 ++- .../StandardClientRegistrationProvider.java | 6 +- .../oidc/userinfo/StandardOidcUser.java | 57 +++++++ .../userinfo/StandardOidcUserService.java | 75 +++++++++ ...tandardClientRegistrationProviderTest.java | 2 +- .../userinfo/StandardOidcUserServiceTest.java | 143 ++++++++++++++++++ 6 files changed, 292 insertions(+), 8 deletions(-) create mode 100644 nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/userinfo/StandardOidcUser.java create mode 100644 nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/userinfo/StandardOidcUserService.java create mode 100644 nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/oidc/userinfo/StandardOidcUserServiceTest.java diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/configuration/OidcSecurityConfiguration.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/configuration/OidcSecurityConfiguration.java index 850028c3ca..fcc3c87f29 100644 --- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/configuration/OidcSecurityConfiguration.java +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/configuration/OidcSecurityConfiguration.java @@ -50,6 +50,7 @@ import org.apache.nifi.web.security.oidc.registration.DisabledClientRegistration import org.apache.nifi.web.security.oidc.registration.StandardClientRegistrationProvider; import org.apache.nifi.web.security.oidc.revocation.StandardTokenRevocationResponseClient; import org.apache.nifi.web.security.oidc.revocation.TokenRevocationResponseClient; +import org.apache.nifi.web.security.oidc.userinfo.StandardOidcUserService; import org.apache.nifi.web.security.oidc.web.authentication.OidcAuthenticationSuccessHandler; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.cache.caffeine.CaffeineCache; @@ -296,7 +297,10 @@ public class OidcSecurityConfiguration { */ @Bean public OidcUserService oidcUserService() { - final OidcUserService oidcUserService = new OidcUserService(); + final StandardOidcUserService oidcUserService = new StandardOidcUserService( + getUserClaimNames(), + IdentityMappingUtil.getIdentityMappings(properties) + ); final DefaultOAuth2UserService userService = new DefaultOAuth2UserService(); userService.setRestOperations(oidcRestOperations()); oidcUserService.setOauth2UserService(userService); @@ -468,9 +472,7 @@ public class OidcSecurityConfiguration { } private OidcAuthenticationSuccessHandler getAuthenticationSuccessHandler() { - final List userClaimNames = new ArrayList<>(); - userClaimNames.add(properties.getOidcClaimIdentifyingUser()); - userClaimNames.addAll(properties.getOidcFallbackClaimsIdentifyingUser()); + final List userClaimNames = getUserClaimNames(); return new OidcAuthenticationSuccessHandler( bearerTokenProvider, @@ -480,4 +482,11 @@ public class OidcSecurityConfiguration { properties.getOidcClaimGroups() ); } + + private List getUserClaimNames() { + final List userClaimNames = new ArrayList<>(); + userClaimNames.add(properties.getOidcClaimIdentifyingUser()); + userClaimNames.addAll(properties.getOidcFallbackClaimsIdentifyingUser()); + return userClaimNames; + } } diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/registration/StandardClientRegistrationProvider.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/registration/StandardClientRegistrationProvider.java index f2368c3eeb..c24b106490 100644 --- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/registration/StandardClientRegistrationProvider.java +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/registration/StandardClientRegistrationProvider.java @@ -29,6 +29,7 @@ import org.apache.nifi.web.security.oidc.client.web.OidcRegistrationProperty; import org.springframework.security.oauth2.client.registration.ClientRegistration; import org.springframework.security.oauth2.core.AuthorizationGrantType; import org.springframework.security.oauth2.core.ClientAuthenticationMethod; +import org.springframework.security.oauth2.core.oidc.IdTokenClaimNames; import org.springframework.security.oauth2.core.oidc.OidcScopes; import org.springframework.web.client.RestOperations; @@ -85,8 +86,6 @@ public class StandardClientRegistrationProvider implements ClientRegistrationPro final List additionalScopes = properties.getOidcAdditionalScopes(); scope.addAll(additionalScopes); - final String userNameAttributeName = properties.getOidcClaimIdentifyingUser(); - return ClientRegistration.withRegistrationId(OidcRegistrationProperty.REGISTRATION_ID.getProperty()) .clientId(clientId) .clientSecret(clientSecret) @@ -99,7 +98,8 @@ public class StandardClientRegistrationProvider implements ClientRegistrationPro .providerConfigurationMetadata(configurationMetadata) .redirectUri(REGISTRATION_REDIRECT_URI) .scope(scope) - .userNameAttributeName(userNameAttributeName) + // OpenID Connect 1.0 requires the sub claim and other components handle application username mapping + .userNameAttributeName(IdTokenClaimNames.SUB) .clientAuthenticationMethod(clientAuthenticationMethod) .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) .build(); diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/userinfo/StandardOidcUser.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/userinfo/StandardOidcUser.java new file mode 100644 index 0000000000..3ce4fea527 --- /dev/null +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/userinfo/StandardOidcUser.java @@ -0,0 +1,57 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 + * + * http://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.apache.nifi.web.security.oidc.userinfo; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.core.oidc.OidcIdToken; +import org.springframework.security.oauth2.core.oidc.OidcUserInfo; +import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser; + +import java.util.Collection; +import java.util.Objects; + +/** + * Standard extension of Spring Security OIDC User supporting customized name configuration + */ +class StandardOidcUser extends DefaultOidcUser { + private final String name; + + /** + * Standard OIDC User constructor with required parameters and customized name value for identification + * + * @param authorities Granted Authorities + * @param idToken OIDC ID Token + * @param userInfo OIDC User Information + * @param nameAttributeKey Claim name that parent class uses to determine username for identification + * @param name Customized name identifying the user + */ + public StandardOidcUser( + final Collection authorities, + final OidcIdToken idToken, + final OidcUserInfo userInfo, + final String nameAttributeKey, + final String name + ) { + super(authorities, idToken, userInfo, nameAttributeKey); + this.name = Objects.requireNonNull(name, "Name required"); + } + + @Override + public String getName() { + return name; + } +} diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/userinfo/StandardOidcUserService.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/userinfo/StandardOidcUserService.java new file mode 100644 index 0000000000..96dc4ce107 --- /dev/null +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/userinfo/StandardOidcUserService.java @@ -0,0 +1,75 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 + * + * http://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.apache.nifi.web.security.oidc.userinfo; + +import org.apache.nifi.authorization.util.IdentityMapping; +import org.apache.nifi.authorization.util.IdentityMappingUtil; +import org.apache.nifi.web.security.oidc.OidcConfigurationException; +import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest; +import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserService; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +/** + * Standard extension of Spring Security OIDC User Service supporting customized identity claim mapping + */ +public class StandardOidcUserService extends OidcUserService { + private final List userClaimNames; + + private final List userIdentityMappings; + + /** + * Standard OIDC User Service constructor with arguments derived from application properties for mapping usernames + * + * @param userClaimNames Ordered list of Token Claim names from which to determine the user identity + * @param userIdentityMappings List of Identity Mapping rules for optional transformation of a user identity + */ + public StandardOidcUserService(final List userClaimNames, final List userIdentityMappings) { + this.userClaimNames = Objects.requireNonNull(userClaimNames, "User Claim Names required"); + this.userIdentityMappings = Objects.requireNonNull(userIdentityMappings, "User Identity Mappings required"); + } + + /** + * Load User with user identity based on first available Token Claim found + * + * @param userRequest OIDC User Request information + * @return Standard OIDC User + * @throws OAuth2AuthenticationException Thrown on failures loading user information from Identity Provider + */ + @Override + public OidcUser loadUser(final OidcUserRequest userRequest) throws OAuth2AuthenticationException { + final OidcUser oidcUser = super.loadUser(userRequest); + final String userClaimName = getUserClaimName(oidcUser); + final String claim = oidcUser.getClaimAsString(userClaimName); + final String name = IdentityMappingUtil.mapIdentity(claim, userIdentityMappings); + return new StandardOidcUser(oidcUser.getAuthorities(), oidcUser.getIdToken(), oidcUser.getUserInfo(), userClaimName, name); + } + + private String getUserClaimName(final OidcUser oidcUser) { + final Optional userClaimNameFound = userClaimNames.stream() + .filter(oidcUser::hasClaim) + .findFirst(); + return userClaimNameFound.orElseThrow(() -> { + final String message = String.format("User Claim Name not found in configured Token Claims %s", userClaimNames); + return new OidcConfigurationException(message); + }); + } +} diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/oidc/registration/StandardClientRegistrationProviderTest.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/oidc/registration/StandardClientRegistrationProviderTest.java index 164cd7c58c..c478dfa094 100644 --- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/oidc/registration/StandardClientRegistrationProviderTest.java +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/oidc/registration/StandardClientRegistrationProviderTest.java @@ -63,7 +63,7 @@ class StandardClientRegistrationProviderTest { private static final String CLIENT_SECRET = "client-secret"; - private static final String USER_NAME_ATTRIBUTE_NAME = "email"; + private static final String USER_NAME_ATTRIBUTE_NAME = "sub"; private static final Set EXPECTED_SCOPES = new LinkedHashSet<>(Arrays.asList(OidcScopes.OPENID, OidcScopes.EMAIL, OidcScopes.PROFILE)); diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/oidc/userinfo/StandardOidcUserServiceTest.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/oidc/userinfo/StandardOidcUserServiceTest.java new file mode 100644 index 0000000000..51d02c3cc4 --- /dev/null +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/oidc/userinfo/StandardOidcUserServiceTest.java @@ -0,0 +1,143 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 + * + * http://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.apache.nifi.web.security.oidc.userinfo; + +import org.apache.nifi.authorization.util.IdentityMapping; +import org.apache.nifi.web.security.jwt.provider.SupportedClaim; +import org.apache.nifi.web.security.oidc.OidcConfigurationException; +import org.apache.nifi.web.security.oidc.client.web.OidcRegistrationProperty; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.oidc.OidcIdToken; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; + +import java.time.Instant; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.regex.Pattern; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class StandardOidcUserServiceTest { + private static final String REDIRECT_URI = "https://localhost:8443/nifi-api/callback"; + + private static final String AUTHORIZATION_URI = "http://localhost/authorize"; + + private static final String TOKEN_URI = "http://localhost/token"; + + private static final String CLIENT_ID = "client-id"; + + private static final String ACCESS_TOKEN = "access"; + + private static final String ID_TOKEN = "id"; + + private static final String USER_NAME_CLAIM = "email"; + + private static final String FALLBACK_CLAIM = "preferred_username"; + + private static final String MISSING_CLAIM = "missing"; + + private static final String SUBJECT = String.class.getSimpleName(); + + private static final String IDENTITY = Authentication.class.getSimpleName(); + + private static final String FIRST_GROUP = "$1"; + + private static final Pattern MATCH_PATTERN = Pattern.compile("(.*)"); + + private static final IdentityMapping UPPER_IDENTITY_MAPPING = new IdentityMapping( + IdentityMapping.Transform.UPPER.toString(), + MATCH_PATTERN, + FIRST_GROUP, + IdentityMapping.Transform.UPPER + ); + + private StandardOidcUserService service; + + @BeforeEach + void setService() { + service = new StandardOidcUserService( + Arrays.asList(USER_NAME_CLAIM, FALLBACK_CLAIM), + Collections.singletonList(UPPER_IDENTITY_MAPPING) + ); + } + + @Test + void testLoadUser() { + final OidcUserRequest userRequest = getUserRequest(USER_NAME_CLAIM); + final OidcUser oidcUser = service.loadUser(userRequest); + + assertNotNull(oidcUser); + assertEquals(IDENTITY.toUpperCase(), oidcUser.getName()); + } + + @Test + void testLoadUserFallbackClaim() { + final OidcUserRequest userRequest = getUserRequest(FALLBACK_CLAIM); + final OidcUser oidcUser = service.loadUser(userRequest); + + assertNotNull(oidcUser); + assertEquals(IDENTITY.toUpperCase(), oidcUser.getName()); + } + + @Test + void testLoadUserClaimNotFound() { + final OidcUserRequest userRequest = getUserRequest(MISSING_CLAIM); + + assertThrows(OidcConfigurationException.class, () -> service.loadUser(userRequest)); + } + + OidcUserRequest getUserRequest(final String userNameClaim) { + final ClientRegistration clientRegistration = getClientRegistrationBuilder().build(); + + final Instant issuedAt = Instant.now(); + final Instant expiresAt = Instant.MAX; + final OAuth2AccessToken accessToken = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, ACCESS_TOKEN, issuedAt, expiresAt); + + final Map claims = getClaims(userNameClaim); + + final OidcIdToken idToken = new OidcIdToken(ID_TOKEN, issuedAt, expiresAt, claims); + return new OidcUserRequest(clientRegistration, accessToken, idToken); + } + + Map getClaims(final String userNameClaim) { + final Map claims = new LinkedHashMap<>(); + claims.put(SupportedClaim.SUBJECT.getClaim(), SUBJECT); + claims.put(SupportedClaim.ISSUED_AT.getClaim(), Instant.now()); + claims.put(SupportedClaim.EXPIRATION.getClaim(), Instant.MAX); + claims.put(userNameClaim, IDENTITY); + return claims; + } + + ClientRegistration.Builder getClientRegistrationBuilder() { + return ClientRegistration.withRegistrationId(OidcRegistrationProperty.REGISTRATION_ID.getProperty()) + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .clientId(CLIENT_ID) + .redirectUri(REDIRECT_URI) + .authorizationUri(AUTHORIZATION_URI) + .tokenUri(TOKEN_URI); + } +}