From eba0ef3c3bc3d86d7b248eb8dd7dd642ec2245b7 Mon Sep 17 00:00:00 2001 From: Jimmy Chakkalakal Date: Fri, 22 May 2026 15:38:21 +0100 Subject: [PATCH] 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 --- ...uedVerifiableCredentialRepresentation.java | 84 +++++++ .../UserVerifiableCredentialResource.java | 6 +- .../cache/infinispan/UserCacheSession.java | 11 + .../keycloak/models/jpa/JpaUserProvider.java | 60 +++++ .../IssuedVerifiableCredentialEntity.java | 118 ++++++++++ .../META-INF/jpa-changelog-26.7.0.xml | 35 +++ .../main/resources/default-persistence.xml | 1 + .../keycloak/storage/UserStorageManager.java | 19 ++ .../models/utils/ModelToRepresentation.java | 15 ++ .../models/utils/RepresentationToModel.java | 14 ++ .../IssuedVerifiableCredentialModel.java | 78 +++++++ .../org/keycloak/models/UserProvider.java | 16 ++ .../oid4vc/issuance/OID4VCIssuerEndpoint.java | 16 ++ .../UserVerifiableCredentialResource.java | 19 ++ .../keycloak/tests/admin/PermissionsTest.java | 1 + .../user/IssuedVerifiableCredentialTest.java | 218 ++++++++++++++++++ .../OID4VCIssuedCredentialsAdminAPITest.java | 105 +++++++++ 17 files changed, 815 insertions(+), 1 deletion(-) create mode 100644 core/src/main/java/org/keycloak/representations/idm/oid4vc/IssuedVerifiableCredentialRepresentation.java create mode 100644 model/jpa/src/main/java/org/keycloak/models/jpa/entities/IssuedVerifiableCredentialEntity.java create mode 100644 server-spi/src/main/java/org/keycloak/models/IssuedVerifiableCredentialModel.java create mode 100644 tests/base/src/test/java/org/keycloak/tests/admin/user/IssuedVerifiableCredentialTest.java create mode 100644 tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCIssuedCredentialsAdminAPITest.java diff --git a/core/src/main/java/org/keycloak/representations/idm/oid4vc/IssuedVerifiableCredentialRepresentation.java b/core/src/main/java/org/keycloak/representations/idm/oid4vc/IssuedVerifiableCredentialRepresentation.java new file mode 100644 index 00000000000..dc97bdaa778 --- /dev/null +++ b/core/src/main/java/org/keycloak/representations/idm/oid4vc/IssuedVerifiableCredentialRepresentation.java @@ -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); + } +} diff --git a/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/UserVerifiableCredentialResource.java b/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/UserVerifiableCredentialResource.java index 5e8149b2d6d..28f084af1b3 100644 --- a/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/UserVerifiableCredentialResource.java +++ b/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/UserVerifiableCredentialResource.java @@ -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 getIssuedCredentials(); } diff --git a/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/UserCacheSession.java b/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/UserCacheSession.java index fe529db23ba..9a53de2a09b 100755 --- a/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/UserCacheSession.java +++ b/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/UserCacheSession.java @@ -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 getIssuedVerifiableCredentialsStreamByUser(String userId) { + return getDelegate().getIssuedVerifiableCredentialsStreamByUser(userId); + } + @Override public void setNotBeforeForUser(RealmModel realm, UserModel user, int notBefore) { if (!isRegisteredForInvalidation(realm, user.getId())) { diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/JpaUserProvider.java b/model/jpa/src/main/java/org/keycloak/models/jpa/JpaUserProvider.java index 3d7d37c7b56..9884f7781d6 100755 --- a/model/jpa/src/main/java/org/keycloak/models/jpa/JpaUserProvider.java +++ b/model/jpa/src/main/java/org/keycloak/models/jpa/JpaUserProvider.java @@ -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 getIssuedVerifiableCredentialsStreamByUser(String userId) { + TypedQuery 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 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; + } } diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/entities/IssuedVerifiableCredentialEntity.java b/model/jpa/src/main/java/org/keycloak/models/jpa/entities/IssuedVerifiableCredentialEntity.java new file mode 100644 index 00000000000..74e53aa353c --- /dev/null +++ b/model/jpa/src/main/java/org/keycloak/models/jpa/entities/IssuedVerifiableCredentialEntity.java @@ -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(); + } +} diff --git a/model/jpa/src/main/resources/META-INF/jpa-changelog-26.7.0.xml b/model/jpa/src/main/resources/META-INF/jpa-changelog-26.7.0.xml index 7fa2be38636..6a871ae8c81 100644 --- a/model/jpa/src/main/resources/META-INF/jpa-changelog-26.7.0.xml +++ b/model/jpa/src/main/resources/META-INF/jpa-changelog-26.7.0.xml @@ -203,4 +203,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/model/jpa/src/main/resources/default-persistence.xml b/model/jpa/src/main/resources/default-persistence.xml index 3f7b039cd05..f0d5316ef2b 100644 --- a/model/jpa/src/main/resources/default-persistence.xml +++ b/model/jpa/src/main/resources/default-persistence.xml @@ -46,6 +46,7 @@ org.keycloak.models.jpa.entities.UserConsentEntity org.keycloak.models.jpa.entities.UserConsentClientScopeEntity org.keycloak.models.jpa.entities.UserVerifiableCredentialEntity + org.keycloak.models.jpa.entities.IssuedVerifiableCredentialEntity org.keycloak.models.jpa.entities.AuthenticationFlowEntity org.keycloak.models.jpa.entities.AuthenticationExecutionEntity org.keycloak.models.jpa.entities.AuthenticatorConfigEntity diff --git a/model/storage-private/src/main/java/org/keycloak/storage/UserStorageManager.java b/model/storage-private/src/main/java/org/keycloak/storage/UserStorageManager.java index 74982942030..0961c6c4a08 100755 --- a/model/storage-private/src/main/java/org/keycloak/storage/UserStorageManager.java +++ b/model/storage-private/src/main/java/org/keycloak/storage/UserStorageManager.java @@ -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 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())) { diff --git a/server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java b/server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java index 5ce54b016b0..ffb4570df82 100755 --- a/server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java +++ b/server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java @@ -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; + } + } diff --git a/server-spi-private/src/main/java/org/keycloak/models/utils/RepresentationToModel.java b/server-spi-private/src/main/java/org/keycloak/models/utils/RepresentationToModel.java index 9492dbb9baa..8c4b39ece73 100755 --- a/server-spi-private/src/main/java/org/keycloak/models/utils/RepresentationToModel.java +++ b/server-spi-private/src/main/java/org/keycloak/models/utils/RepresentationToModel.java @@ -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; + } } diff --git a/server-spi/src/main/java/org/keycloak/models/IssuedVerifiableCredentialModel.java b/server-spi/src/main/java/org/keycloak/models/IssuedVerifiableCredentialModel.java new file mode 100644 index 00000000000..aff884e2084 --- /dev/null +++ b/server-spi/src/main/java/org/keycloak/models/IssuedVerifiableCredentialModel.java @@ -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; + } +} diff --git a/server-spi/src/main/java/org/keycloak/models/UserProvider.java b/server-spi/src/main/java/org/keycloak/models/UserProvider.java index e4f3eca6300..b98a79af754 100755 --- a/server-spi/src/main/java/org/keycloak/models/UserProvider.java +++ b/server-spi/src/main/java/org/keycloak/models/UserProvider.java @@ -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 getIssuedVerifiableCredentialsStreamByUser(String userId); + } diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerEndpoint.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerEndpoint.java index a06e2627469..525ab059b24 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerEndpoint.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerEndpoint.java @@ -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 getAuthorizationDetailsResponse(AccessToken accessToken) { List tokenAuthDetails = accessToken.getAuthorizationDetails(); AuthorizationDetailsProcessor oid4vcProcessor = session.getProvider(AuthorizationDetailsProcessor.class, OPENID_CREDENTIAL); diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/resources/admin/UserVerifiableCredentialResource.java b/services/src/main/java/org/keycloak/protocol/oid4vc/resources/admin/UserVerifiableCredentialResource.java index 6816275ac3a..d1f4cd718bb 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/resources/admin/UserVerifiableCredentialResource.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/resources/admin/UserVerifiableCredentialResource.java @@ -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 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); diff --git a/tests/base/src/test/java/org/keycloak/tests/admin/PermissionsTest.java b/tests/base/src/test/java/org/keycloak/tests/admin/PermissionsTest.java index 16695db01a4..44f9f26ecf9 100644 --- a/tests/base/src/test/java/org/keycloak/tests/admin/PermissionsTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/admin/PermissionsTest.java @@ -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); diff --git a/tests/base/src/test/java/org/keycloak/tests/admin/user/IssuedVerifiableCredentialTest.java b/tests/base/src/test/java/org/keycloak/tests/admin/user/IssuedVerifiableCredentialTest.java new file mode 100644 index 00000000000..d7ab94d3064 --- /dev/null +++ b/tests/base/src/test/java/org/keycloak/tests/admin/user/IssuedVerifiableCredentialTest.java @@ -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 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 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 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 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 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 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); + } + } +} diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCIssuedCredentialsAdminAPITest.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCIssuedCredentialsAdminAPITest.java new file mode 100644 index 00000000000..d94145a6e85 --- /dev/null +++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCIssuedCredentialsAdminAPITest.java @@ -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 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 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 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 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)"); + } +}