From 5778a322fc5f1b2bc4bff4a5e9d41d1cbf53b03e Mon Sep 17 00:00:00 2001 From: jimmychakkalakal <47317106+jimmychakkalakal@users.noreply.github.com> Date: Thu, 21 May 2026 07:42:11 +0100 Subject: [PATCH] Support for user attributes and updating them (#49066) Closes #48578 Signed-off-by: Jimmy Chakkalakal --- ...serVerifiableCredentialRepresentation.java | 15 +++- .../UserVerifiableCredentialResource.java | 6 ++ .../cache/infinispan/UserCacheSession.java | 5 ++ .../keycloak/models/jpa/JpaUserProvider.java | 68 ++++++++++++++++++- .../UserVerifiableCredentialEntity.java | 7 ++ .../META-INF/jpa-changelog-26.7.0.xml | 3 + .../keycloak/storage/UserStorageManager.java | 9 +++ .../models/utils/ModelToRepresentation.java | 1 + .../models/utils/RepresentationToModel.java | 1 + .../org/keycloak/models/UserProvider.java | 10 +++ .../models/UserVerifiableCredentialModel.java | 6 ++ .../UserVerifiableCredentialResource.java | 36 ++++++++++ .../keycloak/tests/admin/PermissionsTest.java | 1 + .../user/UserVerifiableCredentialsTest.java | 59 +++++++++++++++- .../tests/oid4vc/OID4VCExportImportTest.java | 25 ++++++- 15 files changed, 246 insertions(+), 6 deletions(-) diff --git a/core/src/main/java/org/keycloak/representations/idm/oid4vc/UserVerifiableCredentialRepresentation.java b/core/src/main/java/org/keycloak/representations/idm/oid4vc/UserVerifiableCredentialRepresentation.java index 6dde40eabc8..7172a38d115 100644 --- a/core/src/main/java/org/keycloak/representations/idm/oid4vc/UserVerifiableCredentialRepresentation.java +++ b/core/src/main/java/org/keycloak/representations/idm/oid4vc/UserVerifiableCredentialRepresentation.java @@ -1,12 +1,16 @@ package org.keycloak.representations.idm.oid4vc; +import java.util.List; +import java.util.Map; import java.util.Objects; + public class UserVerifiableCredentialRepresentation { private String credentialScopeName; private String revision; private Long createdDate; + private Map> userAttributes; public String getCredentialScopeName() { return credentialScopeName; @@ -32,15 +36,22 @@ public class UserVerifiableCredentialRepresentation { this.createdDate = createdDate; } + public Map> getUserAttributes() { return userAttributes; } + + public void setUserAttributes(Map> userAttributes) { this.userAttributes = userAttributes; } + @Override public boolean equals(Object o) { if (o == null || getClass() != o.getClass()) return false; UserVerifiableCredentialRepresentation that = (UserVerifiableCredentialRepresentation) o; - return Objects.equals(credentialScopeName, that.credentialScopeName) && Objects.equals(revision, that.revision) && Objects.equals(createdDate, that.createdDate); + return Objects.equals(credentialScopeName, that.credentialScopeName) + && Objects.equals(revision, that.revision) + && Objects.equals(createdDate, that.createdDate) + && Objects.equals(userAttributes, that.userAttributes); } @Override public int hashCode() { - return Objects.hash(credentialScopeName, revision, createdDate); + return Objects.hash(credentialScopeName, revision, createdDate, userAttributes); } } diff --git a/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/UserVerifiableCredentialResource.java b/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/UserVerifiableCredentialResource.java index d524f6baef2..5e8149b2d6d 100644 --- a/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/UserVerifiableCredentialResource.java +++ b/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/UserVerifiableCredentialResource.java @@ -6,6 +6,7 @@ import jakarta.ws.rs.Consumes; import jakarta.ws.rs.DELETE; import jakarta.ws.rs.GET; import jakarta.ws.rs.POST; +import jakarta.ws.rs.PUT; import jakarta.ws.rs.Path; import jakarta.ws.rs.PathParam; import jakarta.ws.rs.Produces; @@ -34,5 +35,10 @@ public interface UserVerifiableCredentialResource { @Path("credentials/{credentialScopeName}") void revokeCredential(@PathParam("credentialScopeName") String credentialScopeName); + @PUT + @Path("credentials/{credentialScopeName}") + @Produces(MediaType.APPLICATION_JSON) + UserVerifiableCredentialRepresentation updateCredential(@PathParam("credentialScopeName") String credentialScopeName); + // TODO: Issued credentials } diff --git a/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/UserCacheSession.java b/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/UserCacheSession.java index bed061bf905..fe529db23ba 100755 --- a/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/UserCacheSession.java +++ b/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/UserCacheSession.java @@ -870,6 +870,11 @@ public class UserCacheSession implements UserCache, OnCreateComponent, OnUpdateC return getDelegate().getVerifiableCredentialsByUser(userId); } + @Override + public UserVerifiableCredentialModel updateVerifiableCredential(String userId, String credentialScopeName) { + return getDelegate().updateVerifiableCredential(userId, credentialScopeName); + } + @Override public void setNotBeforeForUser(RealmModel realm, UserModel user, int notBefore) { if (!isRegisteredForInvalidation(realm, user.getId())) { diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/JpaUserProvider.java b/model/jpa/src/main/java/org/keycloak/models/jpa/JpaUserProvider.java index 109d862efeb..763e95a78ca 100755 --- a/model/jpa/src/main/java/org/keycloak/models/jpa/JpaUserProvider.java +++ b/model/jpa/src/main/java/org/keycloak/models/jpa/JpaUserProvider.java @@ -17,6 +17,7 @@ package org.keycloak.models.jpa; +import java.io.IOException; import java.util.ArrayList; import java.util.Collection; import java.util.Comparator; @@ -79,8 +80,11 @@ import org.keycloak.storage.StorageId; import org.keycloak.storage.UserStorageProvider; import org.keycloak.storage.client.ClientStorageProvider; import org.keycloak.storage.jpa.JpaHashUtils; +import org.keycloak.util.JsonSerialization; import org.keycloak.utils.StringUtil; +import com.fasterxml.jackson.core.type.TypeReference; + import static org.keycloak.models.jpa.PaginationUtils.paginateQuery; import static org.keycloak.storage.jpa.JpaHashUtils.predicateForFilteringUsersByAttributes; import static org.keycloak.utils.StreamsUtil.closing; @@ -382,6 +386,21 @@ public class JpaUserProvider implements UserProvider, UserCredentialStore, JpaUs vcEntity.setCreatedDate(createdDate); vcEntity.setCredentialScopeName(verifCredentialModel.getCredentialScopeName()); + + Map> userAttributes; + if (verifCredentialModel.getUserAttributes() != null) { + userAttributes = verifCredentialModel.getUserAttributes(); + } else { + UserModel user = getUserById(session.getContext().getRealm(), userId); + userAttributes = user.getAttributes(); + } + + try { + String attributesJson = JsonSerialization.writeValueAsString(userAttributes); + vcEntity.setUserAttributes(attributesJson); + } catch (IOException e) { + throw new ModelException("Failed to serialize user attributes", e); + } em.persist(vcEntity); em.flush(); @@ -409,16 +428,63 @@ public class JpaUserProvider implements UserProvider, UserCredentialStore, JpaUs .sorted(Comparator.comparing(UserVerifiableCredentialModel::getCredentialScopeName)); } + @Override + public UserVerifiableCredentialModel updateVerifiableCredential(String userId, String credentialScopeName) { + UserEntity userEntity = em.find(UserEntity.class, userId); + if (userEntity == null || !session.getContext().getRealm().getId().equals(userEntity.getRealmId())) { + throw new ModelException("User not found: " + userId); + } + UserModel user = new UserAdapter(session, session.getContext().getRealm(), em, userEntity); + TypedQuery query = getVerifiableCredentialsEntitiesByUser(); + query.setParameter("userId", userId); + + UserVerifiableCredentialEntity entity = query.getResultStream() + .filter(vc -> credentialScopeName.equals(vc.getCredentialScopeName())) + .findFirst() + .orElseThrow(() -> new ModelException( + "Verifiable credential not found: " + credentialScopeName)); + + + Map> userAttributes = user.getAttributes(); + try { + String attributesJson = JsonSerialization.writeValueAsString(userAttributes); + entity.setUserAttributes(attributesJson); + } catch (IOException e) { + throw new ModelException("Failed to serialize user attributes", e); + } + + String newRevision = SecretGenerator.getInstance().generateSecureID(); + entity.setRevision(newRevision); + UserVerifiableCredentialEntity mergedEntity = em.merge(entity); + em.flush(); + + return toVerifiableCredentialModel(mergedEntity); + } + private Stream getVerifiableCredentialsEntitiesByUser(String userId) { - TypedQuery query = em.createNamedQuery("verifiableCredentialsByUser", UserVerifiableCredentialEntity.class); + TypedQuery query = getVerifiableCredentialsEntitiesByUser(); query.setParameter("userId", userId); return closing(query.getResultStream()); } + private TypedQuery getVerifiableCredentialsEntitiesByUser() { + return em.createNamedQuery("verifiableCredentialsByUser", UserVerifiableCredentialEntity.class); + } + private UserVerifiableCredentialModel toVerifiableCredentialModel(UserVerifiableCredentialEntity entity) { UserVerifiableCredentialModel model = new UserVerifiableCredentialModel(entity.getCredentialScopeName()); model.setRevision(entity.getRevision()); model.setCreatedDate(entity.getCreatedDate()); + + if (entity.getUserAttributes() != null) { + try { + TypeReference>> typeRef = new TypeReference<>() {}; + Map> attrs = JsonSerialization.readValue(entity.getUserAttributes(), typeRef); + model.setUserAttributes(attrs); + } catch (IOException e) { + throw new ModelException("Failed to deserialize user attributes", e); + } + } return model; } diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/entities/UserVerifiableCredentialEntity.java b/model/jpa/src/main/java/org/keycloak/models/jpa/entities/UserVerifiableCredentialEntity.java index 78422f29e04..d2bfee121bd 100644 --- a/model/jpa/src/main/java/org/keycloak/models/jpa/entities/UserVerifiableCredentialEntity.java +++ b/model/jpa/src/main/java/org/keycloak/models/jpa/entities/UserVerifiableCredentialEntity.java @@ -40,6 +40,9 @@ public class UserVerifiableCredentialEntity { @Column(name="REVISION") protected String revision; + @Column(name = "USER_ATTRIBUTES") + protected String userAttributes; + @Column(name = "CREATED_DATE") private Long createdDate; @@ -83,6 +86,10 @@ public class UserVerifiableCredentialEntity { this.createdDate = createdDate; } + public String getUserAttributes() { return userAttributes; } + + public void setUserAttributes(String userAttributes) { this.userAttributes = userAttributes; } + @Override public boolean equals(Object o) { if (this == o) return true; diff --git a/model/jpa/src/main/resources/META-INF/jpa-changelog-26.7.0.xml b/model/jpa/src/main/resources/META-INF/jpa-changelog-26.7.0.xml index f2d44e4f204..7fa2be38636 100644 --- a/model/jpa/src/main/resources/META-INF/jpa-changelog-26.7.0.xml +++ b/model/jpa/src/main/resources/META-INF/jpa-changelog-26.7.0.xml @@ -60,6 +60,9 @@ + + + diff --git a/model/storage-private/src/main/java/org/keycloak/storage/UserStorageManager.java b/model/storage-private/src/main/java/org/keycloak/storage/UserStorageManager.java index 693bc385ab6..74982942030 100755 --- a/model/storage-private/src/main/java/org/keycloak/storage/UserStorageManager.java +++ b/model/storage-private/src/main/java/org/keycloak/storage/UserStorageManager.java @@ -940,6 +940,15 @@ public class UserStorageManager extends AbstractStorageManager getVerifiableCredentialsByUser(String userId); + /** + * Update verifiable credential by refreshing user attributes snapshot and incrementing revision + * + * @param userId id of the user + * @param credentialScopeName credential scope name to update + * @return updated credential model + * @throws ModelException if credential doesn't exist + */ + UserVerifiableCredentialModel updateVerifiableCredential(String userId, String credentialScopeName); + /* FEDERATED IDENTITIES methods */ /** diff --git a/server-spi/src/main/java/org/keycloak/models/UserVerifiableCredentialModel.java b/server-spi/src/main/java/org/keycloak/models/UserVerifiableCredentialModel.java index 2915bb169d6..a3bbb2ff582 100644 --- a/server-spi/src/main/java/org/keycloak/models/UserVerifiableCredentialModel.java +++ b/server-spi/src/main/java/org/keycloak/models/UserVerifiableCredentialModel.java @@ -1,10 +1,14 @@ package org.keycloak.models; +import java.util.List; +import java.util.Map; + public class UserVerifiableCredentialModel { private final String credentialScopeName; private String revision; private Long createdDate; + private Map> userAttributes; public UserVerifiableCredentialModel(String credentialScopeName) { this.credentialScopeName = credentialScopeName; @@ -30,5 +34,7 @@ public class UserVerifiableCredentialModel { this.createdDate = createdDate; } + public Map> getUserAttributes() { return userAttributes; } + public void setUserAttributes(Map> userAttributes) { this.userAttributes = userAttributes; } } diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/resources/admin/UserVerifiableCredentialResource.java b/services/src/main/java/org/keycloak/protocol/oid4vc/resources/admin/UserVerifiableCredentialResource.java index c7b945e7b72..6816275ac3a 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/resources/admin/UserVerifiableCredentialResource.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/resources/admin/UserVerifiableCredentialResource.java @@ -7,6 +7,7 @@ import jakarta.ws.rs.DELETE; import jakarta.ws.rs.GET; import jakarta.ws.rs.NotFoundException; import jakarta.ws.rs.POST; +import jakarta.ws.rs.PUT; import jakarta.ws.rs.Path; import jakarta.ws.rs.PathParam; import jakarta.ws.rs.Produces; @@ -130,6 +131,41 @@ public class UserVerifiableCredentialResource { .toList(); } + @PUT + @Path("credentials/{credentialScopeName}") + @Produces(MediaType.APPLICATION_JSON) + @NoCache + @Tag(name = KeycloakOpenAPI.Admin.Tags.USERS) + @Operation(summary = "Update verifiable credential - refreshes user attributes snapshot and increments revision") + @APIResponses(value = { + @APIResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = UserVerifiableCredentialRepresentation.class))), + @APIResponse(responseCode = "400", description = "Bad request", content = @Content(schema = @Schema(implementation = ErrorRepresentation.class))), + @APIResponse(responseCode = "403", description = "Forbidden"), + @APIResponse(responseCode = "404", description = "Not Found") + }) + public UserVerifiableCredentialRepresentation updateCredential(@PathParam("credentialScopeName") String credentialScopeName) { + auth.users().requireManage(user); + checkOid4VCIEnabled(); + + try { + UserVerifiableCredentialModel updatedModel = session.users().updateVerifiableCredential(user.getId(), credentialScopeName); + + UserVerifiableCredentialRepresentation updatedRep = ModelToRepresentation.toRepresentation(updatedModel); + + adminEvent.operation(OperationType.UPDATE) + .resourcePath(session.getContext().getUri(), credentialScopeName) + .representation(updatedRep) + .success(); + + return updatedRep; + + } catch (ModelException e) { + logger.warn(String.format("Verifiable credential '%s' not found for user '%s' in the realm '%s'.", + credentialScopeName, user.getUsername(), realm.getName())); + throw new NotFoundException("Verifiable credential not found"); + } + } + @DELETE @Path("credentials/{credentialScopeName}") @Operation(summary = "Revoke verifiable credential for particular user") diff --git a/tests/base/src/test/java/org/keycloak/tests/admin/PermissionsTest.java b/tests/base/src/test/java/org/keycloak/tests/admin/PermissionsTest.java index 761cb8d5674..16695db01a4 100644 --- a/tests/base/src/test/java/org/keycloak/tests/admin/PermissionsTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/admin/PermissionsTest.java @@ -431,6 +431,7 @@ public class PermissionsTest extends AbstractPermissionsTest { UserVerifiableCredentialRepresentation verifCred = new UserVerifiableCredentialRepresentation(); verifCred.setCredentialScopeName("nosuch"); invoke(realm -> realm.users().get(user.getId()).verifiableCredentials().createCredential(verifCred), Resource.USER, true); + invoke(realm -> realm.users().get(user.getId()).verifiableCredentials().updateCredential("nosuch"), Resource.USER, true); invoke(realm -> realm.users().get(user.getId()).verifiableCredentials().revokeCredential("nosuch"), Resource.USER, true); invoke(realm -> realm.users().get(user.getId()).logout(), Resource.USER, true); diff --git a/tests/base/src/test/java/org/keycloak/tests/admin/user/UserVerifiableCredentialsTest.java b/tests/base/src/test/java/org/keycloak/tests/admin/user/UserVerifiableCredentialsTest.java index 2a243b02138..17fd9c1b4f8 100644 --- a/tests/base/src/test/java/org/keycloak/tests/admin/user/UserVerifiableCredentialsTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/admin/user/UserVerifiableCredentialsTest.java @@ -38,7 +38,10 @@ import org.junit.jupiter.api.Test; import static org.keycloak.tests.oid4vc.OID4VCIssuerTestBase.jwtTypeNaturalPersonScopeName; import static org.keycloak.tests.oid4vc.OID4VCIssuerTestBase.sdJwtTypeNaturalPersonScopeName; +import static org.junit.jupiter.api.Assertions.assertAll; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; @KeycloakIntegrationTest(config = OID4VCIssuerTestBase.VCTestServerConfig.class) @@ -212,7 +215,60 @@ public class UserVerifiableCredentialsTest extends AbstractUserTest { } } - private void createVerifiableCedential(UserVerifiableCredentialResource user, String userId, String clientScopeName) { + @Test + @DatabaseTest + public void verifyUpdateCredentialRefreshesAttributes() { + String userId = createUser(); + UserResource userResource = managedRealm.admin().users().get(userId); + UserVerifiableCredentialResource credResource = userResource.verifiableCredentials(); + + UserRepresentation user = userResource.toRepresentation(); + user.setFirstName("John"); + user.setLastName("Doe"); + user.setEmail("john.doe@example.com"); + userResource.update(user); + adminEvents.clear(); + + UserVerifiableCredentialRepresentation created = createVerifiableCedential(credResource, userId, SCOPE_1_NAME); + + String originalRevision = created.getRevision(); + assertNotNull(created.getUserAttributes(), "Initial snapshot should have attributes"); + assertEquals("John", created.getUserAttributes().get("firstName").get(0), "Initial firstName"); + assertEquals("Doe", created.getUserAttributes().get("lastName").get(0), "Initial lastName"); + assertEquals("john.doe@example.com", created.getUserAttributes().get("email").get(0), "Initial email"); + + + user = userResource.toRepresentation(); + user.setFirstName("Jane"); + user.setEmail("jane.doe@example.com"); + userResource.update(user); + + UserVerifiableCredentialRepresentation updated = credResource.updateCredential(SCOPE_1_NAME); + + assertNotNull(updated.getUserAttributes(), "Credential should have user attributes snapshot"); + + assertAll("Credential snapshot should reflect current user attributes", + () -> assertEquals("Jane", updated.getUserAttributes().get("firstName").get(0), + "firstName should be updated to Jane in snapshot"), + () -> assertEquals("Doe", updated.getUserAttributes().get("lastName").get(0), + "lastName should remain Doe in snapshot"), + () -> assertEquals("jane.doe@example.com", updated.getUserAttributes().get("email").get(0), + "email should be updated in snapshot"), + () -> assertNotEquals(originalRevision, updated.getRevision(), "Revision should be updated") + ); + + List all = credResource.getCredentials(); + UserVerifiableCredentialRepresentation retrieved = all.stream() + .filter(c -> SCOPE_1_NAME.equals(c.getCredentialScopeName())) + .findFirst() + .orElseThrow(() -> new AssertionError("Credential not found")); + + assertEquals(updated.getRevision(), retrieved.getRevision(), "Retrieved revision should match"); + assertEquals("Jane", retrieved.getUserAttributes().get("firstName").get(0), "Retrieved snapshot should have updated firstName"); + assertEquals("jane.doe@example.com", retrieved.getUserAttributes().get("email").get(0), "Retrieved snapshot should have updated email"); + } + + private UserVerifiableCredentialRepresentation createVerifiableCedential(UserVerifiableCredentialResource user, String userId, String clientScopeName) { UserVerifiableCredentialRepresentation verifCred = new UserVerifiableCredentialRepresentation(); verifCred.setCredentialScopeName(clientScopeName); UserVerifiableCredentialRepresentation createdRep = user.createCredential(verifCred); @@ -221,6 +277,7 @@ public class UserVerifiableCredentialsTest extends AbstractUserTest { Assert.assertNotNull(createdRep.getCreatedDate()); Assert.assertNotNull(createdRep.getRevision()); AdminEventAssertion.assertEvent(adminEvents.poll(), OperationType.CREATE, AdminEventPaths.userVerifiableCredentialsPath(userId), createdRep, ResourceType.USER); + return createdRep; } private void assertVerifiableCredentials(List creds, String... expectedCredentialNames) { diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCExportImportTest.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCExportImportTest.java index 62c9d419912..ef9cecd1c8a 100644 --- a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCExportImportTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCExportImportTest.java @@ -64,6 +64,10 @@ public class OID4VCExportImportTest extends OID4VCIssuerTestBase { List verifiableCreds = testRealm.admin().users().get(john.getId()).verifiableCredentials().getCredentials(); assertUserCredentials(verifiableCreds, jwtTypeCredentialScopeName, sdJwtTypeCredentialScopeName, minimalJwtTypeCredentialScopeName, jwtTypeNaturalPersonScopeName, sdJwtTypeNaturalPersonScopeName); + for (UserVerifiableCredentialRepresentation cred : verifiableCreds) { + assertNotNull(cred.getUserAttributes(), "User attributes should be stored in verifiable credential " + cred.getCredentialScopeName()); + } + // Export realm exportRealm("oid4vc-test-realm.json"); @@ -74,8 +78,25 @@ public class OID4VCExportImportTest extends OID4VCIssuerTestBase { // Import the realm. Verify same verifiable credentials importRealm("oid4vc-test-realm.json"); assertRealmExists(true); - List importedVerifiableCreds = testRealm.admin().users().get(john.getId()).verifiableCredentials().getCredentials(); - assertEquals(verifiableCreds, importedVerifiableCreds); + + UserRepresentation userAfterImport = testRealm.admin().users().search(TEST_USER).stream().findFirst().orElseThrow(); + List importedVerifiableCreds = testRealm.admin().users().get(userAfterImport.getId()).verifiableCredentials().getCredentials(); + + // Verify same number of credentials + assertEquals(verifiableCreds.size(), importedVerifiableCreds.size()); + + // Verify each credential's userAttributes are preserved + for (UserVerifiableCredentialRepresentation originalCred : verifiableCreds) { + UserVerifiableCredentialRepresentation importedCred = importedVerifiableCreds.stream() + .filter(c -> c.getCredentialScopeName().equals(originalCred.getCredentialScopeName())) + .findFirst() + .orElseThrow(() -> new RuntimeException("Credential scope not found after import: " + originalCred.getCredentialScopeName())); + + assertNotNull(importedCred.getUserAttributes(), "Imported credential should have userAttributes"); + assertEquals(originalCred.getUserAttributes(), importedCred.getUserAttributes(), + "User attributes in verifiable credential " + originalCred.getCredentialScopeName() + " should be preserved during export/import"); + } + } private void assertUserCredentials(List userCreds, String... expectedCredentialNames) {