ncr demo · keycloak/keycloak#48668 on GitHub · ← all examples ·
what to notice The Liquibase migration appears only after you have met the endpoint and SPI contract it serves.

[OID4VCI] Support for credentials CRUD (DB and admin REST endpoints) · #48668

56/56 blocks placed ✓

This PR adds CRUD support for verifiable credentials (OID4VCI) on users: a new USER_VER_CREDENTIAL DB table, a UserProvider SPI extension, a new admin REST sub-resource mounted at GET/POST/DELETE /users/{id}/vc/credentials, and integration tests. Read outside-in: REST contract first, then the server-side endpoint, then the storage stack (UserProvider → JPA entity/queries), and finally tests.

REST contract: UserVerifiableCredentialRepresentation and client-side resource interface

0 Contract UserVerifiableCredentialRepresentation New wire representation for a verifiable credential — holds credentialScopeName, revision, and createdDate
New wire representation for a verifiable credential — holds credentialScopeName, revision, and createdDate
core/src/main/java/org/keycloak/representations/idm/oid4vc/UserVerifiableCredentialRepresentation.java · b001 b002 · Outward-facing data contract; shared between client and server
+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;+    }+}
0 Contract UserVerifiableCredentialResource (admin-client interface) New admin-client proxy interface exposing createCredential, getCredentials, and revokeCredential under path /vc/credentials; requires OID4VC_VCI feature and verifiable credentials enabled on the realm
New admin-client proxy interface exposing createCredential, getCredentials, and revokeCredential under path /vc/credentials; requires OID4VC_VCI feature and verifiable credentials enabled on the realm
integration/admin-client/src/main/java/org/keycloak/admin/client/resource/UserVerifiableCredentialResource.java · b004 b005 b006 b007 b008 · Admin-client JAX-RS proxy interface, consumed by callers before any server code
+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<p>+ *+ * 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<p>+ */+public interface UserVerifiableCredentialResource {+ +    @POST+    @Path("credentials")+    @Consumes({MediaType.APPLICATION_JSON})+    UserVerifiableCredentialRepresentation createCredential(UserVerifiableCredentialRepresentation representation);+ +    @GET+    @Path("credentials")+    @Produces(MediaType.APPLICATION_JSON)+    List<UserVerifiableCredentialRepresentation> getCredentials();+ +    @DELETE+    @Path("credentials/{credentialScopeName}")+    void revokeCredential(@PathParam("credentialScopeName") String credentialScopeName);+ +    // TODO: Issued credentials+}
0 Contract UserResource.verifiableCredentials() (admin-client) Adds the @Path("vc") sub-resource accessor to the admin-client UserResource interface
Adds the @Path("vc") sub-resource accessor to the admin-client UserResource interface
integration/admin-client/src/main/java/org/keycloak/admin/client/resource/UserResource.java · b003 · Entry point on the existing admin-client UserResource interface
     @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)

Admin REST entrypoint: wiring UserVerifiableCredentialResource into UserResource

1 Entrypoint UserResource.verifiableCredentials() (server) Mounts the new UserVerifiableCredentialResource at @Path("vc") on the existing admin UserResource
Mounts the new UserVerifiableCredentialResource at @Path("vc") on the existing admin UserResource
services/src/main/java/org/keycloak/services/resources/admin/UserResource.java · b043 b044 · Server-side entrypoint that instantiates and delegates to the new sub-resource
 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;⋯         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      *
1 Entrypoint UserVerifiableCredentialResource (server implementation) Implements createCredential, getCredentials, and revokeCredential. createCredential validates that the named client scope exists and has the oid4vc protocol, then delegates to session.users(). All three methods guard on OID4VC_VCI feature flag and realm.isVerifiableCredentialsEnabled(). Auto-generates revision and createdDate if not supplied; rejects caller-supplied values to prevent clock skew / replay attacks.
Implements createCredential, getCredentials, and revokeCredential. createCredential validates that the named client scope exists and has the oid4vc protocol, then delegates to session.users(). All three methods guard on OID4VC_VCI feature flag and realm.isVerifiableCredentialsEnabled(). Auto-generates revision and createdDate if not supplied; rejects caller-supplied values to prevent clock skew / replay attacks.
services/src/main/java/org/keycloak/protocol/oid4vc/resources/admin/UserVerifiableCredentialResource.java · b037 b038 b039 b040 b041 b042 · The core application-layer handler for the new endpoints
+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<UserVerifiableCredentialRepresentation> 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);+        }+    }+ +}

Model/SPI layer: UserVerifiableCredentialModel and UserProvider contract

