This is an automated email from the ASF dual-hosted git repository.

exceptionfactory pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/nifi.git


The following commit(s) were added to refs/heads/main by this push:
     new 0c34095245 NIFI-13016 Add groups claim mapping from OIDC token for 
Registry (#9566)
0c34095245 is described below

commit 0c3409524598173fb74bc79c1601e30cb9cd8778
Author: Matt Patrick <[email protected]>
AuthorDate: Thu Jan 23 22:06:24 2025 -0500

    NIFI-13016 Add groups claim mapping from OIDC token for Registry (#9566)
    
    Signed-off-by: David Handermann <[email protected]>
---
 nifi-registry/nifi-registry-assembly/pom.xml       |  1 +
 .../src/main/asciidoc/administration-guide.adoc    |  3 ++
 .../authorization/StandardManagedAuthorizer.java   | 39 ++++++++++++++++++----
 .../properties/NiFiRegistryProperties.java         | 11 ++++++
 .../main/resources/conf/nifi-registry.properties   |  1 +
 .../authentication/AuthenticationResponse.java     | 21 ++++++++++++
 .../IdentityAuthenticationProvider.java            |  9 ++++-
 .../authentication/jwt/JwtIdentityProvider.java    | 14 +++++---
 .../security/authentication/jwt/JwtService.java    | 34 +++++++++++++++----
 .../oidc/StandardOidcIdentityProvider.java         |  6 +++-
 10 files changed, 121 insertions(+), 18 deletions(-)

diff --git a/nifi-registry/nifi-registry-assembly/pom.xml 
b/nifi-registry/nifi-registry-assembly/pom.xml
index 7aa4033a05..3b02b89b39 100644
--- a/nifi-registry/nifi-registry-assembly/pom.xml
+++ b/nifi-registry/nifi-registry-assembly/pom.xml
@@ -215,6 +215,7 @@
         <nifi.registry.security.user.oidc.client.id />
         <nifi.registry.security.user.oidc.client.secret />
         <nifi.registry.security.user.oidc.preferred.jwsalgorithm />
+        
<nifi.registry.security.user.oidc.claim.groups>groups</nifi.registry.security.user.oidc.claim.groups>
 
         <!-- nifi.registry.properties: revision management properties -->
         
<nifi.registry.revisions.enabled>false</nifi.registry.revisions.enabled>
diff --git 
a/nifi-registry/nifi-registry-core/nifi-registry-docs/src/main/asciidoc/administration-guide.adoc
 
b/nifi-registry/nifi-registry-core/nifi-registry-docs/src/main/asciidoc/administration-guide.adoc
index deced57fc7..5cb8be13c3 100644
--- 
a/nifi-registry/nifi-registry-core/nifi-registry-docs/src/main/asciidoc/administration-guide.adoc
+++ 
b/nifi-registry/nifi-registry-core/nifi-registry-docs/src/main/asciidoc/administration-guide.adoc
@@ -282,6 +282,9 @@ If this value is `none`, NiFi will attempt to validate 
unsecured/plain tokens. O
 JSON Web Key (JWK) provided through the jwks_uri in the metadata found at the 
discovery URL
 |`nifi.registry.security.user.oidc.additional.scopes`                | Comma 
separated scopes that are sent to OpenID Connect Provider in addition to 
`openid` and `email`.
 |`nifi.registry.security.user.oidc.claim.identifying.user`           | Claim 
that identifies the authenticated user. The default value is `email`. Claim 
names may need to be requested using the 
`nifi.registry.security.user.oidc.additional.scopes` property
+|`nifi.registry.security.user.oidc.claim.groups`                     | Name of 
the ID token claim that contains an array of group names of which the
+user is a member. Application groups must be supplied from a User Group 
Provider with matching names in order for the
+authorization process to use ID token claim groups. The default value is 
`groups`.
 
|==================================================================================================================================================
 
 [[authorization]]
diff --git 
a/nifi-registry/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/security/authorization/StandardManagedAuthorizer.java
 
b/nifi-registry/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/security/authorization/StandardManagedAuthorizer.java
index 974a411316..39b592b8d7 100644
--- 
a/nifi-registry/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/security/authorization/StandardManagedAuthorizer.java
+++ 
b/nifi-registry/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/security/authorization/StandardManagedAuthorizer.java
@@ -37,7 +37,10 @@ import java.io.ByteArrayInputStream;
 import java.io.IOException;
 import java.io.StringWriter;
 import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.HashSet;
 import java.util.Set;
+import java.util.stream.Collectors;
 
 public class StandardManagedAuthorizer implements ManagedAuthorizer {
 
@@ -95,19 +98,29 @@ public class StandardManagedAuthorizer implements 
ManagedAuthorizer {
 
         final UserAndGroups userAndGroups = 
userGroupProvider.getUserAndGroups(request.getIdentity());
 
-        final User user = userAndGroups.getUser();
-        if (user == null) {
-            return AuthorizationResult.denied(String.format("Unknown user with 
identity '%s'.", request.getIdentity()));
-        }
+        // combine groups from incoming request with groups from UserAndGroups 
because the request may contain groups from
+        // an external identity provider and the membership may not be 
maintained within any of the UserGroupProviders
+        final Set<Group> userGroups = new HashSet<>();
+        userGroups.addAll(userAndGroups.getGroups() == null ? 
Collections.emptySet() : userAndGroups.getGroups());
+        userGroups.addAll(getGroups(request.getGroups()));
 
-        final Set<Group> userGroups = userAndGroups.getGroups();
-        if (policy.getUsers().contains(user.getIdentifier()) || 
containsGroup(userGroups, policy)) {
+        if (containsUser(userAndGroups.getUser(), policy) || 
containsGroup(userGroups, policy)) {
             return AuthorizationResult.approved();
         }
 
         return 
AuthorizationResult.denied(request.getExplanationSupplier().get());
     }
 
+    private Set<Group> getGroups(final Set<String> groupNames) {
+        if (groupNames == null || groupNames.isEmpty()) {
+            return Collections.emptySet();
+        }
+
+        return userGroupProvider.getGroups().stream()
+                .filter(group -> groupNames.contains(group.getName()))
+                .collect(Collectors.toSet());
+    }
+
     /**
      * Determines if the policy contains one of the user's groups.
      *
@@ -129,6 +142,20 @@ public class StandardManagedAuthorizer implements 
ManagedAuthorizer {
         return false;
     }
 
+    /**
+     * Determines if the policy contains the user's identifier.
+     *
+     * @param user the user
+     * @param policy the policy
+     * @return true if the user is non-null and the user's identifies is 
contained in the policy's users
+     */
+    private boolean containsUser(final User user, final AccessPolicy policy) {
+        if (user == null || policy.getUsers().isEmpty()) {
+            return false;
+        }
+        return policy.getUsers().contains(user.getIdentifier());
+    }
+
     @Override
     public String getFingerprint() throws AuthorizationAccessException {
         XMLStreamWriter writer = null;
diff --git 
a/nifi-registry/nifi-registry-core/nifi-registry-properties/src/main/java/org/apache/nifi/registry/properties/NiFiRegistryProperties.java
 
b/nifi-registry/nifi-registry-core/nifi-registry-properties/src/main/java/org/apache/nifi/registry/properties/NiFiRegistryProperties.java
index 83a3109087..fe024e4cf2 100644
--- 
a/nifi-registry/nifi-registry-core/nifi-registry-properties/src/main/java/org/apache/nifi/registry/properties/NiFiRegistryProperties.java
+++ 
b/nifi-registry/nifi-registry-core/nifi-registry-properties/src/main/java/org/apache/nifi/registry/properties/NiFiRegistryProperties.java
@@ -117,6 +117,7 @@ public class NiFiRegistryProperties extends 
ApplicationProperties {
     public static final String SECURITY_USER_OIDC_PREFERRED_JWSALGORITHM = 
"nifi.registry.security.user.oidc.preferred.jwsalgorithm";
     public static final String SECURITY_USER_OIDC_ADDITIONAL_SCOPES = 
"nifi.registry.security.user.oidc.additional.scopes";
     public static final String SECURITY_USER_OIDC_CLAIM_IDENTIFYING_USER = 
"nifi.registry.security.user.oidc.claim.identifying.user";
+    public static final String SECURITY_USER_OIDC_CLAIM_GROUPS = 
"nifi.registry.security.user.oidc.claim.groups";
 
     // Revision Management Properties
     public static final String REVISIONS_ENABLED = 
"nifi.registry.revisions.enabled";
@@ -481,6 +482,16 @@ public class NiFiRegistryProperties extends 
ApplicationProperties {
     public String getOidcClaimIdentifyingUser() {
         return getProperty(SECURITY_USER_OIDC_CLAIM_IDENTIFYING_USER, 
"email").trim();
     }
+    /**
+     * Returns the claim to be used to extract user groups from the OIDC 
payload.
+     * Claim must be requested by adding the scope for it.
+     * Default is 'groups'.
+     *
+     * @return The claim to be used to extract user groups.
+     */
+    public String getOidcClaimGroups() {
+        return getProperty(SECURITY_USER_OIDC_CLAIM_GROUPS, "groups").trim();
+    }
 
     /**
      * Returns the network interface list to use for HTTPS
diff --git 
a/nifi-registry/nifi-registry-core/nifi-registry-resources/src/main/resources/conf/nifi-registry.properties
 
b/nifi-registry/nifi-registry-core/nifi-registry-resources/src/main/resources/conf/nifi-registry.properties
index e674f3c82c..fbd5aa1de2 100644
--- 
a/nifi-registry/nifi-registry-core/nifi-registry-resources/src/main/resources/conf/nifi-registry.properties
+++ 
b/nifi-registry/nifi-registry-core/nifi-registry-resources/src/main/resources/conf/nifi-registry.properties
@@ -106,6 +106,7 @@ 
nifi.registry.security.user.oidc.read.timeout=${nifi.registry.security.user.oidc
 
nifi.registry.security.user.oidc.client.id=${nifi.registry.security.user.oidc.client.id}
 
nifi.registry.security.user.oidc.client.secret=${nifi.registry.security.user.oidc.client.secret}
 
nifi.registry.security.user.oidc.preferred.jwsalgorithm=${nifi.registry.security.user.oidc.preferred.jwsalgorithm}
+nifi.registry.security.user.oidc.claim.groups=${nifi.registry.security.user.oidc.claim.groups}
 
 # revision management #
 # This feature should remain disabled until a future NiFi release that 
supports the revision API changes
diff --git 
a/nifi-registry/nifi-registry-core/nifi-registry-security-api/src/main/java/org/apache/nifi/registry/security/authentication/AuthenticationResponse.java
 
b/nifi-registry/nifi-registry-core/nifi-registry-security-api/src/main/java/org/apache/nifi/registry/security/authentication/AuthenticationResponse.java
index b8eb721838..7edfdc0a97 100644
--- 
a/nifi-registry/nifi-registry-core/nifi-registry-security-api/src/main/java/org/apache/nifi/registry/security/authentication/AuthenticationResponse.java
+++ 
b/nifi-registry/nifi-registry-core/nifi-registry-security-api/src/main/java/org/apache/nifi/registry/security/authentication/AuthenticationResponse.java
@@ -17,6 +17,8 @@
 package org.apache.nifi.registry.security.authentication;
 
 import java.io.Serializable;
+import java.util.Collections;
+import java.util.Set;
 
 /**
  * Authentication response for a user login attempt.
@@ -27,6 +29,7 @@ public class AuthenticationResponse implements Serializable {
     private final String username;
     private final long expiration;
     private final String issuer;
+    private final Set<String> groups;
 
     /**
      * Creates an authentication response. The username and how long the 
authentication is valid in milliseconds
@@ -37,10 +40,24 @@ public class AuthenticationResponse implements Serializable 
{
      * @param issuer The issuer of the token
      */
     public AuthenticationResponse(final String identity, final String 
username, final long expiration, final String issuer) {
+        this(identity, username, expiration, issuer, Collections.emptySet());
+    }
+
+    /**
+     * Creates an authentication response. The username and how long the 
authentication is valid in milliseconds
+     *
+     * @param identity The user identity
+     * @param username The username
+     * @param expiration The expiration in milliseconds
+     * @param issuer The issuer of the token
+     * @param groups The user groups
+     */
+    public AuthenticationResponse(final String identity, final String 
username, final long expiration, final String issuer, final Set<String> groups) 
{
         this.identity = identity;
         this.username = username;
         this.expiration = expiration;
         this.issuer = issuer;
+        this.groups = groups;
     }
 
     public String getIdentity() {
@@ -64,6 +81,10 @@ public class AuthenticationResponse implements Serializable {
         return expiration;
     }
 
+    public Set<String> getGroups() {
+        return groups;
+    }
+
     @Override
     public boolean equals(Object o) {
         if (this == o) return true;
diff --git 
a/nifi-registry/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/security/authentication/IdentityAuthenticationProvider.java
 
b/nifi-registry/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/security/authentication/IdentityAuthenticationProvider.java
index c89a189961..d697e4bc97 100644
--- 
a/nifi-registry/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/security/authentication/IdentityAuthenticationProvider.java
+++ 
b/nifi-registry/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/security/authentication/IdentityAuthenticationProvider.java
@@ -36,6 +36,7 @@ import 
org.springframework.security.core.AuthenticationException;
 import java.util.Collections;
 import java.util.Set;
 import java.util.stream.Collectors;
+import java.util.stream.Stream;
 
 public class IdentityAuthenticationProvider implements AuthenticationProvider {
 
@@ -94,7 +95,7 @@ public class IdentityAuthenticationProvider implements 
AuthenticationProvider {
         return new AuthenticationSuccessToken(new NiFiUserDetails(
                 new StandardNiFiUser.Builder()
                         .identity(mappedIdentity)
-                        .groups(getUserGroups(mappedIdentity))
+                        .groups(getUserGroups(mappedIdentity, response))
                         .clientAddress(requestToken.getClientAddress())
                         .build()));
     }
@@ -112,6 +113,12 @@ public class IdentityAuthenticationProvider implements 
AuthenticationProvider {
         return getUserGroups(authorizer, identity);
     }
 
+    protected Set<String> getUserGroups(final String identity, 
AuthenticationResponse response) {
+        return Stream
+                .concat(getUserGroups(authorizer, identity).stream(), 
response.getGroups().stream())
+                .collect(Collectors.toSet());
+    }
+
     private static Set<String> getUserGroups(final Authorizer authorizer, 
final String userIdentity) {
         if (authorizer instanceof ManagedAuthorizer) {
             final ManagedAuthorizer managedAuthorizer = (ManagedAuthorizer) 
authorizer;
diff --git 
a/nifi-registry/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/security/authentication/jwt/JwtIdentityProvider.java
 
b/nifi-registry/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/security/authentication/jwt/JwtIdentityProvider.java
index e6d2dc2a21..ef9ead3e3a 100644
--- 
a/nifi-registry/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/security/authentication/jwt/JwtIdentityProvider.java
+++ 
b/nifi-registry/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/security/authentication/jwt/JwtIdentityProvider.java
@@ -16,6 +16,8 @@
  */
 package org.apache.nifi.registry.web.security.authentication.jwt;
 
+import io.jsonwebtoken.Claims;
+import io.jsonwebtoken.Jws;
 import io.jsonwebtoken.JwtException;
 import org.apache.nifi.registry.properties.NiFiRegistryProperties;
 import org.apache.nifi.registry.security.authentication.AuthenticationRequest;
@@ -34,6 +36,7 @@ import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Component;
 
+import java.util.Set;
 import java.util.concurrent.TimeUnit;
 
 @Component
@@ -61,16 +64,19 @@ public class JwtIdentityProvider extends 
BearerAuthIdentityProvider implements I
         }
 
         final Object credentials = authenticationRequest.getCredentials();
-        String jwtAuthToken = credentials != null && credentials instanceof 
String ? (String) credentials : null;
-
         if (credentials == null) {
             logger.info("JWT not found in authenticationRequest credentials, 
returning null.");
             return null;
         }
 
         try {
-            final String jwtPrincipal = 
jwtService.getUserIdentityFromToken(jwtAuthToken);
-            return new AuthenticationResponse(jwtPrincipal, jwtPrincipal, 
expiration, issuer);
+            String jwtAuthToken = credentials.toString();
+            final Jws<Claims> jws = 
jwtService.parseAndValidateToken(jwtAuthToken);
+
+            final String jwtPrincipal = 
jwtService.getUserIdentityFromToken(jws);
+            final Set<String> groups = jwtService.getUserGroupsFromToken(jws);
+
+            return new AuthenticationResponse(jwtPrincipal, jwtPrincipal, 
expiration, issuer, groups);
         } catch (JwtException e) {
             throw new InvalidAuthenticationException(e.getMessage(), e);
         }
diff --git 
a/nifi-registry/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/security/authentication/jwt/JwtService.java
 
b/nifi-registry/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/security/authentication/jwt/JwtService.java
index a48c2ba679..030e82bb40 100644
--- 
a/nifi-registry/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/security/authentication/jwt/JwtService.java
+++ 
b/nifi-registry/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/security/authentication/jwt/JwtService.java
@@ -37,7 +37,13 @@ import 
org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 
 import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
 import java.util.Calendar;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
 import java.util.concurrent.TimeUnit;
 
 @Service
@@ -48,6 +54,7 @@ public class JwtService {
     private static final MacAlgorithm SIGNATURE_ALGORITHM = Jwts.SIG.HS256;
     private static final String KEY_ID_CLAIM = "kid";
     private static final String USERNAME_CLAIM = "preferred_username";
+    private static final String GROUPS_CLAIM = "groups";
 
     private final KeyService keyService;
 
@@ -56,7 +63,7 @@ public class JwtService {
         this.keyService = keyService;
     }
 
-    public String getUserIdentityFromToken(final String base64EncodedToken) 
throws JwtException {
+    public Jws<Claims> parseAndValidateToken(final String base64EncodedToken) 
throws JwtException {
         // The library representations of the JWT should be kept internal to 
this service.
         try {
             final Jws<Claims> jws = 
parseTokenFromBase64EncodedString(base64EncodedToken);
@@ -74,14 +81,24 @@ public class JwtService {
             if (StringUtils.isEmpty(jws.getPayload().getIssuer())) {
                 throw new JwtException("No issuer available in token");
             }
-            return jws.getPayload().getSubject();
+
+            return jws;
         } catch (JwtException e) {
-            final String errorMessage = "There was an error validating the 
JWT";
-            logger.error(errorMessage, e);
-            throw e;
+            throw new JwtException("There was an error validating the JWT", e);
         }
     }
 
+    public String getUserIdentityFromToken(final Jws<Claims> jws) throws 
JwtException {
+        return jws.getPayload().getSubject();
+    }
+
+    public Set<String> getUserGroupsFromToken(final Jws<Claims> jws) throws 
JwtException {
+        @SuppressWarnings("unchecked")
+        final List<String> groupsString = jws.getPayload().get(GROUPS_CLAIM, 
ArrayList.class);
+
+        return new HashSet<>(groupsString != null ? groupsString : 
Collections.emptyList());
+    }
+
     private Jws<Claims> parseTokenFromBase64EncodedString(final String 
base64EncodedToken) throws JwtException {
         try {
             return Jwts.parser().setSigningKeyResolver(new 
SigningKeyResolverAdapter() {
@@ -125,11 +142,15 @@ public class JwtService {
                 authenticationResponse.getUsername(),
                 authenticationResponse.getIssuer(),
                 authenticationResponse.getIssuer(),
-                authenticationResponse.getExpiration());
+                authenticationResponse.getExpiration(),
+                null);
     }
 
     public String generateSignedToken(String identity, String 
preferredUsername, String issuer, String audience, long expirationMillis) 
throws JwtException {
+        return this.generateSignedToken(identity, preferredUsername, issuer, 
audience, expirationMillis, null);
+    }
 
+    public String generateSignedToken(String identity, String 
preferredUsername, String issuer, String audience, long expirationMillis, 
Collection<String> groups) throws JwtException {
         if (identity == null || StringUtils.isEmpty(identity)) {
             String errorMessage = "Cannot generate a JWT for a token with an 
empty identity";
             errorMessage = issuer != null ? errorMessage + " issued by " + 
issuer + "." : ".";
@@ -155,6 +176,7 @@ public class JwtService {
                     .audience().add(audience).and()
                     .claim(USERNAME_CLAIM, preferredUsername)
                     .claim(KEY_ID_CLAIM, key.getId())
+                    .claim(GROUPS_CLAIM, groups != null ? groups : 
Collections.EMPTY_LIST)
                     .issuedAt(now.getTime())
                     .expiration(expiration.getTime())
                     .signWith(Keys.hmacShaKeyFor(keyBytes), 
SIGNATURE_ALGORITHM).compact();
diff --git 
a/nifi-registry/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/security/authentication/oidc/StandardOidcIdentityProvider.java
 
b/nifi-registry/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/security/authentication/oidc/StandardOidcIdentityProvider.java
index 8adeb00ae5..ec7972e84e 100644
--- 
a/nifi-registry/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/security/authentication/oidc/StandardOidcIdentityProvider.java
+++ 
b/nifi-registry/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/security/authentication/oidc/StandardOidcIdentityProvider.java
@@ -401,6 +401,10 @@ public class StandardOidcIdentityProvider implements 
OidcIdentityProvider {
         String identityClaim = properties.getOidcClaimIdentifyingUser();
         String identity = claimsSet.getStringClaim(identityClaim);
 
+        // Attempt to extract groups from the configured claim; default is 
'groups'
+        final String groupsClaim = properties.getOidcClaimGroups();
+        final List<String> groups = claimsSet.getStringListClaim(groupsClaim);
+
         // If default identity not available, attempt secondary identity 
extraction
         if (StringUtils.isBlank(identity)) {
             // Provide clear message to admin that desired claim is missing 
and present available claims
@@ -425,7 +429,7 @@ public class StandardOidcIdentityProvider implements 
OidcIdentityProvider {
         final String issuer = claimsSet.getIssuer().getValue();
 
         // convert into a nifi jwt for retrieval later
-        return jwtService.generateSignedToken(identity, identity, issuer, 
issuer, expiresIn);
+        return jwtService.generateSignedToken(identity, identity, issuer, 
issuer, expiresIn, groups);
     }
 
     private String retrieveIdentityFromUserInfoEndpoint(OIDCTokens oidcTokens) 
throws IOException {

Reply via email to