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 28f084af1b3..0be8c837703 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 @@ -45,4 +45,8 @@ public interface UserVerifiableCredentialResource { @Path("issued-credentials") @Produces(MediaType.APPLICATION_JSON) List getIssuedCredentials(); + + @DELETE + @Path("issued-credentials/{id}") + void revokeIssuedCredential(@PathParam("id") String credentialId); } 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 9a53de2a09b..a939b8ec711 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 @@ -886,6 +886,11 @@ public class UserCacheSession implements UserCache, OnCreateComponent, OnUpdateC return getDelegate().getIssuedVerifiableCredentialsStreamByUser(userId); } + @Override + public boolean removeIssuedVerifiableCredential(String credentialId) { + return getDelegate().removeIssuedVerifiableCredential(credentialId); + } + @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 9884f7781d6..4df2ad0c560 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 @@ -1127,6 +1127,17 @@ public class JpaUserProvider implements UserProvider, UserCredentialStore, JpaUs return closing(query.getResultStream()).map(this::toIssuedVcModel); } + @Override + public boolean removeIssuedVerifiableCredential(String credentialId) { + IssuedVerifiableCredentialEntity entity = em.find(IssuedVerifiableCredentialEntity.class, credentialId, LockModeType.PESSIMISTIC_WRITE); + if (entity == null) { + return false; + } + em.remove(entity); + em.flush(); + return true; + } + // Could override this to provide a custom behavior. protected void ensureEmailConstraint(List users, RealmModel realm) { UserEntity user = users.get(0); 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 0961c6c4a08..51aa1c187ab 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 @@ -986,6 +986,15 @@ public class UserStorageManager extends AbstractStorageManager getIssuedVerifiableCredentialsStreamByUser(String userId); + /** + * Remove an issued verifiable credential by its ID. + * + * @param credentialId the ID of the issued credential to remove + * @return {@code true} if the credential was removed, {@code false} if it was not found + */ + boolean removeIssuedVerifiableCredential(String credentialId); + } 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 d1f4cd718bb..8c7b0e52e4b 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 @@ -207,6 +207,29 @@ public class UserVerifiableCredentialResource { .toList(); } + @DELETE + @Path("issued-credentials/{id}") + @Tag(name = KeycloakOpenAPI.Admin.Tags.USERS) + @Operation(summary = "Revoke an issued verifiable credential") + @APIResponses(value = { + @APIResponse(responseCode = "204", description = "No Content"), + @APIResponse(responseCode = "403", description = "Forbidden"), + @APIResponse(responseCode = "404", description = "Not Found") + }) + public void revokeIssuedCredential(@PathParam("id") String credentialId) { + auth.users().requireManage(user); + checkOid4VCIEnabled(); + + boolean removed = session.users().removeIssuedVerifiableCredential(credentialId); + if (!removed) { + logger.warn(String.format("Issued verifiable credential with ID '%s' not found for user '%s' in realm '%s'.", + credentialId, user.getUsername(), realm.getName())); + throw new NotFoundException("Issued 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); 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 index d7ab94d3064..913c2e64928 100644 --- 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 @@ -178,6 +178,26 @@ public class IssuedVerifiableCredentialTest extends AbstractUserTest { } } + @Test + @DatabaseTest + public void testRevokeIssuedCredential() { + String userId = createUser(); + createIssuedVcViaModelLayer(userId, CREDENTIAL_TYPE_1, "wallet-123", "rev-001"); + + UserResource userResource = managedRealm.admin().users().get(userId); + List issuedCreds = userResource.verifiableCredentials().getIssuedCredentials(); + assertThat(issuedCreds, hasSize(1)); + + String credentialId = issuedCreds.get(0).getId(); + + // Revoke the credential + userResource.verifiableCredentials().revokeIssuedCredential(credentialId); + + // Verify + List afterRevoke = userResource.verifiableCredentials().getIssuedCredentials(); + assertThat(afterRevoke, empty()); + } + // Helper methods protected void createIssuedVcViaModelLayer(String userId, String credentialType,