2 Application UserVerifiableCredentialModel New model class carrying the same three fields as the representation; credentialScopeName is immutable (constructor-only)
New model class carrying the same three fields as the representation; credentialScopeName is immutable (constructor-only)
server-spi/src/main/java/org/keycloak/models/UserVerifiableCredentialModel.java · b035 b036 · Internal domain model; consumed by provider implementations
+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;+    }+ + +}
2 Application UserProvider (SPI contract) Declares addVerifiableCredential, removeVerifiableCredential, and getVerifiableCredentialsByUser on UserProvider; Javadoc clarifies field-generation contract
Declares addVerifiableCredential, removeVerifiableCredential, and getVerifiableCredentialsByUser on UserProvider; Javadoc clarifies field-generation contract
server-spi/src/main/java/org/keycloak/models/UserProvider.java · b034 · SPI interface extended with three new methods
      */     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<UserVerifiableCredentialModel> getVerifiableCredentialsByUser(String userId);+      /* FEDERATED IDENTITIES methods */       /**
2 Application ModelToRepresentation.toRepresentation(UserVerifiableCredentialModel) Adds model-to-representation conversion
Adds model-to-representation conversion
server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java · b028 b029 b030 · Model→wire conversion utility
 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;⋯ 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;⋯         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());
2 Application RepresentationToModel.toModel(UserVerifiableCredentialRepresentation) Adds representation-to-model conversion
Adds representation-to-model conversion
server-spi-private/src/main/java/org/keycloak/models/utils/RepresentationToModel.java · b031 b032 b033 · Wire→model conversion utility
 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;⋯ 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;⋯         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());

JPA persistence: DB schema, entity, and JpaUserProvider implementation

3 Domain jpa-changelog-26.7.0.xml Liquibase changeset creating USER_VER_CREDENTIAL table with PK, FK to USER_ENTITY, and unique constraint on (CREDENTIAL_SCOPE_NAME, USER_ID)
Liquibase changeset creating USER_VER_CREDENTIAL table with PK, FK to USER_ENTITY, and unique constraint on (CREDENTIAL_SCOPE_NAME, USER_ID)
model/jpa/src/main/resources/META-INF/jpa-changelog-26.7.0.xml · b024 · Database migration; must be read before the entity to understand the table shape
         </delete>     </changeSet>  +    <changeSet author="keycloak" id="26.7.0-verifiable-credential">+        <createTable tableName="USER_VER_CREDENTIAL">+            <column name="ID" type="VARCHAR(36)">+                <constraints nullable="false"/>+            </column>+            <column name="CREDENTIAL_SCOPE_NAME" type="VARCHAR(255)">+                <constraints nullable="false"/>+            </column>+            <column name="USER_ID" type="VARCHAR(36)">+                <constraints nullable="false"/>+            </column>+            <column name="REVISION" type="VARCHAR(36)">+                <constraints nullable="false"/>+            </column>+            <column name="CREATED_DATE" type="BIGINT"/>+        </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"/>+        <addUniqueConstraint columnNames="CREDENTIAL_SCOPE_NAME, USER_ID" constraintName="UK_KKUWUVD67ONTGSUGOGM8UEWRE" tableName="USER_VER_CREDENTIAL"/>+    </changeSet>+  </databaseChangeLog>
3 Domain UserVerifiableCredentialEntity JPA entity with four named queries: fetch-by-user, bulk-delete by user/realm/clientScope. The unique constraint mirrors the DB schema, enforcing one credential per scope per user.
JPA entity with four named queries: fetch-by-user, bulk-delete by user/realm/clientScope. The unique constraint mirrors the DB schema, enforcing one credential per scope per user.
model/jpa/src/main/java/org/keycloak/models/jpa/entities/UserVerifiableCredentialEntity.java · b019 b020 b021 b022 b023 · JPA entity mapping the new table
+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();+    }+}
3 Domain default-persistence.xml Registers UserVerifiableCredentialEntity in the default persistence unit
Registers UserVerifiableCredentialEntity in the default persistence unit
model/jpa/src/main/resources/default-persistence.xml · b025 · Required registration of the new entity with the persistence unit
         <class>org.keycloak.models.jpa.entities.ProtocolMapperEntity</class>         <class>org.keycloak.models.jpa.entities.UserConsentEntity</class>         <class>org.keycloak.models.jpa.entities.UserConsentClientScopeEntity</class>+        <class>org.keycloak.models.jpa.entities.UserVerifiableCredentialEntity</class>         <class>org.keycloak.models.jpa.entities.AuthenticationFlowEntity</class>         <class>org.keycloak.models.jpa.entities.AuthenticationExecutionEntity</class>         <class>org.keycloak.models.jpa.entities.AuthenticatorConfigEntity</class>
3 Domain JpaUserProvider (verifiable credential methods + preRemove cleanup) Implements add/remove/get for verifiable credentials. addVerifiableCredential auto-generates revision (SecretGenerator) and createdDate (Time.currentTimeMillis) when absent. preRemove hooks delete credentials when a user, realm, or client scope is removed — preventing orphans.
Implements add/remove/get for verifiable credentials. addVerifiableCredential auto-generates revision (SecretGenerator) and createdDate (Time.currentTimeMillis) when absent. preRemove hooks delete credentials when a user, realm, or client scope is removed — preventing orphans.
model/jpa/src/main/java/org/keycloak/models/jpa/JpaUserProvider.java · b011 b012 b013 b014 b015 b016 b017 b018 · Core JPA implementation of the new UserProvider methods
 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.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;⋯ 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;⋯ 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;⋯         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();⋯         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<UserVerifiableCredentialModel> getVerifiableCredentialsByUser(String userId) {+        return getVerifiableCredentialsEntitiesByUser(userId).map(this::toVerifiableCredentialModel);+    }+ +    private Stream<UserVerifiableCredentialEntity> getVerifiableCredentialsEntitiesByUser(String userId) {+        TypedQuery<UserVerifiableCredentialEntity> 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) {⋯                 .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")⋯         em.createNamedQuery("deleteUserConsentClientScopesByClientScope")                 .setParameter("scopeId", clientScope.getId())                 .executeUpdate();+        em.createNamedQuery("deleteVerifiableCredentialsByClientScope")+                .setParameter("scopeName", clientScope.getName())+                .executeUpdate();     }       @Override

Cache and storage-manager pass-through

4 Adapter UserCacheSession (Infinispan) Forwards all three verifiable-credential methods directly to the delegate. No caching is added; noted as a follow-up.
Forwards all three verifiable-credential methods directly to the delegate. No caching is added; noted as a follow-up.
model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/UserCacheSession.java · b009 b010 · Cache layer adapter; pass-through without caching for now
 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;⋯         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<UserVerifiableCredentialModel> getVerifiableCredentialsByUser(String userId) {+        return getDelegate().getVerifiableCredentialsByUser(userId);+    }+      @Override     public void setNotBeforeForUser(RealmModel realm, UserModel user, int notBefore) {         if (!isRegisteredForInvalidation(realm, user.getId())) {
4 Adapter UserStorageManager Routes all three methods to localStorage() for local users; explicitly throws UnsupportedOperationException for federated users (acknowledged as a known limitation with follow-up planned)
Routes all three methods to localStorage() for local users; explicitly throws UnsupportedOperationException for federated users (acknowledged as a known limitation with follow-up planned)
model/storage-private/src/main/java/org/keycloak/storage/UserStorageManager.java · b026 b027 · Storage manager adapter; routing layer between local and federated storage
 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;⋯         }     }  +    @Override+    public UserVerifiableCredentialModel addVerifiableCredential(String userId, UserVerifiableCredentialModel credentialModel) {+        if (StorageId.isLocalStorage(userId)) {+            return localStorage().addVerifiableCredential(userId, credentialModel);+        } 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)) {+            return localStorage().removeVerifiableCredential(userId, credentialScopeName);+        } else {+            throw new UnsupportedOperationException("Verifiable credential operations not yet supported on federated users");+        }+    }+ +    @Override+    public Stream<UserVerifiableCredentialModel> 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())) {

Tests: CRUD, error paths, and permission coverage

5 Cross-cutting AdminEventPaths.userVerifiableCredential* Adds URI builder helpers for the two new admin event resource paths
Adds URI builder helpers for the two new admin event resource paths
tests/utils/src/main/java/org/keycloak/tests/utils/admin/AdminEventPaths.java · b055 b056 · Test utility; needed to verify admin event paths in tests
 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;   /**⋯         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() {
5 Cross-cutting UserVerifiableCredentialsTest Covers: full CRUD lifecycle, conflict (duplicate scope), automatic cascade-delete when a client scope is removed, cascade-delete when a realm is removed, 400 when feature/realm flag disabled, 400 for unknown or non-OID4VC scope, and 400 for caller-supplied createdDate or revision.
Covers: full CRUD lifecycle, conflict (duplicate scope), automatic cascade-delete when a client scope is removed, cascade-delete when a realm is removed, 400 when feature/realm flag disabled, 400 for unknown or non-OID4VC scope, and 400 for caller-supplied createdDate or revision.
tests/base/src/test/java/org/keycloak/tests/admin/user/UserVerifiableCredentialsTest.java · b047 b048 b049 b050 b051 b052 b053 b054 · Integration tests for the new capability
+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<UserVerifiableCredentialRepresentation> creds, String... expectedCredentialNames) {+        List<String> 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;+        }+ +    }+ + +}
5 Cross-cutting PermissionsTest (verifiable credentials) Asserts that getCredentials requires USER read permission and createCredential/revokeCredential require USER manage permission
Asserts that getCredentials requires USER read permission and createCredential/revokeCredential require USER manage permission
tests/base/src/test/java/org/keycloak/tests/admin/PermissionsTest.java · b045 b046 · Verifies RBAC enforcement on the new endpoints
 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;⋯         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);