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 <jgresock@gmail.com>
This closes #7468.
This commit is contained in:
exceptionfactory 2023-07-11 12:43:10 -05:00 committed by Joe Gresock
parent 437995b75a
commit 95bb23d403
No known key found for this signature in database
GPG Key ID: 37F5B9B6E258C8B7
6 changed files with 292 additions and 8 deletions

View File

@ -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.registration.StandardClientRegistrationProvider;
import org.apache.nifi.web.security.oidc.revocation.StandardTokenRevocationResponseClient; 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.revocation.TokenRevocationResponseClient;
import org.apache.nifi.web.security.oidc.userinfo.StandardOidcUserService;
import org.apache.nifi.web.security.oidc.web.authentication.OidcAuthenticationSuccessHandler; import org.apache.nifi.web.security.oidc.web.authentication.OidcAuthenticationSuccessHandler;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cache.caffeine.CaffeineCache; import org.springframework.cache.caffeine.CaffeineCache;
@ -296,7 +297,10 @@ public class OidcSecurityConfiguration {
*/ */
@Bean @Bean
public OidcUserService oidcUserService() { public OidcUserService oidcUserService() {
final OidcUserService oidcUserService = new OidcUserService(); final StandardOidcUserService oidcUserService = new StandardOidcUserService(
getUserClaimNames(),
IdentityMappingUtil.getIdentityMappings(properties)
);
final DefaultOAuth2UserService userService = new DefaultOAuth2UserService(); final DefaultOAuth2UserService userService = new DefaultOAuth2UserService();
userService.setRestOperations(oidcRestOperations()); userService.setRestOperations(oidcRestOperations());
oidcUserService.setOauth2UserService(userService); oidcUserService.setOauth2UserService(userService);
@ -468,9 +472,7 @@ public class OidcSecurityConfiguration {
} }
private OidcAuthenticationSuccessHandler getAuthenticationSuccessHandler() { private OidcAuthenticationSuccessHandler getAuthenticationSuccessHandler() {
final List<String> userClaimNames = new ArrayList<>(); final List<String> userClaimNames = getUserClaimNames();
userClaimNames.add(properties.getOidcClaimIdentifyingUser());
userClaimNames.addAll(properties.getOidcFallbackClaimsIdentifyingUser());
return new OidcAuthenticationSuccessHandler( return new OidcAuthenticationSuccessHandler(
bearerTokenProvider, bearerTokenProvider,
@ -480,4 +482,11 @@ public class OidcSecurityConfiguration {
properties.getOidcClaimGroups() properties.getOidcClaimGroups()
); );
} }
private List<String> getUserClaimNames() {
final List<String> userClaimNames = new ArrayList<>();
userClaimNames.add(properties.getOidcClaimIdentifyingUser());
userClaimNames.addAll(properties.getOidcFallbackClaimsIdentifyingUser());
return userClaimNames;
}
} }

View File

@ -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.client.registration.ClientRegistration;
import org.springframework.security.oauth2.core.AuthorizationGrantType; import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.core.ClientAuthenticationMethod; 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.security.oauth2.core.oidc.OidcScopes;
import org.springframework.web.client.RestOperations; import org.springframework.web.client.RestOperations;
@ -85,8 +86,6 @@ public class StandardClientRegistrationProvider implements ClientRegistrationPro
final List<String> additionalScopes = properties.getOidcAdditionalScopes(); final List<String> additionalScopes = properties.getOidcAdditionalScopes();
scope.addAll(additionalScopes); scope.addAll(additionalScopes);
final String userNameAttributeName = properties.getOidcClaimIdentifyingUser();
return ClientRegistration.withRegistrationId(OidcRegistrationProperty.REGISTRATION_ID.getProperty()) return ClientRegistration.withRegistrationId(OidcRegistrationProperty.REGISTRATION_ID.getProperty())
.clientId(clientId) .clientId(clientId)
.clientSecret(clientSecret) .clientSecret(clientSecret)
@ -99,7 +98,8 @@ public class StandardClientRegistrationProvider implements ClientRegistrationPro
.providerConfigurationMetadata(configurationMetadata) .providerConfigurationMetadata(configurationMetadata)
.redirectUri(REGISTRATION_REDIRECT_URI) .redirectUri(REGISTRATION_REDIRECT_URI)
.scope(scope) .scope(scope)
.userNameAttributeName(userNameAttributeName) // OpenID Connect 1.0 requires the sub claim and other components handle application username mapping
.userNameAttributeName(IdTokenClaimNames.SUB)
.clientAuthenticationMethod(clientAuthenticationMethod) .clientAuthenticationMethod(clientAuthenticationMethod)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.build(); .build();

View File

@ -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<? extends GrantedAuthority> 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;
}
}

View File

@ -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<String> userClaimNames;
private final List<IdentityMapping> 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<String> userClaimNames, final List<IdentityMapping> 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<String> 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);
});
}
}

View File

@ -63,7 +63,7 @@ class StandardClientRegistrationProviderTest {
private static final String CLIENT_SECRET = "client-secret"; 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<String> EXPECTED_SCOPES = new LinkedHashSet<>(Arrays.asList(OidcScopes.OPENID, OidcScopes.EMAIL, OidcScopes.PROFILE)); private static final Set<String> EXPECTED_SCOPES = new LinkedHashSet<>(Arrays.asList(OidcScopes.OPENID, OidcScopes.EMAIL, OidcScopes.PROFILE));

View File

@ -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<String, Object> claims = getClaims(userNameClaim);
final OidcIdToken idToken = new OidcIdToken(ID_TOKEN, issuedAt, expiresAt, claims);
return new OidcUserRequest(clientRegistration, accessToken, idToken);
}
Map<String, Object> getClaims(final String userNameClaim) {
final Map<String, Object> 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);
}
}