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
new file mode 100644
index 00000000000..87d20728f6c
--- /dev/null
+++ b/core/src/main/java/org/keycloak/representations/idm/oid4vc/UserVerifiableCredentialRepresentation.java
@@ -0,0 +1,32 @@
+package org.keycloak.representations.idm.oid4vc;
+
+public class UserVerifiableCredentialRepresentation {
+
+ private String credentialScopeName;
+ private String revision;
+ private Long createdDate;
+
+ public String getCredentialScopeName() {
+ return credentialScopeName;
+ }
+
+ public void setCredentialScopeName(String credentialScopeName) {
+ this.credentialScopeName = credentialScopeName;
+ }
+
+ public String getRevision() {
+ return revision;
+ }
+
+ public void setRevision(String revision) {
+ this.revision = revision;
+ }
+
+ public Long getCreatedDate() {
+ return createdDate;
+ }
+
+ public void setCreatedDate(Long createdDate) {
+ this.createdDate = createdDate;
+ }
+}
diff --git a/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/UserResource.java b/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/UserResource.java
index 26340692be3..0515f959fac 100644
--- a/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/UserResource.java
+++ b/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/UserResource.java
@@ -326,6 +326,13 @@ public interface UserResource {
@Path("consents/{client}")
void revokeConsent(@PathParam("client") String clientId);
+ /**
+ * @since Keycloak server 26.7.0
+ * @return {@link UserVerifiableCredentialResource} with further methods to deal with credentials and issued credentials of the user
+ */
+ @Path("vc")
+ UserVerifiableCredentialResource verifiableCredentials();
+
@POST
@Path("impersonation")
@Produces(MediaType.APPLICATION_JSON)
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
new file mode 100644
index 00000000000..d524f6baef2
--- /dev/null
+++ b/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/UserVerifiableCredentialResource.java
@@ -0,0 +1,38 @@
+package org.keycloak.admin.client.resource;
+
+import java.util.List;
+
+import jakarta.ws.rs.Consumes;
+import jakarta.ws.rs.DELETE;
+import jakarta.ws.rs.GET;
+import jakarta.ws.rs.POST;
+import jakarta.ws.rs.Path;
+import jakarta.ws.rs.PathParam;
+import jakarta.ws.rs.Produces;
+import jakarta.ws.rs.core.MediaType;
+
+import org.keycloak.representations.idm.oid4vc.UserVerifiableCredentialRepresentation;
+
+/**
+ * @since Keycloak 26.7.0 All the child endpoints are also available since that version
+ *
+ * This endpoint including all the child endpoints requires feature {@link org.keycloak.common.Profile.Feature#OID4VC_VCI} to be enabled and also requires "verifiable credentials" to be enabled for the realm
+ */
+public interface UserVerifiableCredentialResource {
+
+ @POST
+ @Path("credentials")
+ @Consumes({MediaType.APPLICATION_JSON})
+ UserVerifiableCredentialRepresentation createCredential(UserVerifiableCredentialRepresentation representation);
+
+ @GET
+ @Path("credentials")
+ @Produces(MediaType.APPLICATION_JSON)
+ List getCredentials();
+
+ @DELETE
+ @Path("credentials/{credentialScopeName}")
+ void revokeCredential(@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 5cc58b44e79..bed061bf905 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
@@ -48,6 +48,7 @@ import org.keycloak.models.UserConsentModel;
import org.keycloak.models.UserCredentialManager;
import org.keycloak.models.UserModel;
import org.keycloak.models.UserProvider;
+import org.keycloak.models.UserVerifiableCredentialModel;
import org.keycloak.models.cache.CachedUserModel;
import org.keycloak.models.cache.OnUserCache;
import org.keycloak.models.cache.UserCache;
@@ -853,6 +854,22 @@ public class UserCacheSession implements UserCache, OnCreateComponent, OnUpdateC
return consentModel;
}
+
+ @Override
+ public UserVerifiableCredentialModel addVerifiableCredential(String userId, UserVerifiableCredentialModel credentialModel) {
+ return getDelegate().addVerifiableCredential(userId, credentialModel);
+ }
+
+ @Override
+ public boolean removeVerifiableCredential(String userId, String credentialScopeName) {
+ return getDelegate().removeVerifiableCredential(userId, credentialScopeName);
+ }
+
+ @Override
+ public Stream getVerifiableCredentialsByUser(String userId) {
+ return getDelegate().getVerifiableCredentialsByUser(userId);
+ }
+
@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 a3814a2292e..2cd960fd615 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
@@ -39,8 +39,10 @@ import jakarta.persistence.criteria.JoinType;
import jakarta.persistence.criteria.Path;
import jakarta.persistence.criteria.Predicate;
import jakarta.persistence.criteria.Root;
+import jakarta.ws.rs.BadRequestException;
import org.keycloak.authorization.fgap.AdminPermissionsSchema;
+import org.keycloak.common.util.SecretGenerator;
import org.keycloak.common.util.Time;
import org.keycloak.component.ComponentModel;
import org.keycloak.connections.jpa.support.EntityManagers;
@@ -62,6 +64,7 @@ import org.keycloak.models.UserConsentModel;
import org.keycloak.models.UserCredentialManager;
import org.keycloak.models.UserModel;
import org.keycloak.models.UserProvider;
+import org.keycloak.models.UserVerifiableCredentialModel;
import org.keycloak.models.jpa.entities.CredentialEntity;
import org.keycloak.models.jpa.entities.FederatedIdentityEntity;
import org.keycloak.models.jpa.entities.UserAttributeEntity;
@@ -70,6 +73,7 @@ import org.keycloak.models.jpa.entities.UserConsentEntity;
import org.keycloak.models.jpa.entities.UserEntity;
import org.keycloak.models.jpa.entities.UserGroupMembershipEntity;
import org.keycloak.models.jpa.entities.UserRoleMappingEntity;
+import org.keycloak.models.jpa.entities.UserVerifiableCredentialEntity;
import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.storage.StorageId;
import org.keycloak.storage.UserStorageProvider;
@@ -161,6 +165,7 @@ public class JpaUserProvider implements UserProvider, UserCredentialStore, JpaUs
em.createNamedQuery("deleteUserGroupMembershipsByUser").setParameter("user", user).executeUpdate();
em.createNamedQuery("deleteUserConsentClientScopesByUser").setParameter("user", user).executeUpdate();
em.createNamedQuery("deleteUserConsentsByUser").setParameter("user", user).executeUpdate();
+ em.createNamedQuery("deleteVerifiableCredentialsByUser").setParameter("user", user).executeUpdate();
em.remove(user);
em.flush();
@@ -360,6 +365,60 @@ public class JpaUserProvider implements UserProvider, UserCredentialStore, JpaUs
em.flush();
}
+ @Override
+ public UserVerifiableCredentialModel addVerifiableCredential(String userId, UserVerifiableCredentialModel verifCredentialModel) {
+ if (verifCredentialModel.getCredentialScopeName() == null) {
+ throw new BadRequestException("Credential scope not specified");
+ }
+
+ UserVerifiableCredentialEntity vcEntity = new UserVerifiableCredentialEntity();
+ vcEntity.setId(KeycloakModelUtils.generateId());
+ vcEntity.setUser(em.getReference(UserEntity.class, userId));
+
+ String revision = verifCredentialModel.getRevision() == null ? SecretGenerator.getInstance().generateSecureID() : verifCredentialModel.getRevision();
+ vcEntity.setRevision(revision);
+
+ long createdDate = verifCredentialModel.getCreatedDate() == null ? Time.currentTimeMillis() : verifCredentialModel.getCreatedDate();
+ vcEntity.setCreatedDate(createdDate);
+
+ vcEntity.setCredentialScopeName(verifCredentialModel.getCredentialScopeName());
+ em.persist(vcEntity);
+ em.flush();
+
+ return toVerifiableCredentialModel(vcEntity);
+ }
+
+ @Override
+ public boolean removeVerifiableCredential(String userId, String credentialScopeName) {
+ UserVerifiableCredentialEntity found = getVerifiableCredentialsEntitiesByUser(userId)
+ .filter(vcEnt -> vcEnt.getCredentialScopeName().equals(credentialScopeName))
+ .findFirst()
+ .orElse(null);
+
+ if (found == null) return false;
+
+ em.remove(found);
+ em.flush();
+ return true;
+ }
+
+ @Override
+ public Stream getVerifiableCredentialsByUser(String userId) {
+ return getVerifiableCredentialsEntitiesByUser(userId).map(this::toVerifiableCredentialModel);
+ }
+
+ private Stream getVerifiableCredentialsEntitiesByUser(String userId) {
+ TypedQuery query = em.createNamedQuery("verifiableCredentialsByUser", UserVerifiableCredentialEntity.class);
+ query.setParameter("userId", userId);
+ return closing(query.getResultStream());
+ }
+
+ private UserVerifiableCredentialModel toVerifiableCredentialModel(UserVerifiableCredentialEntity entity) {
+ UserVerifiableCredentialModel model = new UserVerifiableCredentialModel(entity.getCredentialScopeName());
+ model.setRevision(entity.getRevision());
+ model.setCreatedDate(entity.getCreatedDate());
+ return model;
+ }
@Override
public void setNotBeforeForUser(RealmModel realm, UserModel user, int notBefore) {
@@ -395,6 +454,8 @@ public class JpaUserProvider implements UserProvider, UserCredentialStore, JpaUs
.setParameter("realmId", realm.getId()).executeUpdate();
em.createNamedQuery("deleteUserConsentsByRealm")
.setParameter("realmId", realm.getId()).executeUpdate();
+ em.createNamedQuery("deleteVerifiableCredentialsByRealm")
+ .setParameter("realmId", realm.getId()).executeUpdate();
em.createNamedQuery("deleteUserRoleMappingsByRealm")
.setParameter("realmId", realm.getId()).executeUpdate();
em.createNamedQuery("deleteUserRequiredActionsByRealm")
@@ -497,6 +558,9 @@ public class JpaUserProvider implements UserProvider, UserCredentialStore, JpaUs
em.createNamedQuery("deleteUserConsentClientScopesByClientScope")
.setParameter("scopeId", clientScope.getId())
.executeUpdate();
+ em.createNamedQuery("deleteVerifiableCredentialsByClientScope")
+ .setParameter("scopeName", clientScope.getName())
+ .executeUpdate();
}
@Override
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
new file mode 100644
index 00000000000..78422f29e04
--- /dev/null
+++ b/model/jpa/src/main/java/org/keycloak/models/jpa/entities/UserVerifiableCredentialEntity.java
@@ -0,0 +1,103 @@
+package org.keycloak.models.jpa.entities;
+
+import jakarta.persistence.Access;
+import jakarta.persistence.AccessType;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.Id;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.ManyToOne;
+import jakarta.persistence.NamedQueries;
+import jakarta.persistence.NamedQuery;
+import jakarta.persistence.Table;
+import jakarta.persistence.UniqueConstraint;
+
+@Entity
+@Table(name="USER_VER_CREDENTIAL", uniqueConstraints = {
+ @UniqueConstraint(columnNames = {"USER_ID", "CREDENTIAL_SCOPE_ID"})
+})
+@NamedQueries({
+ @NamedQuery(name="verifiableCredentialsByUser", query="select vc from UserVerifiableCredentialEntity vc where vc.user.id = :userId"),
+ @NamedQuery(name="deleteVerifiableCredentialsByRealm", query="delete from UserVerifiableCredentialEntity vc where vc.user IN (select user from UserEntity user where user.realmId = :realmId)"),
+ @NamedQuery(name="deleteVerifiableCredentialsByClientScope", query="delete from UserVerifiableCredentialEntity vc where vc.credentialScopeName = :scopeName"),
+ @NamedQuery(name="deleteVerifiableCredentialsByUser", query="delete from UserVerifiableCredentialEntity vc where vc.user = :user"),
+})
+public class UserVerifiableCredentialEntity {
+
+ @Id
+ @Column(name="ID", length = 36)
+ @Access(AccessType.PROPERTY) // we do this because relationships often fetch id, but not entity. This avoids an extra SQL
+ protected String id;
+
+ @ManyToOne(fetch= FetchType.LAZY)
+ @JoinColumn(name="USER_ID")
+ protected UserEntity user;
+
+ @Column(name="CREDENTIAL_SCOPE_NAME")
+ protected String credentialScopeName;
+
+ @Column(name="REVISION")
+ protected String revision;
+
+ @Column(name = "CREATED_DATE")
+ private Long createdDate;
+
+ public String getId() {
+ return id;
+ }
+
+ public void setId(String id) {
+ this.id = id;
+ }
+
+ public UserEntity getUser() {
+ return user;
+ }
+
+ public void setUser(UserEntity user) {
+ this.user = user;
+ }
+
+ public String getCredentialScopeName() {
+ return credentialScopeName;
+ }
+
+ public void setCredentialScopeName(String credentialScopeName) {
+ this.credentialScopeName = credentialScopeName;
+ }
+
+ public String getRevision() {
+ return revision;
+ }
+
+ public void setRevision(String revision) {
+ this.revision = revision;
+ }
+
+ public Long getCreatedDate() {
+ return createdDate;
+ }
+
+ public void setCreatedDate(Long createdDate) {
+ this.createdDate = createdDate;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null) return false;
+ if (!(o instanceof UserVerifiableCredentialEntity)) return false;
+
+ UserVerifiableCredentialEntity that = (UserVerifiableCredentialEntity) o;
+
+ if (!id.equals(that.getId())) return false;
+
+ return true;
+ }
+
+ @Override
+ public int hashCode() {
+ return id.hashCode();
+ }
+}
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 df063d370f1..f160a54c52f 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
@@ -45,4 +45,25 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/model/jpa/src/main/resources/default-persistence.xml b/model/jpa/src/main/resources/default-persistence.xml
index 16b08554e39..567bb52f222 100644
--- a/model/jpa/src/main/resources/default-persistence.xml
+++ b/model/jpa/src/main/resources/default-persistence.xml
@@ -45,6 +45,7 @@
org.keycloak.models.jpa.entities.ProtocolMapperEntity
org.keycloak.models.jpa.entities.UserConsentEntity
org.keycloak.models.jpa.entities.UserConsentClientScopeEntity
+ org.keycloak.models.jpa.entities.UserVerifiableCredentialEntity
org.keycloak.models.jpa.entities.AuthenticationFlowEntity
org.keycloak.models.jpa.entities.AuthenticationExecutionEntity
org.keycloak.models.jpa.entities.AuthenticatorConfigEntity
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 8b3ed490c63..693bc385ab6 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
@@ -54,6 +54,7 @@ import org.keycloak.models.UserCredentialManager;
import org.keycloak.models.UserManager;
import org.keycloak.models.UserModel;
import org.keycloak.models.UserProvider;
+import org.keycloak.models.UserVerifiableCredentialModel;
import org.keycloak.models.cache.CachedUserModel;
import org.keycloak.models.cache.OnUserCache;
import org.keycloak.models.cache.UserCache;
@@ -930,6 +931,33 @@ public class UserStorageManager extends AbstractStorageManager getVerifiableCredentialsByUser(String userId) {
+ if (StorageId.isLocalStorage(userId)) {
+ return localStorage().getVerifiableCredentialsByUser(userId);
+ } else {
+ throw new UnsupportedOperationException("Verifiable credential operations not yet supported on federated users");
+ }
+ }
+
@Override
public void setNotBeforeForUser(RealmModel realm, UserModel user, int notBefore) {
if (StorageId.isLocalStorage(user.getId())) {
diff --git a/server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java b/server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java
index 97717b5e94f..e3ad0c1777b 100755
--- a/server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java
+++ b/server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java
@@ -86,6 +86,7 @@ import org.keycloak.models.UserConsentModel;
import org.keycloak.models.UserCredentialModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.UserSessionModel;
+import org.keycloak.models.UserVerifiableCredentialModel;
import org.keycloak.models.WebAuthnPolicy;
import org.keycloak.models.credential.OTPCredentialModel;
import org.keycloak.models.light.LightweightUserAdapter;
@@ -126,6 +127,7 @@ import org.keycloak.representations.idm.authorization.ResourceOwnerRepresentatio
import org.keycloak.representations.idm.authorization.ResourceRepresentation;
import org.keycloak.representations.idm.authorization.ResourceServerRepresentation;
import org.keycloak.representations.idm.authorization.ScopeRepresentation;
+import org.keycloak.representations.idm.oid4vc.UserVerifiableCredentialRepresentation;
import org.keycloak.storage.StorageId;
import org.keycloak.util.JsonSerialization;
import org.keycloak.utils.StringUtil;
@@ -1033,6 +1035,14 @@ public class ModelToRepresentation {
return consentRep;
}
+ public static UserVerifiableCredentialRepresentation toRepresentation(UserVerifiableCredentialModel model) {
+ UserVerifiableCredentialRepresentation rep = new UserVerifiableCredentialRepresentation();
+ rep.setCredentialScopeName(model.getCredentialScopeName());
+ rep.setRevision(model.getRevision());
+ rep.setCreatedDate(model.getCreatedDate());
+ return rep;
+ }
+
public static AuthenticationFlowRepresentation toRepresentation(KeycloakSession session, RealmModel realm, AuthenticationFlowModel model) {
AuthenticationFlowRepresentation rep = new AuthenticationFlowRepresentation();
rep.setId(model.getId());
diff --git a/server-spi-private/src/main/java/org/keycloak/models/utils/RepresentationToModel.java b/server-spi-private/src/main/java/org/keycloak/models/utils/RepresentationToModel.java
index dbfa3d62799..1dbab1d2fc8 100755
--- a/server-spi-private/src/main/java/org/keycloak/models/utils/RepresentationToModel.java
+++ b/server-spi-private/src/main/java/org/keycloak/models/utils/RepresentationToModel.java
@@ -93,6 +93,7 @@ import org.keycloak.models.UserConsentModel;
import org.keycloak.models.UserCredentialModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.UserProvider;
+import org.keycloak.models.UserVerifiableCredentialModel;
import org.keycloak.models.credential.OTPCredentialModel;
import org.keycloak.models.credential.PasswordCredentialModel;
import org.keycloak.models.credential.dto.OTPCredentialData;
@@ -129,6 +130,7 @@ import org.keycloak.representations.idm.authorization.ResourceOwnerRepresentatio
import org.keycloak.representations.idm.authorization.ResourceRepresentation;
import org.keycloak.representations.idm.authorization.ResourceServerRepresentation;
import org.keycloak.representations.idm.authorization.ScopeRepresentation;
+import org.keycloak.representations.idm.oid4vc.UserVerifiableCredentialRepresentation;
import org.keycloak.storage.DatastoreProvider;
import org.keycloak.util.JsonSerialization;
import org.keycloak.utils.StringUtil;
@@ -984,6 +986,13 @@ public class RepresentationToModel {
return consentModel;
}
+ public static UserVerifiableCredentialModel toModel(UserVerifiableCredentialRepresentation rep) {
+ UserVerifiableCredentialModel verifCredentialModel = new UserVerifiableCredentialModel(rep.getCredentialScopeName());
+ verifCredentialModel.setRevision(rep.getRevision());
+ verifCredentialModel.setCreatedDate(rep.getCreatedDate());
+ return verifCredentialModel;
+ }
+
public static AuthenticationFlowModel toModel(AuthenticationFlowRepresentation rep) {
AuthenticationFlowModel model = new AuthenticationFlowModel();
model.setId(rep.getId());
diff --git a/server-spi/src/main/java/org/keycloak/models/UserProvider.java b/server-spi/src/main/java/org/keycloak/models/UserProvider.java
index a091f5f6e88..1765160a6a7 100755
--- a/server-spi/src/main/java/org/keycloak/models/UserProvider.java
+++ b/server-spi/src/main/java/org/keycloak/models/UserProvider.java
@@ -157,6 +157,32 @@ public interface UserProvider extends Provider,
*/
boolean revokeConsentForClient(RealmModel realm, String userId, String clientInternalId);
+ /**
+ * Create verifiable credential of specified credential scope for this user
+ *
+ * @param userId id of the user
+ * @param credentialModel credential model with "credentialScopeName" set. The other fields will be generated if not set
+ * @return credentialModel with all the fields set
+ */
+ UserVerifiableCredentialModel addVerifiableCredential(String userId, UserVerifiableCredentialModel credentialModel);
+
+ /**
+ * Remove verifiable credential of specified client scope from this user
+ *
+ * @param userId id if the user
+ * @param credentialScopeName credential scope name to delete
+ * @return true if credential was successfully removed. False otherwise
+ */
+ boolean removeVerifiableCredential(String userId, String credentialScopeName);
+
+ /**
+ * Return all verifiable credentials of specified user
+ *
+ * @param userId id if the user
+ * @return all verifiable credentials of specified user
+ */
+ Stream getVerifiableCredentialsByUser(String userId);
+
/* 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
new file mode 100644
index 00000000000..2915bb169d6
--- /dev/null
+++ b/server-spi/src/main/java/org/keycloak/models/UserVerifiableCredentialModel.java
@@ -0,0 +1,34 @@
+package org.keycloak.models;
+
+public class UserVerifiableCredentialModel {
+
+ private final String credentialScopeName;
+ private String revision;
+ private Long createdDate;
+
+ public UserVerifiableCredentialModel(String credentialScopeName) {
+ this.credentialScopeName = credentialScopeName;
+ }
+
+ public String getCredentialScopeName() {
+ return credentialScopeName;
+ }
+
+ public String getRevision() {
+ return revision;
+ }
+
+ public void setRevision(String revision) {
+ this.revision = revision;
+ }
+
+ public Long getCreatedDate() {
+ return createdDate;
+ }
+
+ public void setCreatedDate(Long createdDate) {
+ this.createdDate = createdDate;
+ }
+
+
+}
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
new file mode 100644
index 00000000000..c7b945e7b72
--- /dev/null
+++ b/services/src/main/java/org/keycloak/protocol/oid4vc/resources/admin/UserVerifiableCredentialResource.java
@@ -0,0 +1,164 @@
+package org.keycloak.protocol.oid4vc.resources.admin;
+
+import java.util.List;
+
+import jakarta.ws.rs.Consumes;
+import jakarta.ws.rs.DELETE;
+import jakarta.ws.rs.GET;
+import jakarta.ws.rs.NotFoundException;
+import jakarta.ws.rs.POST;
+import jakarta.ws.rs.Path;
+import jakarta.ws.rs.PathParam;
+import jakarta.ws.rs.Produces;
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.Response;
+
+import org.keycloak.common.Profile;
+import org.keycloak.constants.OID4VCIConstants;
+import org.keycloak.events.admin.OperationType;
+import org.keycloak.models.ClientScopeModel;
+import org.keycloak.models.KeycloakSession;
+import org.keycloak.models.ModelDuplicateException;
+import org.keycloak.models.ModelException;
+import org.keycloak.models.RealmModel;
+import org.keycloak.models.UserModel;
+import org.keycloak.models.UserVerifiableCredentialModel;
+import org.keycloak.models.utils.KeycloakModelUtils;
+import org.keycloak.models.utils.ModelToRepresentation;
+import org.keycloak.models.utils.RepresentationToModel;
+import org.keycloak.representations.idm.ErrorRepresentation;
+import org.keycloak.representations.idm.oid4vc.UserVerifiableCredentialRepresentation;
+import org.keycloak.services.ErrorResponse;
+import org.keycloak.services.resources.KeycloakOpenAPI;
+import org.keycloak.services.resources.admin.AdminEventBuilder;
+import org.keycloak.services.resources.admin.fgap.AdminPermissionEvaluator;
+
+import org.eclipse.microprofile.openapi.annotations.Operation;
+import org.eclipse.microprofile.openapi.annotations.media.Content;
+import org.eclipse.microprofile.openapi.annotations.media.Schema;
+import org.eclipse.microprofile.openapi.annotations.responses.APIResponse;
+import org.eclipse.microprofile.openapi.annotations.responses.APIResponses;
+import org.eclipse.microprofile.openapi.annotations.tags.Tag;
+import org.jboss.logging.Logger;
+import org.jboss.resteasy.reactive.NoCache;
+
+public class UserVerifiableCredentialResource {
+
+ private static final Logger logger = Logger.getLogger(UserVerifiableCredentialResource.class);
+
+ private final AdminPermissionEvaluator auth;
+ private final AdminEventBuilder adminEvent;
+ private final UserModel user;
+ private final KeycloakSession session;
+ private final RealmModel realm;
+
+ public UserVerifiableCredentialResource(KeycloakSession session, RealmModel realm, UserModel user, AdminPermissionEvaluator auth, AdminEventBuilder adminEvent) {
+ this.session = session;
+ this.realm = realm;
+ this.user = user;
+ this.auth = auth;
+ this.adminEvent = adminEvent;
+ }
+
+ @POST
+ @Path("credentials")
+ @Consumes({MediaType.APPLICATION_JSON})
+ @NoCache
+ @Tag(name = KeycloakOpenAPI.Admin.Tags.USERS)
+ @Operation(summary = "Create verifiable credential for the user. Once this is successful, user will be able to issue verifiable credentials of the credential type specified type afterwards")
+ @APIResponses(value = {
+ @APIResponse(responseCode = "201", description = "Created", 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 = "409", description = "Conflict", content = @Content(schema = @Schema(implementation = ErrorRepresentation.class)))
+ })
+ public UserVerifiableCredentialRepresentation createCredential(UserVerifiableCredentialRepresentation representation) {
+ auth.users().requireManage(user);
+ checkOid4VCIEnabled();
+
+ if (representation.getCreatedDate() != null) {
+ throw ErrorResponse.error("Created date not expected to be specified", Response.Status.BAD_REQUEST);
+ }
+ if (representation.getRevision() != null) {
+ throw ErrorResponse.error("Revision not expected to be specified", Response.Status.BAD_REQUEST);
+ }
+
+ ClientScopeModel clientScope = KeycloakModelUtils.getClientScopeByName(realm, representation.getCredentialScopeName());
+ if (clientScope == null) {
+ logger.warn(String.format("Client scope '%s' does not exists in the realm realm '%s'.", representation.getCredentialScopeName(),realm.getName()));
+ throw ErrorResponse.error("Client scope does not exists", Response.Status.BAD_REQUEST);
+ }
+ if (!OID4VCIConstants.OID4VC_PROTOCOL.equals(clientScope.getProtocol())) {
+ logger.warn(String.format("Client scope '%s' in the realm realm '%s' does not have protocol '%s'.",
+ representation.getCredentialScopeName(),realm.getName(), OID4VCIConstants.OID4VC_PROTOCOL));
+ throw ErrorResponse.error("Client scope has incorrect protocol", Response.Status.BAD_REQUEST);
+ }
+
+ try {
+ UserVerifiableCredentialModel modelToCreate = RepresentationToModel.toModel(representation);
+ UserVerifiableCredentialModel createdModel = session.users().addVerifiableCredential(user.getId(), modelToCreate);
+
+ UserVerifiableCredentialRepresentation createdRep = ModelToRepresentation.toRepresentation(createdModel);
+ adminEvent.operation(OperationType.CREATE).resourcePath(session.getContext().getUri()).representation(createdRep).success();
+ return createdRep;
+ } catch (ModelDuplicateException mde) {
+ logger.warn(String.format("Verifiable credential '%s' already exists for user '%s' in the realm '%s' for credential. Details: '%s'",
+ representation.getCredentialScopeName(), user.getUsername(), realm.getName(), mde.getMessage()));
+ throw ErrorResponse.exists("Verifiable credential already exists");
+ } catch (ModelException mde) {
+ logger.warn(String.format("Error when creating verifiable credential of type '%s' for user '%s' in the realm '%s'. Details: '%s'",
+ representation.getCredentialScopeName(), user.getUsername(), realm.getName(), mde.getMessage()), mde);
+ throw ErrorResponse.error("Error when creating verifiable credential", Response.Status.BAD_REQUEST);
+ }
+ }
+
+ @GET
+ @Path("credentials")
+ @Produces(MediaType.APPLICATION_JSON)
+ @Tag(name = KeycloakOpenAPI.Admin.Tags.USERS)
+ @Operation(summary = "Get verifiable credentials granted to the user")
+ @APIResponses(value = {
+ @APIResponse(responseCode = "200", description = "OK"),
+ @APIResponse(responseCode = "403", description = "Forbidden")
+ })
+ public List getCredentials() {
+ auth.users().requireView(user);
+ checkOid4VCIEnabled();
+
+ return session.users().getVerifiableCredentialsByUser(user.getId())
+ .map(ModelToRepresentation::toRepresentation)
+ .toList();
+ }
+
+ @DELETE
+ @Path("credentials/{credentialScopeName}")
+ @Operation(summary = "Revoke verifiable credential for particular user")
+ @APIResponses(value = {
+ @APIResponse(responseCode = "204", description = "No Content"),
+ @APIResponse(responseCode = "403", description = "Forbidden"),
+ @APIResponse(responseCode = "404", description = "Not Found")
+ })
+ public void revokeCredential(@PathParam("credentialScopeName") String credentialScopeName) {
+ auth.users().requireManage(user);
+ checkOid4VCIEnabled();
+
+ boolean removed = session.users().removeVerifiableCredential(user.getId(), credentialScopeName);
+ if (!removed) {
+ 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");
+ }
+
+ adminEvent.operation(OperationType.DELETE).resourcePath(session.getContext().getUri()).success();
+ }
+
+ private void checkOid4VCIEnabled() {
+ if (!Profile.isFeatureEnabled(Profile.Feature.OID4VC_VCI)) {
+ throw ErrorResponse.error("Feature " + Profile.Feature.OID4VC_VCI.getKey() + " not enabled", Response.Status.BAD_REQUEST);
+ }
+ if (!realm.isVerifiableCredentialsEnabled()) {
+ throw ErrorResponse.error("Verifiable credentials not enabled for the realm", Response.Status.BAD_REQUEST);
+ }
+ }
+
+}
diff --git a/services/src/main/java/org/keycloak/services/resources/admin/UserResource.java b/services/src/main/java/org/keycloak/services/resources/admin/UserResource.java
index e8e63608eb7..1df536ea729 100755
--- a/services/src/main/java/org/keycloak/services/resources/admin/UserResource.java
+++ b/services/src/main/java/org/keycloak/services/resources/admin/UserResource.java
@@ -92,6 +92,7 @@ import org.keycloak.models.utils.RoleUtils;
import org.keycloak.models.utils.SystemClientUtil;
import org.keycloak.organization.utils.Organizations;
import org.keycloak.policy.PasswordPolicyNotMetException;
+import org.keycloak.protocol.oid4vc.resources.admin.UserVerifiableCredentialResource;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.oidc.utils.RedirectUtils;
import org.keycloak.provider.ProviderFactory;
@@ -663,6 +664,11 @@ public class UserResource {
adminEvent.operation(OperationType.ACTION).resourcePath(session.getContext().getUri()).success();
}
+ @Path("vc")
+ public UserVerifiableCredentialResource verifiableCredentials() {
+ return new UserVerifiableCredentialResource(session, realm, user, auth, adminEvent);
+ }
+
/**
* Remove all user sessions associated with the 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 17e07a366a5..761cb8d5674 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
@@ -44,6 +44,7 @@ import org.keycloak.representations.idm.ProtocolMapperRepresentation;
import org.keycloak.representations.idm.RealmEventsConfigRepresentation;
import org.keycloak.representations.idm.RoleRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
+import org.keycloak.representations.idm.oid4vc.UserVerifiableCredentialRepresentation;
import org.keycloak.services.resources.admin.AdminAuth.Resource;
import org.keycloak.testframework.annotations.InjectRealm;
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
@@ -425,6 +426,13 @@ public class PermissionsTest extends AbstractPermissionsTest {
invoke(realm -> realm.users().get(user.getId()).removeFederatedIdentity("nosuch"), Resource.USER, true);
invoke(realm -> realm.users().get(user.getId()).getConsents(), Resource.USER, false);
invoke(realm -> realm.users().get(user.getId()).revokeConsent("testclient"), Resource.USER, true);
+
+ invoke(realm -> realm.users().get(user.getId()).verifiableCredentials().getCredentials(), Resource.USER, false);
+ 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().revokeCredential("nosuch"), Resource.USER, true);
+
invoke(realm -> realm.users().get(user.getId()).logout(), Resource.USER, true);
invoke(realm -> realm.users().get(user.getId()).resetPassword(CredentialBuilder.password("password").build()),
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
new file mode 100644
index 00000000000..2a243b02138
--- /dev/null
+++ b/tests/base/src/test/java/org/keycloak/tests/admin/user/UserVerifiableCredentialsTest.java
@@ -0,0 +1,258 @@
+package org.keycloak.tests.admin.user;
+
+import java.util.List;
+
+import jakarta.ws.rs.BadRequestException;
+import jakarta.ws.rs.ClientErrorException;
+import jakarta.ws.rs.core.Response;
+
+import org.keycloak.OAuth2Constants;
+import org.keycloak.admin.client.resource.RealmResource;
+import org.keycloak.admin.client.resource.UserResource;
+import org.keycloak.admin.client.resource.UserVerifiableCredentialResource;
+import org.keycloak.common.util.Time;
+import org.keycloak.constants.OID4VCIConstants;
+import org.keycloak.events.admin.OperationType;
+import org.keycloak.events.admin.ResourceType;
+import org.keycloak.representations.idm.ClientScopeRepresentation;
+import org.keycloak.representations.idm.ErrorRepresentation;
+import org.keycloak.representations.idm.RealmRepresentation;
+import org.keycloak.representations.idm.UserRepresentation;
+import org.keycloak.representations.idm.oid4vc.UserVerifiableCredentialRepresentation;
+import org.keycloak.testframework.annotations.InjectRealm;
+import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
+import org.keycloak.testframework.events.AdminEventAssertion;
+import org.keycloak.testframework.realm.ClientScopeBuilder;
+import org.keycloak.testframework.realm.ManagedRealm;
+import org.keycloak.testframework.realm.RealmBuilder;
+import org.keycloak.testframework.realm.RealmConfig;
+import org.keycloak.testframework.util.ApiUtil;
+import org.keycloak.tests.oid4vc.OID4VCIssuerTestBase;
+import org.keycloak.tests.suites.DatabaseTest;
+import org.keycloak.tests.utils.admin.AdminEventPaths;
+
+import org.junit.Assert;
+import org.junit.jupiter.api.Assertions;
+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.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+@KeycloakIntegrationTest(config = OID4VCIssuerTestBase.VCTestServerConfig.class)
+public class UserVerifiableCredentialsTest extends AbstractUserTest {
+
+ private static final String SCOPE_1_NAME = jwtTypeNaturalPersonScopeName;
+ private static final String SCOPE_2_NAME = sdJwtTypeNaturalPersonScopeName;
+
+ @InjectRealm(config = VCTestRealmConfig.class)
+ protected ManagedRealm testRealm;
+
+ @Test
+ @DatabaseTest
+ public void verifiableCredentialsCrud() {
+ String userId = createUser();
+ UserVerifiableCredentialResource user = managedRealm.admin().users().get(userId).verifiableCredentials();
+
+ // Empty list initially
+ assertTrue(user.getCredentials().isEmpty());
+
+ // Create first credential and assert it is present
+ createVerifiableCedential(user, userId ,SCOPE_1_NAME);
+ assertVerifiableCredentials(user.getCredentials(), SCOPE_1_NAME);
+
+ // Create second credential and assert both are present
+ createVerifiableCedential(user, userId, SCOPE_2_NAME);
+ assertVerifiableCredentials(user.getCredentials(), SCOPE_1_NAME, SCOPE_2_NAME);
+
+ // Remove one of credentials
+ user.revokeCredential(SCOPE_1_NAME);
+ AdminEventAssertion.assertEvent(adminEvents.poll(), OperationType.DELETE, AdminEventPaths.userVerifiableCredentialPath(userId, SCOPE_1_NAME), null, ResourceType.USER);
+ assertVerifiableCredentials(user.getCredentials(), SCOPE_2_NAME);
+
+ // Remove second one
+ user.revokeCredential(SCOPE_2_NAME);
+ AdminEventAssertion.assertEvent(adminEvents.poll(), OperationType.DELETE, AdminEventPaths.userVerifiableCredentialPath(userId, SCOPE_2_NAME), null, ResourceType.USER);
+ assertTrue(user.getCredentials().isEmpty());
+ }
+
+ @Test
+ @DatabaseTest
+ public void verifiableCredentialsConflict() {
+ String userId = createUser();
+ UserVerifiableCredentialResource user = managedRealm.admin().users().get(userId).verifiableCredentials();
+
+ createVerifiableCedential(user, userId, SCOPE_1_NAME);
+ try {
+ createVerifiableCedential(user, userId, SCOPE_1_NAME);
+ Assertions.fail("Not expected to successfully create verifiable credential of same name");
+ } catch (ClientErrorException cee) {
+ ErrorRepresentation error = cee.getResponse().readEntity(ErrorRepresentation.class);
+ assertEquals("Verifiable credential already exists", error.getErrorMessage());
+ assertEquals(409, cee.getResponse().getStatus());
+ }
+ }
+
+ @Test
+ @DatabaseTest
+ public void verifiableCredentialsClientScopeRemoved() {
+ String userId = createUser();
+ UserVerifiableCredentialResource user = managedRealm.admin().users().get(userId).verifiableCredentials();
+
+ ClientScopeRepresentation clientScopeRep = ClientScopeBuilder.create().name("new-scope").protocol(OID4VCIConstants.OID4VC_PROTOCOL).build();
+ Response resp = managedRealm.admin().clientScopes().create(clientScopeRep);
+ resp.close();
+ String clientScopeId = ApiUtil.getCreatedId(resp);
+ adminEvents.clear();
+
+ createVerifiableCedential(user, userId ,"new-scope");
+ assertVerifiableCredentials(user.getCredentials(), "new-scope");
+
+ // Remove client scope. Assert automatically removed from the user as well
+ managedRealm.admin().clientScopes().get(clientScopeId).remove();
+ assertVerifiableCredentials(user.getCredentials());
+ }
+
+ @Test
+ @DatabaseTest
+ public void verifiableCredentialsRealmRemoved() {
+ // Create new realm
+ RealmRepresentation realmRep = new RealmRepresentation();
+ realmRep.setRealm("new");
+ realmRep.setEnabled(true);
+ realmRep.setVerifiableCredentialsEnabled(true);
+ adminClient.realms().create(realmRep);
+
+ // Create user
+ RealmResource realm = adminClient.realm("new");
+ UserRepresentation user = new UserRepresentation();
+ user.setUsername("john");
+ user.setEmail("john@email.cz");
+ user.setEnabled(true);
+ Response response = realm.users().create(user);
+ String userId = ApiUtil.getCreatedId(response);
+ response.close();
+ UserResource userRes = realm.users().get(userId);
+
+ // Create verifiable credential
+ UserVerifiableCredentialRepresentation verifCred = new UserVerifiableCredentialRepresentation();
+ verifCred.setCredentialScopeName(SCOPE_1_NAME);
+ userRes.verifiableCredentials().createCredential(verifCred);
+
+ // Remove realm
+ realm.remove();
+ }
+
+ @Test
+ public void verifiableCredentialsDisabled() {
+ managedRealm.updateWithCleanup((realm) -> realm.verifiableCredentialsEnabled(false));
+ adminEvents.clear();
+
+ String userId = createUser();
+ UserVerifiableCredentialResource user = managedRealm.admin().users().get(userId).verifiableCredentials();
+
+ try {
+ createVerifiableCedential(user, userId, SCOPE_1_NAME);
+ Assertions.fail("Not expected to successfully create verifiable credential when disabled for the realm");
+ } catch (BadRequestException cee) {
+ ErrorRepresentation error = cee.getResponse().readEntity(ErrorRepresentation.class);
+ assertEquals("Verifiable credentials not enabled for the realm", error.getErrorMessage());
+ }
+ }
+
+ @Test
+ public void verifiableCredentialsClientScopeErrors() {
+ String userId = createUser();
+ UserVerifiableCredentialResource user = managedRealm.admin().users().get(userId).verifiableCredentials();
+
+ try {
+ createVerifiableCedential(user, userId, "non-existent");
+ Assertions.fail("Not expected to successfully create verifiable credential referencing unknown client scope");
+ } catch (BadRequestException cee) {
+ ErrorRepresentation error = cee.getResponse().readEntity(ErrorRepresentation.class);
+ assertEquals("Client scope does not exists", error.getErrorMessage());
+ }
+
+ try {
+ createVerifiableCedential(user, userId, OAuth2Constants.SCOPE_ADDRESS);
+ Assertions.fail("Not expected to successfully create verifiable credential of OIDC protocol");
+ } catch (BadRequestException cee) {
+ ErrorRepresentation error = cee.getResponse().readEntity(ErrorRepresentation.class);
+ assertEquals("Client scope has incorrect protocol", error.getErrorMessage());
+ }
+ }
+
+ @Test
+ public void verifiableCredentialsCreateErrors() {
+ String userId = createUser();
+ UserVerifiableCredentialResource user = managedRealm.admin().users().get(userId).verifiableCredentials();
+
+ try {
+ UserVerifiableCredentialRepresentation verifCred = new UserVerifiableCredentialRepresentation();
+ verifCred.setCredentialScopeName(SCOPE_1_NAME);
+ verifCred.setCreatedDate(Time.currentTimeMillis());
+ user.createCredential(verifCred);
+ Assertions.fail("Not expected to successfully create verifiable credential with filled createdDate");
+ } catch (BadRequestException cee) {
+ ErrorRepresentation error = cee.getResponse().readEntity(ErrorRepresentation.class);
+ assertEquals("Created date not expected to be specified", error.getErrorMessage());
+ }
+
+ try {
+ UserVerifiableCredentialRepresentation verifCred = new UserVerifiableCredentialRepresentation();
+ verifCred.setCredentialScopeName(SCOPE_1_NAME);
+ verifCred.setRevision("some-revision");
+ user.createCredential(verifCred);
+ Assertions.fail("Not expected to successfully create verifiable credential with filled revision");
+ } catch (BadRequestException cee) {
+ ErrorRepresentation error = cee.getResponse().readEntity(ErrorRepresentation.class);
+ assertEquals("Revision not expected to be specified", error.getErrorMessage());
+ }
+ }
+
+ private void createVerifiableCedential(UserVerifiableCredentialResource user, String userId, String clientScopeName) {
+ UserVerifiableCredentialRepresentation verifCred = new UserVerifiableCredentialRepresentation();
+ verifCred.setCredentialScopeName(clientScopeName);
+ UserVerifiableCredentialRepresentation createdRep = user.createCredential(verifCred);
+
+ Assert.assertEquals(clientScopeName, createdRep.getCredentialScopeName());
+ Assert.assertNotNull(createdRep.getCreatedDate());
+ Assert.assertNotNull(createdRep.getRevision());
+ AdminEventAssertion.assertEvent(adminEvents.poll(), OperationType.CREATE, AdminEventPaths.userVerifiableCredentialsPath(userId), createdRep, ResourceType.USER);
+ }
+
+ private void assertVerifiableCredentials(List creds, String... expectedCredentialNames) {
+ List verifCredNames = creds.stream()
+ .map(UserVerifiableCredentialRepresentation::getCredentialScopeName)
+ .sorted()
+ .toList();
+
+ if (expectedCredentialNames == null || expectedCredentialNames.length == 0) {
+ assertTrue(verifCredNames.isEmpty(), "Expected empty list of verifiable credentials, but was " + verifCredNames);
+ } else {
+ assertEquals(expectedCredentialNames.length, verifCredNames.size());
+ assertTrue(verifCredNames.containsAll(List.of(expectedCredentialNames)), "Expected verifiable credentials " + List.of(expectedCredentialNames) + ", but was " + verifCredNames);
+ }
+ }
+
+
+ private static class VCTestRealmConfig implements RealmConfig {
+
+ public static final String TEST_REALM_NAME = "test";
+
+ @Override
+ public RealmBuilder configure(RealmBuilder realm) {
+ realm.name(TEST_REALM_NAME)
+ .eventsEnabled(true);
+
+ realm.eventsListeners("jboss-logging");
+ realm.verifiableCredentialsEnabled(true);
+ return realm;
+ }
+
+ }
+
+
+}
diff --git a/tests/utils/src/main/java/org/keycloak/tests/utils/admin/AdminEventPaths.java b/tests/utils/src/main/java/org/keycloak/tests/utils/admin/AdminEventPaths.java
index 015873044c0..b7886e01db5 100644
--- a/tests/utils/src/main/java/org/keycloak/tests/utils/admin/AdminEventPaths.java
+++ b/tests/utils/src/main/java/org/keycloak/tests/utils/admin/AdminEventPaths.java
@@ -41,6 +41,7 @@ import org.keycloak.admin.client.resource.RoleMappingResource;
import org.keycloak.admin.client.resource.RoleResource;
import org.keycloak.admin.client.resource.RolesResource;
import org.keycloak.admin.client.resource.UserResource;
+import org.keycloak.admin.client.resource.UserVerifiableCredentialResource;
import org.keycloak.admin.client.resource.UsersResource;
/**
@@ -283,6 +284,22 @@ public class AdminEventPaths {
return uri.toString();
}
+ public static String userVerifiableCredentialsPath(String userId) {
+ URI uri = UriBuilder.fromUri(userResourcePath(userId))
+ .path(UserResource.class, "verifiableCredentials")
+ .path(UserVerifiableCredentialResource.class, "getCredentials")
+ .build();
+ return uri.toString();
+ }
+
+ public static String userVerifiableCredentialPath(String userId, String credentialScopeName) {
+ URI uri = UriBuilder.fromUri(userResourcePath(userId))
+ .path(UserResource.class, "verifiableCredentials")
+ .path(UserVerifiableCredentialResource.class, "revokeCredential")
+ .build(credentialScopeName);
+ return uri.toString();
+ }
+
// IDENTITY PROVIDERS
public static String identityProvidersPath() {