8000 Issues/48578 support for user attributes and updating them by jimmychakkalakal · Pull Request #49066 · keycloak/keycloak · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -32,15 +36,22 @@ public void setCreatedDate(Long createdDate) {
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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
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;
Expand Down Expand Up @@ -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);
Comment on lines +39 to +41
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I’m not sure if it would be better to use a different name for this API and keep the PUT on /credentials for generic updates since we’ll probably need it to update other things like the revision. @mposolda WDYT?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For me, the updateCredential works. Maybe there is better name, but don't have better ideas.

For example: Is refreshCredential better name or is it rather more confusing? To me, it is rather more confusing (as it can imply it has something to do with "refresh tokens" and "refreshing verifiable credentials" as described in the OID4VCI specs, which is not the case)

Another name like regenerateCredential also might be confusing IMO...

So my vote is to likely keep as it is.


// TODO: Issued credentials
}
Original file line number Diff line number Diff line change
Expand Up @@ -870,6 +870,11 @@ public Stream<UserVerifiableCredentialModel> getVerifiableCredentialsByUser(Stri
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())) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -79,8 +80,11 @@
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;
Expand Down Expand Up @@ -382,6 +386,21 @@ public UserVerifiableCredentialModel addVerifiableCredential(String userId, User
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();

Expand Down Expand Up @@ -409,16 +428,63 @@ public Stream<UserVerifiableCredentialModel> getVerifiableCredential 10000 sByUser(Stri
.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()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems to me that this could be simplified further to call just:

UserVerifiableCredentialEntity entity = getVerifiableCredentialsEntitiesByUser(userId)

without a need to manually create query above. However that is a nitpick, which can be possibly addressed in a follow-up. Not a blocker for this PR IMO.

.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;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -83,6 +86,10 @@ public void setCreatedDate(Long createdDate) {
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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -940,6 +940,15 @@ public UserVerifiableCredentialModel addVerifiableCredential(String userId, User
}
}

@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)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1042,6 +1042,7 @@ public static UserVerifiableCredentialRepresentation toRepresentation(UserVerifi
rep.setCredentialScopeName(model.getCredentialScopeName());
rep.setRevision(model.getRevision());
rep.setCreatedDate(model.getCreatedDate());
rep.setUserAttributes(model.getUserAttributes());
@mposolda mposolda May 19, 2026 •
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think similar change might be needed in the RepresentationToModel class as well. Is it also possible to slightly test this in OID4VCExportImportTest and make sure that export/import works for this new column as expected? (Probably no need to introduce new method in that test class, but sufficient to just re-use existing methods and test export/import of "attributes" works as expected).

@jimmychakkalakal jimmychakkalakal May 20, 2026 •
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the comment, I have extended RepresentationToModel for User attributes and modified test in OID4VCExportImportTest to test import and export of user attributes

return rep;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -998,6 +998,7 @@ public static UserVerifiableCredentialModel toModel(UserVerifiableCredentialRepr
UserVerifiableCredentialModel verifCredentialModel = new UserVerifiableCredentialModel(rep.getCredentialScopeName());
verifCredentialModel.setRevision(rep.getRevision());
verifCredentialModel.setCreatedDate(rep.getCreatedDate());
verifCredentialModel.setUserAttributes(rep.getUserAttributes());
return verifCredentialModel;
}

Expand Down
10 changes: 10 additions & 0 deletions server-spi/src/main/java/org/keycloak/models/UserProvider.java
Original file line number Diff line number Diff line change
Expand Up @@ -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 */

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -30,5 +34,7 @@ public void setCreatedDate(Long createdDate) {
this.createdDate = createdDate;
}

public Map<String, List<String>> getUserAttributes() { return userAttributes; }

public void setUserAttributes(Map<String, List<String>> userAttributes) { this.userAttributes = userAttributes; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
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;
Expand Down Expand Up @@ -130,6 +131,41 @@ public List<UserVerifiableCredentialRepresentation> getCredentials() {
.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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,7 @@ public void users() {
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);
Expand Down
Loading
Loading
0