feat(oid4vci): add issued VC tracking and admin REST endpoint

- Add database schema for tracking issued verifiable credentials
- Implement admin REST API endpoints for managing issued credentials
- Add support for credential revocation tracking
- Include tests for new functionality

Fixes #46204

Signed-off-by: Jimmy Chakkalakal <jimmy.chakkalakal@ibm.com>
This commit is contained in:
Jimmy Chakkalakal 2026-05-22 15:38:21 +01:00
parent a0a4156d04
commit eba0ef3c3b
17 changed files with 815 additions and 1 deletions

View file

@ -0,0 +1,84 @@
package org.keycloak.representations.idm.oid4vc;
import java.util.Objects;
public class IssuedVerifiableCredentialRepresentation {
private String id;
private String userId;
private String credentialType;
private Long issuedAt;
private Long expiresAt;
// This represents UUID of the client, which acts as OID4VCI wallet
private String clientId;
private String revision;
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public String getUserId() {
return userId;
}
public void setUserId(String userId) {
this.userId = userId;
}
public String getCredentialType() {
return credentialType;
}
public void setCredentialType(String credentialType) {
this.credentialType = credentialType;
}
public Long getIssuedAt() {
return issuedAt;
}
public void setIssuedAt(Long issuedAt) {
this.issuedAt = issuedAt;
}
public Long getExpiresAt() {
return expiresAt;
}
public void setExpiresAt(Long expiresAt) {
this.expiresAt = expiresAt;
}
public String getClientId() {
return clientId;
}
public void setClientId(String clientId) {
this.clientId = clientId;
}
public String getRevision() {
return revision;
}
public void setRevision(String revision) {
this.revision = revision;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
IssuedVerifiableCredentialRepresentation that = (IssuedVerifiableCredentialRepresentation) o;
return Objects.equals(id, that.id);
}
@Override
public int hashCode() {
return Objects.hash(id);
}
}

View file

