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

ilgrosso pushed a commit to branch 4_0_X
in repository https://gitbox.apache.org/repos/asf/syncope.git


The following commit(s) were added to refs/heads/4_0_X by this push:
     new fbf4231779 Checking that AccessToken owner matches JWT subject for 
admin
fbf4231779 is described below

commit fbf4231779a5df12744921c24fba77055dfe032d
Author: Francesco Chicchiriccò <[email protected]>
AuthorDate: Mon Aug 24 11:36:10 2026 +0200

    Checking that AccessToken owner matches JWT subject for admin
---
 .../core/spring/security/AuthDataAccessor.java     |  86 ++++++++-------
 .../spring/security/SyncopeJWTSSOProvider.java     |   2 +-
 .../core/spring/security/AuthDataAccessorTest.java | 118 +++++++++++++++++++++
 3 files changed, 164 insertions(+), 42 deletions(-)

diff --git 
a/core/spring/src/main/java/org/apache/syncope/core/spring/security/AuthDataAccessor.java
 
b/core/spring/src/main/java/org/apache/syncope/core/spring/security/AuthDataAccessor.java
index 9409f76b90..e49df3ba6c 100644
--- 
a/core/spring/src/main/java/org/apache/syncope/core/spring/security/AuthDataAccessor.java
+++ 
b/core/spring/src/main/java/org/apache/syncope/core/spring/security/AuthDataAccessor.java
@@ -49,6 +49,7 @@ import org.apache.syncope.core.persistence.api.dao.RoleDAO;
 import org.apache.syncope.core.persistence.api.dao.UserDAO;
 import org.apache.syncope.core.persistence.api.dao.search.AttrCond;
 import org.apache.syncope.core.persistence.api.dao.search.SearchCond;
+import org.apache.syncope.core.persistence.api.entity.AccessToken;
 import org.apache.syncope.core.persistence.api.entity.Delegation;
 import org.apache.syncope.core.persistence.api.entity.DynRealm;
 import org.apache.syncope.core.persistence.api.entity.ExternalResource;
