diff --git a/core/src/main/java/org/keycloak/representations/idm/oid4vc/UserVerifiableCredentialRepresentation.java b/core/src/main/java/org/keycloak/representations/idm/oid4vc/UserVerifiableCredentialRepresentation.java new file mode 100644 index 00000000000..87d20728f6c --- /dev/null +++ b/core/src/main/java/org/keycloak/representations/idm/oid4vc/UserVerifiableCredentialRepresentation.java @@ -0,0 +1,32 @@ +package org.keycloak.representations.idm.oid4vc; + +public class UserVerifiableCredentialRepresentation { + + private String credentialScopeName; + private String revision; + private Long createdDate; + + public String getCredentialScopeName() { + return credentialScopeName; + } + + public void setCredentialScopeName(String credentialScopeName) { + this.credentialScopeName = credentialScopeName; + } + + public String getRevision() { + return revision; + } + + public void setRevision(String revision) { + this.revision = revision; + } + + public Long getCreatedDate() { + return createdDate; + } + + public void setCreatedDate(Long createdDate) { + this.createdDate = createdDate; + } +} diff --git a/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/UserResource.java b/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/UserResource.java index 26340692be3..0515f959fac 100644 --- a/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/UserResource.java +++ b/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/UserResource.java @@ -326,6 +326,13 @@ public interface UserResource { @Path("consents/{client}") void revokeConsent(@PathParam("client") String clientId); + /** + * @since Keycloak server 26.7.0 + * @return {@link UserVerifiableCredentialResource} with further methods to deal with credentials and issued credentials of the user + */ + @Path("vc") + UserVerifiableCredentialResource verifiableCredentials(); + @POST @Path("impersonation") @Produces(MediaType.APPLICATION_JSON) diff --git a/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/UserVerifiableCredentialResource.java b/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/UserVerifiableCredentialResource.java new file mode 100644 index 00000000000..d524f6baef2 --- /dev/null +++ b/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/UserVerifiableCredentialResource.java @@ -0,0 +1,38 @@ +package org.keycloak.admin.client.resource; + +import java.util.List; + +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; + +import org.keycloak.representations.idm.oid4vc.UserVerifiableCredentialRepresentation; + +/** + * @since Keycloak 26.7.0 All the child endpoints are also available since that version

+ * + * This endpoint including all the child endpoints requires feature {@link org.keycloak.common.Profile.Feature#OID4VC_VCI} to be enabled and also requires "verifiable credentials" to be enabled for the realm

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