@ -12,6 +12,7 @@ import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import org.keycloak.representations.idm.oid4vc.IssuedVerifiableCredentialRepresentation;
import org.keycloak.representations.idm.oid4vc.UserVerifiableCredentialRepresentation;
/**
@ -40,5 +41,8 @@ public interface UserVerifiableCredentialResource {
@Produces(MediaType.APPLICATION_JSON)
UserVerifiableCredentialRepresentation updateCredential(@PathParam("credentialScopeName") String credentialScopeName);
// TODO: Issued credentials
@GET
@Path("issued-credentials")
@Produces(MediaType.APPLICATION_JSON)
List<IssuedVerifiableCredentialRepresentation> getIssuedCredentials();
}

View file

@ -39,6 +39,7 @@ import org.keycloak.models.CredentialValidationOutput;
import org.keycloak.models.FederatedIdentityModel;
import org.keycloak.models.GroupModel;
import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.IssuedVerifiableCredentialModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakTransaction;
import org.keycloak.models.ProtocolMapperModel;
@ -875,6 +876,16 @@ public class UserCacheSession implements UserCache, OnCreateComponent, OnUpdateC
return getDelegate().updateVerifiableCredential(userId, credentialScopeName);
}
@Override
public void addIssuedVerifiableCredential(IssuedVerifiableCredentialModel issuedVc) {
getDelegate().addIssuedVerifiableCredential(issuedVc);
}
@Override
public Stream<IssuedVerifiableCredentialModel> getIssuedVerifiableCredentialsStreamByUser(String userId) {
return getDelegate().getIssuedVerifiableCredentialsStreamByUser(userId);
}
@Override
public void setNotBeforeForUser(RealmModel realm, UserModel user, int notBefore) {
if (!isRegisteredForInvalidation(realm, user.getId())) {

View file

@ -54,6 +54,7 @@ import org.keycloak.models.ClientScopeModel;
import org.keycloak.models.FederatedIdentityModel;
import org.keycloak.models.GroupModel;
import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.IssuedVerifiableCredentialModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.ModelDuplicateException;
import org.keycloak.models.ModelException;
@ -68,6 +69,7 @@ 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.IssuedVerifiableCredentialEntity;
import org.keycloak.models.jpa.entities.UserAttributeEntity;
import org.keycloak.models.jpa.entities.UserConsentClientScopeEntity;
import org.keycloak.models.jpa.entities.UserConsentEntity;
@ -170,6 +172,7 @@ public class JpaUserProvider implements UserProvider, UserCredentialStore, JpaUs
em.createNamedQuery("deleteUserConsentClientScopesByUser").setParameter("user", user).executeUpdate();
em.createNamedQuery("deleteUserConsentsByUser").setParameter("user", user).executeUpdate();
em.createNamedQuery("deleteVerifiableCredentialsByUser").setParameter("user", user).executeUpdate();
em.createNamedQuery("deleteIssuedVcsByUser").setParameter("userId", user.getId()).executeUpdate();
em.remove(user);
em.flush();
@ -518,6 +521,8 @@ public class JpaUserProvider implements UserProvider, UserCredentialStore, JpaUs
.setParameter("realmId", realm.getId()).executeUpdate();
em.createNamedQuery("deleteVerifiableCredentialsByRealm")
.setParameter("realmId", realm.getId()).executeUpdate();
em.createNamedQuery("deleteIssuedVcsByRealm")
.setParameter("realmId", realm.getId()).executeUpdate();
em.createNamedQuery("deleteUserRoleMappingsByRealm")
.setParameter("realmId", realm.getId()).executeUpdate();
em.createNamedQuery("deleteUserRequiredActionsByRealm")
@ -623,6 +628,9 @@ public class JpaUserProvider implements UserProvider, UserCredentialStore, JpaUs
em.createNamedQuery("deleteVerifiableCredentialsByClientScope")
.setParameter("scopeName", clientScope.getName())
.executeUpdate();
em.createNamedQuery("deleteIssuedVcsByClientScope")
.setParameter("scopeName", clientScope.getName())
.executeUpdate();
}
@Override
@ -1080,6 +1088,45 @@ public class JpaUserProvider implements UserProvider, UserCredentialStore, JpaUs
return new org.keycloak.credential.UserCredentialManager(session, session.getContext().getRealm(), user);
}
@Override
public void addIssuedVerifiableCredential(IssuedVerifiableCredentialModel model) {
String revision;
if (model.getRevision() != null) {
revision = model.getRevision();
} else {
UserVerifiableCredentialEntity userVerifiableCredentialEntity = getVerifiableCredentialsEntitiesByUser(model.getUserId())
.filter(vc -> model.getCredentialType().equals(vc.getCredentialScopeName()))
.findFirst()
.orElseThrow(() -> new ModelException(
"Verifiable credential not found: " + model.getCredentialType()));
revision = userVerifiableCredentialEntity.getRevision();
}
IssuedVerifiableCredentialEntity issuedVerifiableCredentialEntity = new IssuedVerifiableCredentialEntity();
issuedVerifiableCredentialEntity.setId(KeycloakModelUtils.generateId());
issuedVerifiableCredentialEntity.setUser(em.getReference(UserEntity.class, model.getUserId()));
issuedVerifiableCredentialEntity.setCredentialType(model.getCredentialType());
issuedVerifiableCredentialEntity.setClientId(model.getClientId());
issuedVerifiableCredentialEntity.setRevision(revision);
long issuedAt = model.getIssuedAt() != null ? model.getIssuedAt() : Time.currentTimeMillis();
issuedVerifiableCredentialEntity.setIssuedAt(issuedAt);
issuedVerifiableCredentialEntity.setExpiresAt(model.getExpiresAt());
em.persist(issuedVerifiableCredentialEntity);
em.flush();
}
@Override
public Stream<IssuedVerifiableCredentialModel> getIssuedVerifiableCredentialsStreamByUser(String userId) {
TypedQuery<IssuedVerifiableCredentialEntity> query = em.createNamedQuery("issuedVcsByUser", IssuedVerifiableCredentialEntity.class);
query.setParameter("userId", userId);
return closing(query.getResultStream()).map(this::toIssuedVcModel);
}
// Could override this to provide a custom behavior.
protected void ensureEmailConstraint(List<UserEntity> users, RealmModel realm) {
UserEntity user = users.get(0);
@ -1260,4 +1307,17 @@ public class JpaUserProvider implements UserProvider, UserCredentialStore, JpaUs
return query.select(from.getJoins().isEmpty() ? builder.count(from) : builder.countDistinct(from))
.where(predicates);
}
private IssuedVerifiableCredentialModel toIssuedVcModel(IssuedVerifiableCredentialEntity entity) {
IssuedVerifiableCredentialModel model = new IssuedVerifiableCredentialModel();
model.setId(entity.getId());
model.setUserId(entity.getUser().getId());
model.setCredentialType(entity.getCredentialType());
model.setRevision(entity.getRevision());
model.setIssuedAt(entity.getIssuedAt());
model.setExpiresAt(entity.getExpiresAt());
model.setClientId(entity.getClientId());
return model;
}
}

View file

@ -0,0 +1,118 @@
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;
@Entity
@Table(name="ISSUED_VER_CREDENTIAL")
@NamedQueries({
@NamedQuery(name="issuedVcsByUser", query="select vc from IssuedVerifiableCredentialEntity vc where vc.user.id = :userId order by vc.issuedAt desc"),
@NamedQuery(name="deleteIssuedVcsByRealm", query="delete from IssuedVerifiableCredentialEntity vc where vc.user IN (select u from UserEntity u where u.realmId = :realmId)"),
@NamedQuery(name="deleteIssuedVcsByUser", query="delete from IssuedVerifiableCredentialEntity vc where vc.user.id = :userId"),
@NamedQuery(name="deleteIssuedVcsByClientScope", query="delete from IssuedVerifiableCredentialEntity vc where vc.credentialType = :scopeName"),
})
public class IssuedVerifiableCredentialEntity {
@Id
@Column(name="ID", length = 36)
@Access(AccessType.PROPERTY)
protected String id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name="USER_ID")
protected UserEntity user;
@Column(name="CREDENTIAL_TYPE")
protected String credentialType;
@Column(name="ISSUED_AT")
protected Long issuedAt;
@Column(name="EXPIRES_AT")
protected Long expiresAt;
// This represents UUID of the client, which acts as OID4VCI wallet
@Column(name="CLIENT_ID")
protected String clientId;
@Column(name="REVISION")
protected String revision;
public void setId(String id) {
this.id = id;
}
public String getId() {
return id;
}
public UserEntity getUser() {
return user;
}
public void setUser(UserEntity user) {
this.user = user;
}
public String getCredentialType() {
return credentialType;
}
public void setCredentialType(String credentialType) {
this.credentialType = credentialType;
}
public Long getIssuedAt() {
return issuedAt;
}
public void setIssuedAt(Long issuedAt) {
this.issuedAt = issuedAt;
}
public Long getExpiresAt() {
return expiresAt;
}
public void setExpiresAt(Long expiresAt) {
this.expiresAt = expiresAt;
}
public String getClientId() {
return clientId;
}
public void setClientId(String clientId) {
this.clientId = clientId;
}
public String getRevision() {
return revision;
}
public void setRevision(String revision) {
this.revision = revision;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
IssuedVerifiableCredentialEntity that = (IssuedVerifiableCredentialEntity) o;
return id.equals(that.id);
}
@Override
public int hashCode() {
return id.hashCode();
}
}

View file

@ -203,4 +203,39 @@
</changeSet>
<changeSet author="keycloak" id="26.7.0-46204-issued-ver-credential-table">
<createTable tableName="ISSUED_VER_CREDENTIAL">
<column name="ID" type="VARCHAR(36)">
<constraints nullable="false"/>
</column>
<column name="USER_ID" type="VARCHAR(36)">
<constraints nullable="false"/>
</column>
<column name="CREDENTIAL_TYPE" type="VARCHAR(255)">
<constraints nullable="false"/>
</column>
<column name="ISSUED_AT" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="EXPIRES_AT" type="BIGINT"/>
<column name="CLIENT_ID" type="VARCHAR(36)"/>
<column name="REVISION" type="VARCHAR(36)">
<constraints nullable="false"/>
</column>
</createTable>
<addPrimaryKey columnNames="ID" constraintName="PK_ISSUED_VER_CREDENTIAL" tableName="ISSUED_VER_CREDENTIAL"/>
<addForeignKeyConstraint baseColumnNames="USER_ID"
baseTableName="ISSUED_VER_CREDENTIAL"
constraintName="FK_ISSUED_VER_CREDENTIAL_USER"
referencedColumnNames="ID"
referencedTableName="USER_ENTITY"/>
<!-- Index for listing user's credentials -->
<createIndex tableName="ISSUED_VER_CREDENTIAL" indexName="IDX_ISSUED_VER_CREDENTIAL_USER">
<column name="USER_ID"/>
<column name="ISSUED_AT"/>
</createIndex>
</changeSet>
</databaseChangeLog>

View file

@ -46,6 +46,7 @@
<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.IssuedVerifiableCredentialEntity</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>

View file

@ -44,6 +44,7 @@ import org.keycloak.models.CredentialValidationOutput;
import org.keycloak.models.FederatedIdentityModel;
import org.keycloak.models.GroupModel;
import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.IssuedVerifiableCredentialModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.ModelException;
import org.keycloak.models.ProtocolMapperModel;
@ -967,6 +968,24 @@ public class UserStorageManager extends AbstractStorageManager<UserStorageProvid
}
}
@Override
public void addIssuedVerifiableCredential(IssuedVerifiableCredentialModel issuedVc) {
if (StorageId.isLocalStorage(issuedVc.getUserId())) {
localStorage().addIssuedVerifiableCredential(issuedVc);
} else {
throw new UnsupportedOperationException("Issued verifiable credential operations not yet supported on federated users");
}
}
@Override
public Stream<IssuedVerifiableCredentialModel> getIssuedVerifiableCredentialsStreamByUser(String userId) {
if (StorageId.isLocalStorage(userId)) {
return localStorage().getIssuedVerifiableCredentialsStreamByUser(userId);
} else {
throw new UnsupportedOperationException("Issued verifiable credential operations not yet supported on federated users");
}
}
@Override
public void setNotBeforeForUser(RealmModel realm, UserModel user, int notBefore) {
if (StorageId.isLocalStorage(user.getId())) {

View file

@ -69,6 +69,7 @@ import org.keycloak.models.IdentityProviderMapperModel;
import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.IdentityProviderQuery;
import org.keycloak.models.IdentityProviderType;
import org.keycloak.models.IssuedVerifiableCredentialModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.ModelException;
import org.keycloak.models.ModelIllegalStateException;
@ -127,6 +128,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.IssuedVerifiableCredentialRepresentation;
import org.keycloak.representations.idm.oid4vc.UserVerifiableCredentialRepresentation;
import org.keycloak.storage.StorageId;
import org.keycloak.util.JsonSerialization;
@ -1473,4 +1475,17 @@ public class ModelToRepresentation {
representation.setVerified(model.isVerified());
return representation;
}
public static IssuedVerifiableCredentialRepresentation toRepresentation(IssuedVerifiableCredentialModel model) {
IssuedVerifiableCredentialRepresentation rep = new IssuedVerifiableCredentialRepresentation();
rep.setId(model.getId());
rep.setUserId(model.getUserId());
rep.setCredentialType(model.getCredentialType());
rep.setRevision(model.getRevision());
rep.setIssuedAt(model.getIssuedAt());
rep.setExpiresAt(model.getExpiresAt());
rep.setClientId(model.getClientId());
return rep;
}
}

View file

@ -80,6 +80,7 @@ import org.keycloak.models.FederatedIdentityModel;
import org.keycloak.models.GroupModel;
import org.keycloak.models.IdentityProviderMapperModel;
import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.IssuedVerifiableCredentialModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.ModelDuplicateException;
import org.keycloak.models.ModelException;
@ -130,6 +131,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.IssuedVerifiableCredentialRepresentation;
import org.keycloak.representations.idm.oid4vc.UserVerifiableCredentialRepresentation;
import org.keycloak.storage.DatastoreProvider;
import org.keycloak.util.JsonSerialization;
@ -1772,4 +1774,16 @@ public class RepresentationToModel {
public static OrganizationDomainModel toModel(OrganizationDomainRepresentation domainRepresentation) {
return new OrganizationDomainModel(domainRepresentation.getName(), domainRepresentation.isVerified());
}
public static IssuedVerifiableCredentialModel toModel(IssuedVerifiableCredentialRepresentation representation) {
IssuedVerifiableCredentialModel model = new IssuedVerifiableCredentialModel();
model.setId(representation.getId());
model.setUserId(representation.getUserId());
model.setCredentialType(representation.getCredentialType());
model.setIssuedAt(representation.getIssuedAt());
model.setExpiresAt(representation.getExpiresAt());
model.setClientId(representation.getClientId());
model.setRevision(representation.getRevision());
return model;
}
}

View file

@ -0,0 +1,78 @@
package org.keycloak.models;
public class IssuedVerifiableCredentialModel {
private String id;
private String userId;
private String credentialType;
private Long issuedAt;
private Long expiresAt;
// This represents UUID of the client, which acts as OID4VCI wallet
private String clientId;
private String revision;
public IssuedVerifiableCredentialModel() {
}
public IssuedVerifiableCredentialModel(String userId, String credentialType, String clientId) {
this.userId = userId;
this.credentialType = credentialType;
this.clientId = clientId;
}
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public String getUserId() {
return userId;
}
public void setUserId(String userId) {
this.userId = userId;
}
public String getCredentialType() {
return credentialType;
}
public void setCredentialType(String credentialType) {
this.credentialType = credentialType;
}
public Long getIssuedAt() {
return issuedAt;
}
public void setIssuedAt(Long issuedAt) {
this.issuedAt = issuedAt;
}
public Long getExpiresAt() {
return expiresAt;
}
public void setExpiresAt(Long expiresAt) {
this.expiresAt = expiresAt;
}
public String getClientId() {
return clientId;
}
public void setClientId(String clientId) {
this.clientId = clientId;
}
public String getRevision() {
return revision;
}
public void setRevision(String revision) {
this.revision = revision;
}
}

View file

@ -340,4 +340,20 @@ public interface UserProvider extends Provider,
*/
UserCredentialManager getUserCredentialManager(UserModel user);
/**
* Record that a verifiable credential was issued to a user.
*
* @param issuedVc model with userId, clientId, credentialType set
*
*/
void addIssuedVerifiableCredential(IssuedVerifiableCredentialModel issuedVc);
/**
* Get all issued verifiable credentials for a specific user.
*
* @param userId user ID
* @return stream of issued verifiable credentials, sorted by issuedAt descending
*/
Stream<IssuedVerifiableCredentialModel> getIssuedVerifiableCredentialsStreamByUser(String userId);
}