@@ -164,7 +165,7 @@ public class AuthDataAccessor {
 
         return jwtSSOProviders.stream().filter(provider -> 
issuer.equals(provider.getIssuer())).findFirst().
                 orElseThrow(() -> new 
AuthenticationCredentialsNotFoundException(
-                "Could not find any registered JWTSSOProvider for issuer " + 
issuer));
+                        "Could not find any registered JWTSSOProvider for 
issuer " + issuer));
     }
 
     protected String getDelegationKey(final SyncopeAuthenticationDetails 
details, final String delegatedKey) {
@@ -184,7 +185,7 @@ public class AuthDataAccessor {
 
         return delegationDAO.findValidFor(delegatingKey, delegatedKey, 
OffsetDateTime.now()).
                 orElseThrow(() -> new SessionAuthenticationException(
-                "Delegation by " + delegatingKey + " was requested but none 
found"));
+                        "Delegation by " + delegatingKey + " was requested but 
none found"));
     }
 
     /**
@@ -277,10 +278,10 @@ public class AuthDataAccessor {
             try {
                 Provision provision = 
resource.getProvisionByAnyType(AnyTypeKind.USER.name()).
                         orElseThrow(() -> new AccountNotFoundException(
-                        "Unable to locate provision for user type " + 
AnyTypeKind.USER.name()));
+                                "Unable to locate provision for user type " + 
AnyTypeKind.USER.name()));
                 connObjectKey = mappingManager.getConnObjectKeyValue(user, 
resource, provision).
                         orElseThrow(() -> new AccountNotFoundException(
-                        "Unable to locate conn object key value for " + 
AnyTypeKind.USER.name()));
+                                "Unable to locate conn object key value for " 
+ AnyTypeKind.USER.name()));
                 Uid uid = 
connectorManager.getConnector(resource).authenticate(connObjectKey, password, 
null);
                 if (uid != null) {
                     authenticated = true;
@@ -356,31 +357,31 @@ public class AuthDataAccessor {
         userDAO.findAllRoles(user).stream().
                 filter(role -> 
!RoleDAO.GROUP_OWNER_ROLE.equals(role.getKey())).
                 forEach(role -> role.getEntitlements().forEach(entitlement -> {
-            Set<String> realms = 
Optional.ofNullable(entForRealms.get(entitlement)).orElseGet(() -> {
-                Set<String> r = new HashSet<>();
-                entForRealms.put(entitlement, r);
-                return r;
-            });
-
-            
realms.addAll(role.getRealms().stream().map(Realm::getFullPath).collect(Collectors.toSet()));
-            if (!entitlement.endsWith("_CREATE") && 
!entitlement.endsWith("_DELETE")) {
-                
realms.addAll(role.getDynRealms().stream().map(DynRealm::getKey).toList());
-            }
-        }));
-
-        // Give group entitlements for owned groups
-        groupDAO.findOwnedByUser(user.getKey()).
-                forEach(g -> 
roleDAO.findById(RoleDAO.GROUP_OWNER_ROLE).ifPresentOrElse(
-                groupOwnerRole -> 
groupOwnerRole.getEntitlements().forEach(entitlement -> {
                     Set<String> realms = 
Optional.ofNullable(entForRealms.get(entitlement)).orElseGet(() -> {
-                        HashSet<String> r = new HashSet<>();
+                        Set<String> r = new HashSet<>();
                         entForRealms.put(entitlement, r);
                         return r;
                     });
 
-                    realms.add(new 
RealmUtils.GroupOwnerRealm(g.getRealm().getFullPath(), g.getKey()).output());
-                }),
-                () -> LOG.warn("Role {} was not found", 
RoleDAO.GROUP_OWNER_ROLE)));
+                    
realms.addAll(role.getRealms().stream().map(Realm::getFullPath).collect(Collectors.toSet()));
+                    if (!entitlement.endsWith("_CREATE") && 
!entitlement.endsWith("_DELETE")) {
+                        
realms.addAll(role.getDynRealms().stream().map(DynRealm::getKey).toList());
+                    }
+                }));
+
+        // Give group entitlements for owned groups
+        groupDAO.findOwnedByUser(user.getKey()).
+                forEach(g -> 
roleDAO.findById(RoleDAO.GROUP_OWNER_ROLE).ifPresentOrElse(
+                        groupOwnerRole -> 
groupOwnerRole.getEntitlements().forEach(entitlement -> {
+                            Set<String> realms = 
Optional.ofNullable(entForRealms.get(entitlement)).orElseGet(() -> {
+                                HashSet<String> r = new HashSet<>();
+                                entForRealms.put(entitlement, r);
+                                return r;
+                            });
+
+                            realms.add(new 
RealmUtils.GroupOwnerRealm(g.getRealm().getFullPath(), g.getKey()).output());
+                        }),
+                        () -> LOG.warn("Role {} was not found", 
RoleDAO.GROUP_OWNER_ROLE)));
 
         return buildAuthorities(entForRealms);
     }
@@ -390,17 +391,17 @@ public class AuthDataAccessor {
 
         delegation.getRoles().stream().filter(role -> 
!RoleDAO.GROUP_OWNER_ROLE.equals(role.getKey())).
                 forEach(role -> role.getEntitlements().forEach(entitlement -> {
-            Set<String> realms = 
Optional.ofNullable(entForRealms.get(entitlement)).orElseGet(() -> {
-                HashSet<String> r = new HashSet<>();
-                entForRealms.put(entitlement, r);
-                return r;
-            });
-
-            
realms.addAll(role.getRealms().stream().map(Realm::getFullPath).collect(Collectors.toSet()));
-            if (!entitlement.endsWith("_CREATE") && 
!entitlement.endsWith("_DELETE")) {
-                
realms.addAll(role.getDynRealms().stream().map(DynRealm::getKey).toList());
-            }
-        }));
+                    Set<String> realms = 
Optional.ofNullable(entForRealms.get(entitlement)).orElseGet(() -> {
+                        HashSet<String> r = new HashSet<>();
+                        entForRealms.put(entitlement, r);
+                        return r;
+                    });
+
+                    
realms.addAll(role.getRealms().stream().map(Realm::getFullPath).collect(Collectors.toSet()));
+                    if (!entitlement.endsWith("_CREATE") && 
!entitlement.endsWith("_DELETE")) {
+                        
realms.addAll(role.getDynRealms().stream().map(DynRealm::getKey).toList());
+                    }
+                }));
 
         return buildAuthorities(entForRealms);
     }
@@ -416,7 +417,7 @@ public class AuthDataAccessor {
         } else if (delegationKey != null) {
             Delegation delegation = delegationDAO.findById(delegationKey).
                     orElseThrow(() -> new UsernameNotFoundException(
-                    "Could not find delegation " + delegationKey));
+                            "Could not find delegation " + delegationKey));
 
             authorities = delegation.getRoles().isEmpty()
                     ? getUserAuthorities(delegation.getDelegating())
@@ -424,7 +425,7 @@ public class AuthDataAccessor {
         } else {
             User user = userDAO.findByUsername(username).
                     orElseThrow(() -> new UsernameNotFoundException(
-                    "Could not find any user with username " + username));
+                            "Could not find any user with username " + 
username));
 
             authorities = getUserAuthorities(user);
         }
@@ -438,9 +439,12 @@ public class AuthDataAccessor {
         Set<SyncopeGrantedAuthority> authorities;
 
         if 
(securityProperties.getAdminUser().equals(authentication.getClaims().getSubject()))
 {
-            accessTokenDAO.findById(authentication.getClaims().getJWTID()).
+            AccessToken accessToken = 
accessTokenDAO.findById(authentication.getClaims().getJWTID()).
                     orElseThrow(() -> new 
AuthenticationCredentialsNotFoundException(
-                    "Could not find an Access Token for JWT " + 
authentication.getClaims().getJWTID()));
+                            "Could not find an Access Token for JWT " + 
authentication.getClaims().getJWTID()));
+            if 
(!securityProperties.getAdminUser().equals(accessToken.getOwner())) {
+                throw new AuthenticationCredentialsNotFoundException("Access 
Token owner does not match JWT subject");
+            }
 
             username = securityProperties.getAdminUser();
             authorities = getAdminAuthorities();
@@ -448,8 +452,8 @@ public class AuthDataAccessor {
             JWTSSOProvider jwtSSOProvider = 
getJWTSSOProvider(authentication.getClaims().getIssuer());
             JWTSSOProvider.ResolvedClaims resolved = 
jwtSSOProvider.resolve(authentication.getClaims()).
                     orElseThrow(() -> new 
AuthenticationCredentialsNotFoundException(
-                    "Could not find User " + 
authentication.getClaims().getSubject()
-                    + " for JWT " + authentication.getClaims().getJWTID()));
+                            "Could not find User " + 
authentication.getClaims().getSubject()
+                            + " for JWT " + 
authentication.getClaims().getJWTID()));
 
             User user = resolved.user();
             String delegationKey = 
getDelegationKey(authentication.getDetails(), user.getKey());
diff --git 
a/core/spring/src/main/java/org/apache/syncope/core/spring/security/SyncopeJWTSSOProvider.java
 
b/core/spring/src/main/java/org/apache/syncope/core/spring/security/SyncopeJWTSSOProvider.java
index 01920c77db..1c181400cb 100644
--- 
a/core/spring/src/main/java/org/apache/syncope/core/spring/security/SyncopeJWTSSOProvider.java
+++ 
b/core/spring/src/main/java/org/apache/syncope/core/spring/security/SyncopeJWTSSOProvider.java
@@ -100,7 +100,7 @@ public class SyncopeJWTSSOProvider implements 
JWTSSOProvider {
     public Optional<ResolvedClaims> resolve(final JWTClaimsSet jwtClaims) {
         AccessToken accessToken = 
accessTokenDAO.findById(jwtClaims.getJWTID()).
                 orElseThrow(() -> new 
AuthenticationCredentialsNotFoundException(
-                "Could not find an Access Token for JWT " + 
jwtClaims.getJWTID()));
+                        "Could not find an Access Token for JWT " + 
jwtClaims.getJWTID()));
 
         Set<SyncopeGrantedAuthority> authorities = new HashSet<>();
         if (accessToken.getAuthorities() != null) {
diff --git 
a/core/spring/src/test/java/org/apache/syncope/core/spring/security/AuthDataAccessorTest.java
 
b/core/spring/src/test/java/org/apache/syncope/core/spring/security/AuthDataAccessorTest.java
new file mode 100644
index 0000000000..fe29f907ac
--- /dev/null
+++ 
b/core/spring/src/test/java/org/apache/syncope/core/spring/security/AuthDataAccessorTest.java
@@ -0,0 +1,118 @@
+/*
+ * 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.syncope.core.spring.security;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.JWSHeader;
+import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.jwt.SignedJWT;
+import java.lang.reflect.Constructor;
+import java.time.OffsetDateTime;
+import java.util.Arrays;
+import java.util.Date;
+import java.util.List;
+import java.util.Optional;
+import org.apache.syncope.common.lib.SyncopeConstants;
+import org.apache.syncope.common.lib.types.EntitlementsHolder;
+import org.apache.syncope.common.lib.types.IdRepoEntitlement;
+import org.apache.syncope.core.persistence.api.dao.AccessTokenDAO;
+import org.apache.syncope.core.persistence.api.entity.AccessToken;
+import org.apache.syncope.core.spring.security.jws.AccessTokenJWSSigner;
+import org.apache.syncope.core.spring.security.jws.AccessTokenJWSVerifier;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import 
org.springframework.security.authentication.AuthenticationCredentialsNotFoundException;
+
+class AuthDataAccessorTest {
+
+    private static final String LOW_PRIVILEGE_JTI = 
"low-privilege-users-valid-token-id";
+
+    private static final String LOW_PRIVILEGE_USER = "low-priv-user";
+
+    private SecurityProperties securityProperties;
+
+    private AccessTokenDAO accessTokenDAO;
+
+    private AuthDataAccessor authDataAccessor;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        securityProperties = new SecurityProperties();
+        securityProperties.setJwtIssuer("ApacheSyncope");
+        securityProperties.setJwsAlgorithm("HS512");
+        
securityProperties.setJwsKey(SecureRandomUtils.generateRandomLetters(64));
+        securityProperties.setAdminUser("admin");
+
+        EntitlementsHolder.getInstance().addAll(IdRepoEntitlement.values());
+
+        AccessToken lowPrivilegeToken = mock(AccessToken.class);
+        when(lowPrivilegeToken.getOwner()).thenReturn(LOW_PRIVILEGE_USER);
+        accessTokenDAO = mock(AccessTokenDAO.class);
+        
doReturn(Optional.of(lowPrivilegeToken)).when(accessTokenDAO).findById(LOW_PRIVILEGE_JTI);
+
+        Constructor<?> constructor = 
Arrays.stream(AuthDataAccessor.class.getConstructors()).
+                findFirst().orElseThrow();
+        Object[] arguments = 
Arrays.stream(constructor.getParameterTypes()).map(type -> {
+            if (type == SecurityProperties.class) {
+                return securityProperties;
+            }
+            if (type == AccessTokenDAO.class) {
+                return accessTokenDAO;
+            }
+            if (type == List.class) {
+                return List.of();
+            }
+            return mock(type);
+        }).toArray();
+
+        authDataAccessor = (AuthDataAccessor) 
constructor.newInstance(arguments);
+    }
+
+    @Test
+    void lowPrivilegeTokenIdCanBeReboundToAdminWithDefaultSigningKey() throws 
Exception {
+        JWTClaimsSet claims = new JWTClaimsSet.Builder().
+                issuer(securityProperties.getJwtIssuer()).
+                subject(securityProperties.getAdminUser()).
+                jwtID(LOW_PRIVILEGE_JTI).
+                issueTime(new Date()).
+                notBeforeTime(new Date()).
+                
expirationTime(Date.from(OffsetDateTime.now().plusMinutes(10).toInstant())).
+                build();
+        SignedJWT forged = new SignedJWT(new JWSHeader(JWSAlgorithm.HS512), 
claims);
+        forged.sign(new AccessTokenJWSSigner(JWSAlgorithm.HS512, 
securityProperties.getJwsKey()));
+
+        assertTrue(forged.verify(new 
AccessTokenJWSVerifier(JWSAlgorithm.HS512, securityProperties.getJwsKey())));
+
+        JWTAuthentication authentication = new JWTAuthentication(
+                forged.getJWTClaimsSet(),
+                new 
SyncopeAuthenticationDetails(SyncopeConstants.MASTER_DOMAIN, null));
+
+        AuthenticationCredentialsNotFoundException ex = assertThrows(
+                AuthenticationCredentialsNotFoundException.class,
+                () -> authDataAccessor.authenticate(authentication));
+        assertEquals("Access Token owner does not match JWT subject", 
ex.getMessage());
+    }
+}

Reply via email to