Support for user attributes and updating them (#49066)

Closes #48578


Signed-off-by: Jimmy Chakkalakal <jimmy.chakkalakal@ibm.com>
This commit is contained in:
jimmychakkalakal 2026-05-21 07:42:11 +01:00 committed by GitHub
parent 27262be569
commit 5778a322fc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 246 additions and 6 deletions

View file

@ -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<String, List<String>> userAttributes;
public String getCredentialScopeName() {
return credentialScopeName;
@ -32,15 +36,22 @@ public class UserVerifiableCredentialRepresentation {
this.createdDate = createdDate;
}
public Map<String, List<String>> getUserAttributes() { return userAttributes; }
public void setUserAttributes(Map<String, List<String>> 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);
}
}

View file

@ -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
}

View file

@ -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())) {

View file

@ -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<String, List<String>> 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<UserVerifiableCredentialEntity> 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<String, List<String>> 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<UserVerifiableCredentialEntity> getVerifiableCredentialsEntitiesByUser(String userId) {
TypedQuery<UserVerifiableCredentialEntity> query = em.createNamedQuery("verifiableCredentialsByUser", UserVerifiableCredentialEntity.class);
TypedQuery<UserVerifiableCredentialEntity> query = getVerifiableCredentialsEntitiesByUser();
query.setParameter("userId", userId);
return closing(query.getResultStream());
}
private TypedQuery<UserVerifiableCredentialEntity> 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<Map<String, List<String>>> typeRef = new TypeReference<>() {};
Map<String, List<String>> attrs = JsonSerialization.readValue(entity.getUserAttributes(), typeRef);
model.setUserAttributes(attrs);
} catch (IOException e) {
throw new ModelException("Failed to deserialize user attributes", e);
}
}
return model;
}

View file

@ -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;

View file

@ -60,6 +60,9 @@
<constraints nullable="false"/>
</column>
<column name="CREATED_DATE" type="BIGINT"/>
<column name="USER_ATTRIBUTES" type="TEXT">
<constraints nullable="true"/>
</column>
</createTable>
<addPrimaryKey columnNames="ID" constraintName="CONSTRAINT_VCRED_PM" tableName="USER_VER_CREDENTIAL"/>
<addForeignKeyConstraint baseColumnNames="USER_ID" baseTableName="USER_VER_CREDENTIAL" constraintName="FK_VCRED_USER" referencedColumnNames="ID" referencedTableName="USER_ENTITY"/>

View file

@ -940,6 +940,15 @@ public class UserStorageManager extends AbstractStorageManager<UserStorageProvid
}
}
@Override
public UserVerifiableCredentialModel updateVerifiableCredential(String userId, String credentialScopeName) {
if (StorageId.isLocalStorage(userId)) {
return localStorage().updateVerifiableCredential(userId, credentialScopeName);
} else {
throw new UnsupportedOperationException("Verifiable credential operations not yet supported on federated users");
}
}
@Override
public boolean removeVerifiableCredential(String userId, String credentialScopeName) {
if (StorageId.isLocalStorage(userId)) {

View file

@ -1042,6 +1042,7 @@ public class ModelToRepresentation {
rep.setCredentialScopeName(model.getCredentialScopeName());
rep.setRevision(model.getRevision());
rep.setCreatedDate(model.getCreatedDate());
rep.setUserAttributes(model.getUserAttributes());
return rep;
}

View file

@ -998,6 +998,7 @@ public class RepresentationToModel {
UserVerifiableCredentialModel verifCredentialModel = new UserVerifiableCredentialModel(rep.getCredentialScopeName());
verifCredentialModel.setRevision(rep.getRevision());
verifCredentialModel.setCreatedDate(rep.getCreatedDate());
verifCredentialModel.setUserAttributes(rep.getUserAttributes());
return verifCredentialModel;
}

View file

@ -183,6 +183,16 @@ public interface UserProvider extends Provider,
*/
Stream<UserVerifiableCredentialModel> 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 */
/**

View file

@ -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<String, List<String>> userAttributes;
public UserVerifiableCredentialModel(String credentialScopeName) {
this.credentialScopeName = credentialScopeName;
@ -30,5 +34,7 @@ public class UserVerifiableCredentialModel {
this.createdDate = createdDate;
}
public Map<String, List<String>> getUserAttributes() { return userAttributes; }
public void setUserAttributes(Map<String, List<String>> userAttributes) { this.userAttributes = userAttributes; }
}

View file

@ -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")

View file

@ -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);

View file

@ -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<UserVerifiableCredentialRepresentation> 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<UserVerifiableCredentialRepresentation> creds, String... expectedCredentialNames) {

View file

@ -64,6 +64,10 @@ public class OID4VCExportImportTest extends OID4VCIssuerTestBase {
List<UserVerifiableCredentialRepresentation> 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<UserVerifiableCredentialRepresentation> importedVerifiableCreds = testRealm.admin().users().get(john.getId()).verifiableCredentials().getCredentials();
assertEquals(verifiableCreds, importedVerifiableCreds);
UserRepresentation userAfterImport = testRealm.admin().users().search(TEST_USER).stream().findFirst().orElseThrow();
List<UserVerifiableCredentialRepresentation> 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<UserVerifiableCredentialRepresentation> userCreds, String... expectedCredentialNames) {