View file

@ -68,6 +68,7 @@ import org.keycloak.jose.jwk.JWKParser;
import org.keycloak.models.AuthenticatedClientSessionModel;
import org.keycloak.models.ClientModel;
import org.keycloak.models.ClientScopeModel;
import org.keycloak.models.IssuedVerifiableCredentialModel;
import org.keycloak.models.KeyManager;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
@ -1022,6 +1023,9 @@ public class OID4VCIssuerEndpoint {
eventBuilder.detail(Details.SCOPE, supportedCredential.getScope())
.detail(Details.VERIFIABLE_CREDENTIAL_FORMAT, supportedCredential.getFormat())
.detail(Details.VERIFIABLE_CREDENTIALS_ISSUED, String.valueOf(responseVO.getCredentials().size()));
recordIssuedVerifiableCredentials(userModel, clientModel, supportedCredential.getScope(), responseVO.getCredentials().size());
eventBuilder.success();
// Clean up offer state after successful credential issuance
@ -1034,6 +1038,18 @@ public class OID4VCIssuerEndpoint {
return response;
}
private void recordIssuedVerifiableCredentials(UserModel userModel, ClientModel clientModel, String credentialScope, int count) {
try {
if (count > 0) {
IssuedVerifiableCredentialModel model = new IssuedVerifiableCredentialModel(userModel.getId(), credentialScope, clientModel.getId());
session.users().addIssuedVerifiableCredential(model);
LOGGER.debugf("Recorded VC issuance: user=%s, client=%s, type=%s, credentials=%d", userModel.getUsername(), clientModel.getClientId(), credentialScope, count);
}
} catch (Exception e) {
LOGGER.warnf(e, "Failed to record VC issuance for user=%s, client=%s, type=%s", userModel.getUsername(), clientModel.getClientId(), credentialScope);
}
}
private List<OID4VCAuthorizationDetail> getAuthorizationDetailsResponse(AccessToken accessToken) {
List<AuthorizationDetailsJSONRepresentation> tokenAuthDetails = accessToken.getAuthorizationDetails();
AuthorizationDetailsProcessor<OID4VCAuthorizationDetail> oid4vcProcessor = session.getProvider(AuthorizationDetailsProcessor.class, OPENID_CREDENTIAL);

View file

@ -28,6 +28,7 @@ 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.IssuedVerifiableCredentialRepresentation;
import org.keycloak.representations.idm.oid4vc.UserVerifiableCredentialRepresentation;
import org.keycloak.services.ErrorResponse;
import org.keycloak.services.resources.KeycloakOpenAPI;
@ -188,6 +189,24 @@ public class UserVerifiableCredentialResource {
adminEvent.operation(OperationType.DELETE).resourcePath(session.getContext().getUri()).success();
}
@GET
@Path("issued-credentials")
@Produces(MediaType.APPLICATION_JSON)
@Tag(name = KeycloakOpenAPI.Admin.Tags.USERS)
@Operation(summary = "Get issued verifiable credentials for the user")
@APIResponses(value = {
@APIResponse(responseCode = "200", description = "OK"),
@APIResponse(responseCode = "403", description = "Forbidden")
})
public List<IssuedVerifiableCredentialRepresentation> getIssuedCredentials() {
auth.users().requireView(user);
checkOid4VCIEnabled();
return session.users().getIssuedVerifiableCredentialsStreamByUser(user.getId())
.map(ModelToRepresentation::toRepresentation)
.toList();
}
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);

View file

@ -428,6 +428,7 @@ public class PermissionsTest extends AbstractPermissionsTest {
invoke(realm -> realm.users().get(user.getId()).revokeConsent("testclient"), Resource.USER, true);
invoke(realm -> realm.users().get(user.getId()).verifiableCredentials().getCredentials(), Resource.USER, false);
invoke(realm -> realm.users().get(user.getId()).verifiableCredentials().getIssuedCredentials(), Resource.USER, false);
UserVerifiableCredentialRepresentation verifCred = new UserVerifiableCredentialRepresentation();
verifCred.setCredentialScopeName("nosuch");
invoke(realm -> realm.users().get(user.getId()).verifiableCredentials().createCredential(verifCred), Resource.USER, true);

View file

@ -0,0 +1,218 @@
package org.keycloak.tests.admin.user;
import java.util.List;
import jakarta.ws.rs.BadRequestException;
import jakarta.ws.rs.NotFoundException;
import org.keycloak.admin.client.resource.UserResource;
import org.keycloak.common.util.Time;
import org.keycloak.models.IssuedVerifiableCredentialModel;
import org.keycloak.models.UserVerifiableCredentialModel;
import org.keycloak.representations.idm.oid4vc.IssuedVerifiableCredentialRepresentation;
import org.keycloak.testframework.annotations.InjectRealm;
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
import org.keycloak.testframework.realm.ManagedRealm;
import org.keycloak.testframework.realm.RealmBuilder;
import org.keycloak.testframework.realm.RealmConfig;
import org.keycloak.tests.oid4vc.OID4VCIssuerTestBase;
import org.keycloak.tests.suites.DatabaseTest;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import static org.keycloak.tests.oid4vc.OID4VCIssuerTestBase.jwtTypeNaturalPersonScopeName;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.empty;
import static org.hamcrest.Matchers.hasSize;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.oneOf;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
@KeycloakIntegrationTest(config = OID4VCIssuerTestBase.VCTestServerConfig.class)
public class IssuedVerifiableCredentialTest extends AbstractUserTest {
private static final String CREDENTIAL_TYPE_1 = jwtTypeNaturalPersonScopeName;
private static final String CREDENTIAL_TYPE_2 = "education-cert";
@InjectRealm(config = IssuedVcTestRealmConfig.class)
protected ManagedRealm testRealm;
@Test
@DatabaseTest
public void testGetIssuedCredentials_EmptyList() {
String userId = createUser();
UserResource userResource = managedRealm.admin().users().get(userId);
List<IssuedVerifiableCredentialRepresentation> issuedCreds = userResource.verifiableCredentials().getIssuedCredentials();
assertNotNull(issuedCreds, "Should return empty list, not null");
assertThat(issuedCreds, empty());
}
@Test
@DatabaseTest
public void testGetIssuedCredentials_WithData() {
String userId = createUser();
createIssuedVcViaModelLayer(userId, CREDENTIAL_TYPE_1, "wallet-123", "rev-001");
createIssuedVcViaModelLayer(userId, CREDENTIAL_TYPE_2, "wallet-456", "rev-002");
// Retrieve via REST API
UserResource userResource = managedRealm.admin().users().get(userId);
List<IssuedVerifiableCredentialRepresentation> issuedCreds = userResource.verifiableCredentials().getIssuedCredentials();
assertThat(issuedCreds, hasSize(2));
// Verify field population
IssuedVerifiableCredentialRepresentation firstCred = issuedCreds.get(0);
assertNotNull(firstCred.getId());
assertEquals(userId, firstCred.getUserId());
assertThat(firstCred.getCredentialType(), is(oneOf(CREDENTIAL_TYPE_1, CREDENTIAL_TYPE_2)));
assertNotNull(firstCred.getClientId());
assertNotNull(firstCred.getRevision());
assertNotNull(firstCred.getIssuedAt());
}
@Test
@DatabaseTest
public void testGetIssuedCredentials_SortedByIssuedAtDesc() {
String userId = createUser();
long baseTime = Time.currentTimeMillis();
createIssuedVcViaModelLayer(userId, "cert-1", "wallet-1", "rev-1", baseTime);
createIssuedVcViaModelLayer(userId, "cert-2", "wallet-2", "rev-2", baseTime + 1000);
createIssuedVcViaModelLayer(userId, "cert-3", "wallet-3", "rev-3", baseTime + 2000);
UserResource userResource = managedRealm.admin().users().get(userId);
List<IssuedVerifiableCredentialRepresentation> issuedCreds = userResource.verifiableCredentials().getIssuedCredentials();
assertThat(issuedCreds, hasSize(3));
assertEquals("cert-3", issuedCreds.get(0).getCredentialType());
assertEquals("cert-2", issuedCreds.get(1).getCredentialType());
assertEquals("cert-1", issuedCreds.get(2).getCredentialType());
}
@Test
@DatabaseTest
public void testGetIssuedCredentials_UserIsolation() {
String user1Id = createUser("user1", "user1@test.com");
String user2Id = createUser("user2", "user2@test.com");
createIssuedVcViaModelLayer(user1Id, "user1-cert", "wallet1", "rev1");
createIssuedVcViaModelLayer(user2Id, "user2-cert", "wallet2", "rev2");
// User 1 should only see their VC
List<IssuedVerifiableCredentialRepresentation> user1Creds =
managedRealm.admin().users().get(user1Id).verifiableCredentials().getIssuedCredentials();
assertThat(user1Creds, hasSize(1));
assertEquals("user1-cert", user1Creds.get(0).getCredentialType());
// User 2 should only see their VC
List<IssuedVerifiableCredentialRepresentation> user2Creds =
managedRealm.admin().users().get(user2Id).verifiableCredentials().getIssuedCredentials();
assertThat(user2Creds, hasSize(1));
assertEquals("user2-cert", user2Creds.get(0).getCredentialType());
}
@Test
@DatabaseTest
public void testGetIssuedCredentials_WithExpiresAt() {
String userId = createUser();
long issuedAt = Time.currentTimeMillis();
long expiresAt = issuedAt + 86400000L; // 24 hours later
runOnServer.run(session -> {
UserVerifiableCredentialModel vcModel = new UserVerifiableCredentialModel(CREDENTIAL_TYPE_1);
vcModel.setRevision("rev-001");
session.users().addVerifiableCredential(userId, vcModel);
});
runOnServer.run(session -> {
IssuedVerifiableCredentialModel model = new IssuedVerifiableCredentialModel(userId, CREDENTIAL_TYPE_1, "wallet-123");
model.setRevision("rev-001");
model.setIssuedAt(issuedAt);
model.setExpiresAt(expiresAt);
session.users().addIssuedVerifiableCredential(model);
});
UserResource userResource = managedRealm.admin().users().get(userId);
List<IssuedVerifiableCredentialRepresentation> issuedCreds = userResource.verifiableCredentials().getIssuedCredentials();
assertThat(issuedCreds, hasSize(1));
IssuedVerifiableCredentialRepresentation cred = issuedCreds.get(0);
assertEquals(expiresAt, cred.getExpiresAt());
}
@Test
public void testGetIssuedCredentials_FeatureDisabled() {
managedRealm.updateWithCleanup((realm) -> realm.verifiableCredentialsEnabled(false));
adminEvents.clear();
String userId = createUser();
UserResource userResource = managedRealm.admin().users().get(userId);
try {
userResource.verifiableCredentials().getIssuedCredentials();
Assertions.fail("Expected BadRequestException when feature is disabled");
} catch (BadRequestException e) {
assertThat(e.getResponse().getStatus(), is(400));
}
}
@Test
@DatabaseTest
public void testGetIssuedCredentials_NonExistentUser() {
try {
managedRealm.admin().users().get("non-existent-user-id").verifiableCredentials().getIssuedCredentials();
Assertions.fail("Expected NotFoundException");
} catch (NotFoundException e) {
assertThat(e.getResponse().getStatus(), is(404));
}
}
// Helper methods
protected void createIssuedVcViaModelLayer(String userId, String credentialType,
String clientId, String revision) {
createIssuedVcViaModelLayer(userId, credentialType, clientId, revision, null);
}
protected void createIssuedVcViaModelLayer(String userId, String credentialType,
String clientId, String revision, Long issuedAt) {
runOnServer.run(session -> {
UserVerifiableCredentialModel vcModel = new UserVerifiableCredentialModel(credentialType);
vcModel.setRevision(revision);
session.users().addVerifiableCredential(userId, vcModel);
});
runOnServer.run(session -> {
IssuedVerifiableCredentialModel model = new IssuedVerifiableCredentialModel(userId, credentialType, clientId);
model.setRevision(revision);
if (issuedAt != null) {
model.setIssuedAt(issuedAt);
}
session.users().addIssuedVerifiableCredential(model);
});
}
public static class IssuedVcTestRealmConfig implements RealmConfig {
public static final String TEST_REALM_NAME = "test";
@Override
public RealmBuilder configure(RealmBuilder realm) {
return realm
.name(TEST_REALM_NAME)
.eventsEnabled(true)
.eventsListeners("jboss-logging")
.verifiableCredentialsEnabled(true);
}
}
}

View file

@ -0,0 +1,105 @@
package org.keycloak.tests.oid4vc;
import java.util.List;
import org.keycloak.admin.client.resource.UserVerifiableCredentialResource;
import org.keycloak.models.oid4vci.CredentialScopeModel;
import org.keycloak.protocol.oid4vc.model.CredentialIssuer;
import org.keycloak.protocol.oid4vc.model.CredentialResponse;
import org.keycloak.protocol.oid4vc.model.OID4VCAuthorizationDetail;
import org.keycloak.protocol.oid4vc.model.Proofs;
import org.keycloak.representations.idm.oid4vc.IssuedVerifiableCredentialRepresentation;
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
import org.keycloak.testsuite.util.oauth.AccessTokenResponse;
import org.junit.jupiter.api.Test;
import static org.keycloak.OID4VCConstants.OPENID_CREDENTIAL;
import static org.keycloak.tests.oid4vc.OID4VCProofTestUtils.jwtProofs;
import static org.junit.jupiter.api.Assertions.assertAll;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
@KeycloakIntegrationTest(config = OID4VCIssuerTestBase.VCTestServerWithRestCredentialOfferEnabled.class)
public class OID4VCIssuedCredentialsAdminAPITest extends OID4VCIssuerEndpointTest {
@Test
public void testIssuedCredentialsArePersistedAndRetrievableViaAdminAPI() {
String scopeName = jwtTypeCredentialScope.getName();
String credConfigId = jwtTypeCredentialScope.getAttributes().get(CredentialScopeModel.VC_CONFIGURATION_ID);
String userId = testRealm.admin().users().search("john").get(0).getId();
UserVerifiableCredentialResource userVerifiableCredentialResource = testRealm.admin().users().get(userId).verifiableCredentials();
List<IssuedVerifiableCredentialRepresentation> initialIssuedCreds = userVerifiableCredentialResource.getIssuedCredentials();
assertTrue(initialIssuedCreds.isEmpty(), "No issued credentials should exist initially");
CredentialIssuer credentialIssuer = getCredentialIssuerMetadata();
OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail();
authDetail.setType(OPENID_CREDENTIAL);
authDetail.setCredentialConfigurationId(credConfigId);
authDetail.setLocations(List.of(credentialIssuer.getCredentialIssuer()));
String authCode = getAuthorizationCode(oauth, client, "john", scopeName);
AccessTokenResponse tokenResponse = getBearerToken(oauth, authCode, authDetail);
String token = tokenResponse.getAccessToken();
List<OID4VCAuthorizationDetail> authDetailsResponse = tokenResponse.getOID4VCAuthorizationDetails();
String credentialIdentifier = authDetailsResponse.get(0).getCredentialIdentifiers().get(0);
String cNonce = oauth.oid4vc().nonceRequest().send().getNonce();
Proofs proofs = jwtProofs(credentialIssuer.getCredentialIssuer(), cNonce);
CredentialResponse credentialResponseVO = oauth.oid4vc()
.credentialRequest()
.bearerToken(token)
.credentialIdentifier(credentialIdentifier)
.proofs(proofs)
.send()
.getCredentialResponse();
assertNotNull(credentialResponseVO, "Credential should have been issued");
assertNotNull(credentialResponseVO.getCredentials(), "Credentials array should not be null");
assertFalse(credentialResponseVO.getCredentials().isEmpty(), "At least one credential should be issued");
List<IssuedVerifiableCredentialRepresentation> issuedCreds = testRealm.admin().users().get(userId).verifiableCredentials().getIssuedCredentials();
assertEquals(1, issuedCreds.size(), "Exactly one issued credential should be stored");
IssuedVerifiableCredentialRepresentation issuedCred = issuedCreds.get(0);
assertAll(
() -> assertNotNull(issuedCred.getId(), "Issued credential should have an ID"),
() -> assertEquals(userId, issuedCred.getUserId(), "User ID should match"),
() -> assertEquals(scopeName, issuedCred.getCredentialType(), "Credential type should match the scope name"),
() -> assertNotNull(issuedCred.getRevision(), "Revision should be set"),
() -> assertNotNull(issuedCred.getIssuedAt(), "IssuedAt timestamp should be set"),
() -> assertNotNull(issuedCred.getClientId(), "ClientId should be set")
);
String cNonce2 = oauth.oid4vc().doNonceRequest().getNonce();
Proofs proofs2 = jwtProofs(credentialIssuer.getCredentialIssuer(), cNonce2);
CredentialResponse credentialResponseVO2 = oauth.oid4vc()
.credentialRequest()
.bearerToken(token)
.credentialIdentifier(credentialIdentifier)
.proofs(proofs2)
.send()
.getCredentialResponse();
assertNotNull(credentialResponseVO2, "Second credential should have been issued");
List<IssuedVerifiableCredentialRepresentation> multipleIssuedCreds = testRealm.admin().users().get(userId).verifiableCredentials().getIssuedCredentials();
assertEquals(2, multipleIssuedCreds.size(), "Two issued credentials should be stored");
// Verify sorting (newest first - DESC by issuedAt)
assertTrue(multipleIssuedCreds.get(0).getIssuedAt() >= multipleIssuedCreds.get(1).getIssuedAt(),
"Issued credentials should be sorted by issuedAt DESC (newest first)");
}
}