From 823057adaa6127eca80dba4df123f07098d34f65 Mon Sep 17 00:00:00 2001 From: Fabio Pinheiro Date: Thu, 4 Jul 2024 09:23:41 +0100 Subject: [PATCH 01/13] fix: Move InMemory classes to the test moduels (#1240) Signed-off-by: FabioPinheiro --- .../agent/walletapi/memory/GenericSecretStorageInMemory.scala | 0 .../agent/walletapi/memory/WalletSecretStorageInMemory.scala | 0 .../connect/core/repository/ConnectionRepositoryInMemory.scala | 0 .../core/repository/CredentialDefinitionRepositoryInMemory.scala | 0 .../pollux/core/repository/CredentialRepositoryInMemory.scala | 0 .../core/repository/CredentialStatusListRepositoryInMemory.scala | 0 .../pollux/core/repository/PresentationRepositoryInMemory.scala | 0 7 files changed, 0 insertions(+), 0 deletions(-) rename cloud-agent/service/wallet-api/src/{main => test}/scala/org/hyperledger/identus/agent/walletapi/memory/GenericSecretStorageInMemory.scala (100%) rename cloud-agent/service/wallet-api/src/{main => test}/scala/org/hyperledger/identus/agent/walletapi/memory/WalletSecretStorageInMemory.scala (100%) rename connect/core/src/{main => test}/scala/org/hyperledger/identus/connect/core/repository/ConnectionRepositoryInMemory.scala (100%) rename pollux/core/src/{main => test}/scala/org/hyperledger/identus/pollux/core/repository/CredentialDefinitionRepositoryInMemory.scala (100%) rename pollux/core/src/{main => test}/scala/org/hyperledger/identus/pollux/core/repository/CredentialRepositoryInMemory.scala (100%) rename pollux/core/src/{main => test}/scala/org/hyperledger/identus/pollux/core/repository/CredentialStatusListRepositoryInMemory.scala (100%) rename pollux/core/src/{main => test}/scala/org/hyperledger/identus/pollux/core/repository/PresentationRepositoryInMemory.scala (100%) diff --git a/cloud-agent/service/wallet-api/src/main/scala/org/hyperledger/identus/agent/walletapi/memory/GenericSecretStorageInMemory.scala b/cloud-agent/service/wallet-api/src/test/scala/org/hyperledger/identus/agent/walletapi/memory/GenericSecretStorageInMemory.scala similarity index 100% rename from cloud-agent/service/wallet-api/src/main/scala/org/hyperledger/identus/agent/walletapi/memory/GenericSecretStorageInMemory.scala rename to cloud-agent/service/wallet-api/src/test/scala/org/hyperledger/identus/agent/walletapi/memory/GenericSecretStorageInMemory.scala diff --git a/cloud-agent/service/wallet-api/src/main/scala/org/hyperledger/identus/agent/walletapi/memory/WalletSecretStorageInMemory.scala b/cloud-agent/service/wallet-api/src/test/scala/org/hyperledger/identus/agent/walletapi/memory/WalletSecretStorageInMemory.scala similarity index 100% rename from cloud-agent/service/wallet-api/src/main/scala/org/hyperledger/identus/agent/walletapi/memory/WalletSecretStorageInMemory.scala rename to cloud-agent/service/wallet-api/src/test/scala/org/hyperledger/identus/agent/walletapi/memory/WalletSecretStorageInMemory.scala diff --git a/connect/core/src/main/scala/org/hyperledger/identus/connect/core/repository/ConnectionRepositoryInMemory.scala b/connect/core/src/test/scala/org/hyperledger/identus/connect/core/repository/ConnectionRepositoryInMemory.scala similarity index 100% rename from connect/core/src/main/scala/org/hyperledger/identus/connect/core/repository/ConnectionRepositoryInMemory.scala rename to connect/core/src/test/scala/org/hyperledger/identus/connect/core/repository/ConnectionRepositoryInMemory.scala diff --git a/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/repository/CredentialDefinitionRepositoryInMemory.scala b/pollux/core/src/test/scala/org/hyperledger/identus/pollux/core/repository/CredentialDefinitionRepositoryInMemory.scala similarity index 100% rename from pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/repository/CredentialDefinitionRepositoryInMemory.scala rename to pollux/core/src/test/scala/org/hyperledger/identus/pollux/core/repository/CredentialDefinitionRepositoryInMemory.scala diff --git a/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/repository/CredentialRepositoryInMemory.scala b/pollux/core/src/test/scala/org/hyperledger/identus/pollux/core/repository/CredentialRepositoryInMemory.scala similarity index 100% rename from pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/repository/CredentialRepositoryInMemory.scala rename to pollux/core/src/test/scala/org/hyperledger/identus/pollux/core/repository/CredentialRepositoryInMemory.scala diff --git a/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/repository/CredentialStatusListRepositoryInMemory.scala b/pollux/core/src/test/scala/org/hyperledger/identus/pollux/core/repository/CredentialStatusListRepositoryInMemory.scala similarity index 100% rename from pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/repository/CredentialStatusListRepositoryInMemory.scala rename to pollux/core/src/test/scala/org/hyperledger/identus/pollux/core/repository/CredentialStatusListRepositoryInMemory.scala diff --git a/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/repository/PresentationRepositoryInMemory.scala b/pollux/core/src/test/scala/org/hyperledger/identus/pollux/core/repository/PresentationRepositoryInMemory.scala similarity index 100% rename from pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/repository/PresentationRepositoryInMemory.scala rename to pollux/core/src/test/scala/org/hyperledger/identus/pollux/core/repository/PresentationRepositoryInMemory.scala From cfd5101f18276b9f59830c47c0d7fa64b30662db Mon Sep 17 00:00:00 2001 From: Bassam Date: Fri, 5 Jul 2024 06:00:07 -0400 Subject: [PATCH 02/13] fix: Wallet Management Error Handling (#1248) Signed-off-by: Bassam Riman --- .../identus/agent/server/Modules.scala | 10 +- .../oidc/KeycloakAuthenticatorImpl.scala | 12 +- .../DefaultPermissionManagementService.scala | 27 ++-- .../EntityPermissionManagementService.scala | 27 ++-- .../core/PermissionManagement.scala | 38 ------ .../core/PermissionManagementService.scala | 19 +++ .../PermissionManagementServiceError.scala | 50 +++++++ .../KeycloakPermissionManagementService.scala | 122 ++++++++++-------- .../WalletManagementController.scala | 21 +-- .../CredentialIssuerServerEndpoints.scala | 1 + .../server/AgentInitializationSpec.scala | 2 +- .../core/EntityPermissionManagementSpec.scala | 13 +- ...cloakPermissionManagementServiceSpec.scala | 22 ++-- .../service/handler/DIDCreateHandler.scala | 2 +- .../service/handler/DIDUpdateHandler.scala | 2 +- .../sql/JdbcWalletSecretStorage.scala | 2 +- .../storage/WalletSecretStorage.scala | 2 +- .../agent/walletapi/util/KeyResolver.scala | 2 +- .../vault/VaultWalletSecretStorage.scala | 2 +- .../memory/WalletSecretStorageInMemory.scala | 2 +- .../service/WalletManagementServiceSpec.scala | 8 +- .../storage/WalletSecretStorageSpec.scala | 6 +- .../identus/pollux/vc/jwt/Proof.scala | 2 - 23 files changed, 218 insertions(+), 176 deletions(-) create mode 100644 cloud-agent/service/server/src/main/scala/org/hyperledger/identus/iam/authorization/core/PermissionManagementService.scala create mode 100644 cloud-agent/service/server/src/main/scala/org/hyperledger/identus/iam/authorization/core/PermissionManagementServiceError.scala diff --git a/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/agent/server/Modules.scala b/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/agent/server/Modules.scala index 6f0dc8996f..492fb55b7c 100644 --- a/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/agent/server/Modules.scala +++ b/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/agent/server/Modules.scala @@ -38,7 +38,7 @@ import org.hyperledger.identus.iam.authentication.oidc.{ KeycloakConfig, KeycloakEntity } -import org.hyperledger.identus.iam.authorization.core.PermissionManagement +import org.hyperledger.identus.iam.authorization.core.PermissionManagementService import org.hyperledger.identus.iam.authorization.keycloak.admin.KeycloakPermissionManagementService import org.hyperledger.identus.pollux.vc.jwt.{DidResolver as JwtDidResolver, PrismDidResolver} import org.hyperledger.identus.shared.crypto.Apollo @@ -103,7 +103,7 @@ object AppModule { ) val keycloakAuthenticatorLayer: RLayer[ - AppConfig & WalletManagementService & Client & PermissionManagement.Service[KeycloakEntity], + AppConfig & WalletManagementService & Client & PermissionManagementService[KeycloakEntity], KeycloakAuthenticator ] = ZLayer.fromZIO { @@ -113,7 +113,7 @@ object AppModule { if (!isEnabled) KeycloakAuthenticatorImpl.disabled else ZLayer.makeSome[ - AppConfig & WalletManagementService & Client & PermissionManagement.Service[KeycloakEntity], + AppConfig & WalletManagementService & Client & PermissionManagementService[KeycloakEntity], KeycloakAuthenticator ]( KeycloakConfig.layer, @@ -125,14 +125,14 @@ object AppModule { }.flatten val keycloakPermissionManagementLayer - : RLayer[AppConfig & WalletManagementService & Client, PermissionManagement.Service[KeycloakEntity]] = { + : RLayer[AppConfig & WalletManagementService & Client, PermissionManagementService[KeycloakEntity]] = { ZLayer.fromZIO { ZIO .serviceWith[AppConfig](_.agent.authentication.keycloak.enabled) .map { isEnabled => if (!isEnabled) KeycloakPermissionManagementService.disabled else - ZLayer.makeSome[AppConfig & WalletManagementService & Client, PermissionManagement.Service[KeycloakEntity]]( + ZLayer.makeSome[AppConfig & WalletManagementService & Client, PermissionManagementService[KeycloakEntity]]( KeycloakClientImpl.authzClientLayer, KeycloakClientImpl.layer, KeycloakConfig.layer, diff --git a/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/iam/authentication/oidc/KeycloakAuthenticatorImpl.scala b/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/iam/authentication/oidc/KeycloakAuthenticatorImpl.scala index 5c416a4d19..6f3426ecb4 100644 --- a/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/iam/authentication/oidc/KeycloakAuthenticatorImpl.scala +++ b/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/iam/authentication/oidc/KeycloakAuthenticatorImpl.scala @@ -3,8 +3,8 @@ package org.hyperledger.identus.iam.authentication.oidc import org.hyperledger.identus.agent.walletapi.model.EntityRole import org.hyperledger.identus.iam.authentication.AuthenticationError import org.hyperledger.identus.iam.authentication.AuthenticationError.AuthenticationMethodNotEnabled -import org.hyperledger.identus.iam.authorization.core.PermissionManagement -import org.hyperledger.identus.iam.authorization.core.PermissionManagement.Error.PermissionNotAvailable +import org.hyperledger.identus.iam.authorization.core.PermissionManagementService +import org.hyperledger.identus.iam.authorization.core.PermissionManagementServiceError.PermissionNotAvailable import org.hyperledger.identus.shared.models.{WalletAccessContext, WalletAdministrationContext} import zio.* @@ -13,7 +13,7 @@ import java.util.UUID class KeycloakAuthenticatorImpl( client: KeycloakClient, keycloakConfig: KeycloakConfig, - keycloakPermissionService: PermissionManagement.Service[KeycloakEntity], + keycloakPermissionService: PermissionManagementService[KeycloakEntity], ) extends KeycloakAuthenticator { override def isEnabled: Boolean = keycloakConfig.enabled @@ -48,7 +48,7 @@ class KeycloakAuthenticatorImpl( .listWalletPermissions(entity) .mapError { case PermissionNotAvailable(_, msg) => AuthenticationError.InvalidCredentials(msg) - case e => AuthenticationError.UnexpectedError(e.message) + case e => AuthenticationError.UnexpectedError(e.userFacingMessage) } .flatMap { case head +: Nil => ZIO.succeed(head) @@ -68,7 +68,7 @@ class KeycloakAuthenticatorImpl( .listWalletPermissions(entity) .provide(ZLayer.succeed(WalletAdministrationContext.Admin())) .mapBoth( - e => AuthenticationError.UnexpectedError(e.message), + e => AuthenticationError.UnexpectedError(e.userFacingMessage), wallets => WalletAdministrationContext.SelfService(wallets) ) @@ -90,7 +90,7 @@ class KeycloakAuthenticatorImpl( object KeycloakAuthenticatorImpl { val layer: RLayer[ - KeycloakClient & KeycloakConfig & PermissionManagement.Service[KeycloakEntity], + KeycloakClient & KeycloakConfig & PermissionManagementService[KeycloakEntity], KeycloakAuthenticator ] = ZLayer.fromFunction(KeycloakAuthenticatorImpl(_, _, _)) diff --git a/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/iam/authorization/DefaultPermissionManagementService.scala b/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/iam/authorization/DefaultPermissionManagementService.scala index 53a33371f7..18121922b1 100644 --- a/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/iam/authorization/DefaultPermissionManagementService.scala +++ b/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/iam/authorization/DefaultPermissionManagementService.scala @@ -2,31 +2,38 @@ package org.hyperledger.identus.iam.authorization import org.hyperledger.identus.agent.walletapi.model.{BaseEntity, Entity} import org.hyperledger.identus.iam.authentication.oidc.KeycloakEntity -import org.hyperledger.identus.iam.authorization.core.PermissionManagement -import org.hyperledger.identus.iam.authorization.core.PermissionManagement.Error +import org.hyperledger.identus.iam.authorization.core.{PermissionManagementService, PermissionManagementServiceError} import org.hyperledger.identus.shared.models.{WalletAdministrationContext, WalletId} import zio.* class DefaultPermissionManagementService( - entityPermission: PermissionManagement.Service[Entity], - keycloakPermission: PermissionManagement.Service[KeycloakEntity] -) extends PermissionManagement.Service[BaseEntity] { + entityPermission: PermissionManagementService[Entity], + keycloakPermission: PermissionManagementService[KeycloakEntity] +) extends PermissionManagementService[BaseEntity] { - def grantWalletToUser(walletId: WalletId, entity: BaseEntity): ZIO[WalletAdministrationContext, Error, Unit] = { + def grantWalletToUser( + walletId: WalletId, + entity: BaseEntity + ): ZIO[WalletAdministrationContext, PermissionManagementServiceError, Unit] = { entity match { case entity: Entity => entityPermission.grantWalletToUser(walletId, entity) case kcEntity: KeycloakEntity => keycloakPermission.grantWalletToUser(walletId, kcEntity) } } - def revokeWalletFromUser(walletId: WalletId, entity: BaseEntity): ZIO[WalletAdministrationContext, Error, Unit] = { + def revokeWalletFromUser( + walletId: WalletId, + entity: BaseEntity + ): ZIO[WalletAdministrationContext, PermissionManagementServiceError, Unit] = { entity match { case entity: Entity => entityPermission.revokeWalletFromUser(walletId, entity) case kcEntity: KeycloakEntity => keycloakPermission.revokeWalletFromUser(walletId, kcEntity) } } - def listWalletPermissions(entity: BaseEntity): ZIO[WalletAdministrationContext, Error, Seq[WalletId]] = { + def listWalletPermissions( + entity: BaseEntity + ): ZIO[WalletAdministrationContext, PermissionManagementServiceError, Seq[WalletId]] = { entity match { case entity: Entity => entityPermission.listWalletPermissions(entity) case kcEntity: KeycloakEntity => keycloakPermission.listWalletPermissions(kcEntity) @@ -37,8 +44,8 @@ class DefaultPermissionManagementService( object DefaultPermissionManagementService { def layer: URLayer[ - PermissionManagement.Service[KeycloakEntity] & PermissionManagement.Service[Entity], - PermissionManagement.Service[BaseEntity] + PermissionManagementService[KeycloakEntity] & PermissionManagementService[Entity], + PermissionManagementService[BaseEntity] ] = ZLayer.fromFunction(DefaultPermissionManagementService(_, _)) } diff --git a/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/iam/authorization/core/EntityPermissionManagementService.scala b/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/iam/authorization/core/EntityPermissionManagementService.scala index 6b6f5ebba8..a27b8fd84c 100644 --- a/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/iam/authorization/core/EntityPermissionManagementService.scala +++ b/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/iam/authorization/core/EntityPermissionManagementService.scala @@ -2,38 +2,45 @@ package org.hyperledger.identus.iam.authorization.core import org.hyperledger.identus.agent.walletapi.model.Entity import org.hyperledger.identus.agent.walletapi.service.EntityService -import org.hyperledger.identus.iam.authorization.core.PermissionManagement.Error -import org.hyperledger.identus.iam.authorization.core.PermissionManagement.Error.{ServiceError, WalletNotFoundById} +import org.hyperledger.identus.iam.authorization.core.PermissionManagementServiceError.* import org.hyperledger.identus.shared.models.{WalletAdministrationContext, WalletId} import zio.* import scala.language.implicitConversions -class EntityPermissionManagementService(entityService: EntityService) extends PermissionManagement.Service[Entity] { +class EntityPermissionManagementService(entityService: EntityService) extends PermissionManagementService[Entity] { - override def grantWalletToUser(walletId: WalletId, entity: Entity): ZIO[WalletAdministrationContext, Error, Unit] = { + override def grantWalletToUser( + walletId: WalletId, + entity: Entity + ): ZIO[WalletAdministrationContext, PermissionManagementServiceError, Unit] = { for { _ <- ZIO .serviceWith[WalletAdministrationContext](_.isAuthorized(walletId)) - .filterOrFail(identity)(Error.WalletNotFoundById(walletId)) + .filterOrFail(identity)(WalletNotFoundById(walletId)) _ <- entityService.assignWallet(entity.id, walletId.toUUID).orDieAsUnmanagedFailure } yield () } - override def revokeWalletFromUser(walletId: WalletId, entity: Entity): ZIO[WalletAdministrationContext, Error, Unit] = - ZIO.fail(Error.ServiceError(s"Revoking wallet permission for an Entity is not yet supported.")) + override def revokeWalletFromUser( + walletId: WalletId, + entity: Entity + ): ZIO[WalletAdministrationContext, PermissionManagementServiceError, Unit] = + ZIO.fail(ServiceError(s"Revoking wallet permission for an Entity is not yet supported.")) - override def listWalletPermissions(entity: Entity): ZIO[WalletAdministrationContext, Error, Seq[WalletId]] = { + override def listWalletPermissions( + entity: Entity + ): ZIO[WalletAdministrationContext, PermissionManagementServiceError, Seq[WalletId]] = { val walletId = WalletId.fromUUID(entity.walletId) ZIO .serviceWith[WalletAdministrationContext](_.isAuthorized(walletId)) - .filterOrFail(identity)(Error.WalletNotFoundById(walletId)) + .filterOrFail(identity)(WalletNotFoundById(walletId)) .as(Seq(walletId)) } } object EntityPermissionManagementService { - val layer: URLayer[EntityService, PermissionManagement.Service[Entity]] = + val layer: URLayer[EntityService, PermissionManagementService[Entity]] = ZLayer.fromFunction(EntityPermissionManagementService(_)) } diff --git a/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/iam/authorization/core/PermissionManagement.scala b/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/iam/authorization/core/PermissionManagement.scala index ef10b8bbca..8b13789179 100644 --- a/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/iam/authorization/core/PermissionManagement.scala +++ b/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/iam/authorization/core/PermissionManagement.scala @@ -1,39 +1 @@ -package org.hyperledger.identus.iam.authorization.core -import org.hyperledger.identus.agent.walletapi.model.BaseEntity -import org.hyperledger.identus.shared.models.{WalletAdministrationContext, WalletId} -import zio.* - -import java.util.UUID - -object PermissionManagement { - trait Service[E <: BaseEntity] { - def grantWalletToUser(walletId: WalletId, entity: E): ZIO[WalletAdministrationContext, Error, Unit] - def revokeWalletFromUser(walletId: WalletId, entity: E): ZIO[WalletAdministrationContext, Error, Unit] - def listWalletPermissions(entity: E): ZIO[WalletAdministrationContext, Error, Seq[WalletId]] - } - - sealed trait Error(val message: String) - - object Error { - case class UserNotFoundById(userId: UUID, cause: Option[Throwable] = None) - extends Error(s"User $userId is not found" + cause.map(t => s" Cause: ${t.getMessage}")) - case class WalletNotFoundByUserId(userId: UUID) extends Error(s"Wallet for user $userId is not found") - - case class WalletNotFoundById(walletId: WalletId) extends Error(s"Wallet not found by ${walletId.toUUID}") - - case class WalletResourceNotFoundById(walletId: WalletId) - extends Error(s"Wallet resource not found by ${walletId.toUUID}") - - case class PermissionNotFoundById(userId: UUID, walletId: WalletId, walletResourceId: String) - extends Error( - s"Permission not found by userId: $userId, walletId: ${walletId.toUUID}, walletResourceId: $walletResourceId" - ) - - case class PermissionNotAvailable(userId: UUID, cause: String) extends Error(cause) - - case class UnexpectedError(cause: Throwable) extends Error(cause.getMessage) - - case class ServiceError(cause: String) extends Error(cause) - } -} diff --git a/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/iam/authorization/core/PermissionManagementService.scala b/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/iam/authorization/core/PermissionManagementService.scala new file mode 100644 index 0000000000..ce309c1561 --- /dev/null +++ b/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/iam/authorization/core/PermissionManagementService.scala @@ -0,0 +1,19 @@ +package org.hyperledger.identus.iam.authorization.core + +import org.hyperledger.identus.agent.walletapi.model.BaseEntity +import org.hyperledger.identus.shared.models.{WalletAdministrationContext, WalletId} +import zio.* + +trait PermissionManagementService[E <: BaseEntity] { + def grantWalletToUser( + walletId: WalletId, + entity: E + ): ZIO[WalletAdministrationContext, PermissionManagementServiceError, Unit] + def revokeWalletFromUser( + walletId: WalletId, + entity: E + ): ZIO[WalletAdministrationContext, PermissionManagementServiceError, Unit] + def listWalletPermissions( + entity: E + ): ZIO[WalletAdministrationContext, PermissionManagementServiceError, Seq[WalletId]] +} diff --git a/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/iam/authorization/core/PermissionManagementServiceError.scala b/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/iam/authorization/core/PermissionManagementServiceError.scala new file mode 100644 index 0000000000..7b8ca64b4a --- /dev/null +++ b/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/iam/authorization/core/PermissionManagementServiceError.scala @@ -0,0 +1,50 @@ +package org.hyperledger.identus.iam.authorization.core + +import org.hyperledger.identus.shared.models.{Failure, StatusCode, WalletId} + +import java.util.UUID + +sealed trait PermissionManagementServiceError( + val statusCode: StatusCode, + val userFacingMessage: String +) extends Failure { + override val namespace: String = "PermissionManagementServiceError" +} + +object PermissionManagementServiceError { + + case class UserNotFoundById(userId: UUID, cause: Option[Throwable] = None) + extends PermissionManagementServiceError( + StatusCode.BadRequest, + s"User $userId is not found" + cause.map(t => s" Cause: ${t.getMessage}") + ) + + case class WalletNotFoundByUserId(userId: UUID) + extends PermissionManagementServiceError( + StatusCode.BadRequest, + s"Wallet for user $userId is not found" + ) + + case class WalletNotFoundById(walletId: WalletId) + extends PermissionManagementServiceError( + StatusCode.BadRequest, + s"Wallet not found by ${walletId.toUUID}" + ) + + case class WalletResourceNotFoundById(walletId: WalletId) + extends PermissionManagementServiceError( + StatusCode.BadRequest, + s"Wallet resource not found by ${walletId.toUUID}" + ) + + case class PermissionNotFoundById(userId: UUID, walletId: WalletId, walletResourceId: String) + extends PermissionManagementServiceError( + StatusCode.BadRequest, + s"Permission not found by userId: $userId, walletId: ${walletId.toUUID}, walletResourceId: $walletResourceId" + ) + + case class PermissionNotAvailable(userId: UUID, cause: String) + extends PermissionManagementServiceError(StatusCode.BadRequest, cause) + + case class ServiceError(cause: String) extends PermissionManagementServiceError(StatusCode.InternalServerError, cause) +} diff --git a/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/iam/authorization/keycloak/admin/KeycloakPermissionManagementService.scala b/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/iam/authorization/keycloak/admin/KeycloakPermissionManagementService.scala index b06586d798..d4eb6963cc 100644 --- a/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/iam/authorization/keycloak/admin/KeycloakPermissionManagementService.scala +++ b/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/iam/authorization/keycloak/admin/KeycloakPermissionManagementService.scala @@ -3,9 +3,8 @@ package org.hyperledger.identus.iam.authorization.keycloak.admin import org.hyperledger.identus.agent.walletapi.model.Wallet import org.hyperledger.identus.agent.walletapi.service.WalletManagementService import org.hyperledger.identus.iam.authentication.oidc.{KeycloakClient, KeycloakEntity} -import org.hyperledger.identus.iam.authorization.core.PermissionManagement -import org.hyperledger.identus.iam.authorization.core.PermissionManagement.Error -import org.hyperledger.identus.iam.authorization.core.PermissionManagement.Error.* +import org.hyperledger.identus.iam.authorization.core.{PermissionManagementService, PermissionManagementServiceError} +import org.hyperledger.identus.iam.authorization.core.PermissionManagementServiceError.* import org.hyperledger.identus.shared.models.{WalletAdministrationContext, WalletId} import org.keycloak.authorization.client.AuthzClient import org.keycloak.representations.idm.authorization.{ResourceRepresentation, UmaPermissionRepresentation} @@ -19,7 +18,7 @@ case class KeycloakPermissionManagementService( authzClient: AuthzClient, keycloakClient: KeycloakClient, walletManagementService: WalletManagementService -) extends PermissionManagement.Service[KeycloakEntity] { +) extends PermissionManagementService[KeycloakEntity] { private def walletResourceName(walletId: WalletId) = s"wallet-${walletId.toUUID.toString}" @@ -28,60 +27,58 @@ case class KeycloakPermissionManagementService( override def grantWalletToUser( walletId: WalletId, entity: KeycloakEntity - ): ZIO[WalletAdministrationContext, PermissionManagement.Error, Unit] = { + ): ZIO[WalletAdministrationContext, PermissionManagementServiceError, Unit] = { for { _ <- walletManagementService .findWallet(walletId) .someOrFail(WalletNotFoundById(walletId)) walletResourceOpt <- findWalletResource(walletId) - .logError("Error while finding wallet resource") - .mapError(UnexpectedError.apply) walletResource <- ZIO .fromOption(walletResourceOpt) .orElse(createWalletResource(walletId)) - .logError("Error while creating wallet resource") - .mapError(UnexpectedError.apply) _ <- ZIO.log(s"Wallet resource created ${walletResource.toString}") permission <- createResourcePermission(walletResource.getId, entity.id.toString) - .mapError(UnexpectedError.apply) _ <- ZIO.log(s"Permission created with id ${permission.getId} and name ${permission.getName}") } yield () } - private def createResourcePermission(resourceId: String, userId: String): Task[UmaPermissionRepresentation] = { + private def createResourcePermission(resourceId: String, userId: String): UIO[UmaPermissionRepresentation] = { val policy = UmaPermissionRepresentation() policy.setName(policyName(userId, resourceId)) policy.setUsers(Set(userId).asJava) for { - umaPermissionRepresentation <- ZIO.attemptBlocking( - authzClient - .protection() - .policy(resourceId) - .create(policy) - ) + umaPermissionRepresentation <- ZIO + .attemptBlocking( + authzClient + .protection() + .policy(resourceId) + .create(policy) + ) + .orDie } yield umaPermissionRepresentation } - private def findWalletResource(walletId: WalletId): Task[Option[ResourceRepresentation]] = { + private def findWalletResource(walletId: WalletId): UIO[Option[ResourceRepresentation]] = { for { walletResource <- ZIO .attemptBlocking( - authzClient.protection().resource().findById(walletId.toUUID.toString()) + authzClient.protection().resource().findById(walletId.toUUID.toString) ) .asSome .catchSome { case e: RuntimeException => - if (e.getMessage().contains("Could not find resource")) ZIO.none + if (e.getMessage.contains("Could not find resource")) ZIO.none else ZIO.fail(e) } + .orDie } yield walletResource } - private def createWalletResource(walletId: WalletId): Task[ResourceRepresentation] = { + private def createWalletResource(walletId: WalletId): UIO[ResourceRepresentation] = { val walletResource = ResourceRepresentation() walletResource.setId(walletId.toUUID.toString) walletResource.setUris(Set(s"/wallets/${walletResourceName(walletId)}").asJava) @@ -90,18 +87,22 @@ case class KeycloakPermissionManagementService( for { _ <- ZIO.log(s"Creating resource for the wallet ${walletId.toUUID.toString}") - response <- ZIO.attemptBlocking( - authzClient - .protection() - .resource() - .create(walletResource) - ) - resource <- ZIO.attemptBlocking( - authzClient - .protection() - .resource() - .findById(walletResource.getId) - ) + response <- ZIO + .attemptBlocking( + authzClient + .protection() + .resource() + .create(walletResource) + ) + .orDie + resource <- ZIO + .attemptBlocking( + authzClient + .protection() + .resource() + .findById(walletResource.getId) + ) + .orDie _ <- ZIO.log(s"Resource for the wallet created id: ${resource.getId}, name ${resource.getName}") } yield resource } @@ -109,17 +110,14 @@ case class KeycloakPermissionManagementService( override def revokeWalletFromUser( walletId: WalletId, entity: KeycloakEntity - ): ZIO[WalletAdministrationContext, PermissionManagement.Error, Unit] = { + ): ZIO[WalletAdministrationContext, PermissionManagementServiceError, Unit] = { val userId = entity.id for { _ <- walletManagementService .findWallet(walletId) .someOrFail(WalletNotFoundById(walletId)) - walletResource <- findWalletResource(walletId) - .logError("Error while finding wallet resource") - .mapError(UnexpectedError.apply) - .someOrFail(WalletResourceNotFoundById(walletId)) + walletResource <- findWalletResource(walletId).someOrFail(WalletNotFoundById(walletId)) permissionOpt <- ZIO .attemptBlocking( @@ -133,9 +131,8 @@ case class KeycloakPermissionManagementService( 1 ) ) + .orDie .map(_.asScala.headOption) - .logError(s"Error while finding permission by name ${policyName(userId.toString, walletResource.getId)}") - .mapError(UnexpectedError.apply) permission <- ZIO .fromOption(permissionOpt) @@ -148,8 +145,7 @@ case class KeycloakPermissionManagementService( .policy(walletResource.getId) .delete(permission.getId) ) - .logError(s"Error while deleting permission ${permission.getId}") - .mapError(UnexpectedError.apply) + .orDie _ <- ZIO.log( s"Permission ${permission.getId} deleted for user ${userId.toString} and wallet ${walletResource.getId}" @@ -157,29 +153,33 @@ case class KeycloakPermissionManagementService( } yield () } - override def listWalletPermissions(entity: KeycloakEntity): ZIO[WalletAdministrationContext, Error, Seq[WalletId]] = { + override def listWalletPermissions( + entity: KeycloakEntity + ): ZIO[WalletAdministrationContext, PermissionManagementServiceError, Seq[WalletId]] = { for { token <- ZIO .fromOption(entity.accessToken) - .mapError(_ => Error.ServiceError("AccessToken is missing for listing permissions.")) - tokenIsRpt <- ZIO.fromEither(token.isRpt).mapError(Error.ServiceError(_)) + .mapError(_ => ServiceError("AccessToken is missing for listing permissions.")) + tokenIsRpt <- ZIO.fromEither(token.isRpt).mapError(ServiceError.apply) rpt <- if (tokenIsRpt) ZIO.succeed(token) else if (keycloakClient.keycloakConfig.autoUpgradeToRPT) { keycloakClient .getRpt(token) .logError("Fail to obtail RPT for wallet permissions") - .mapError(e => Error.ServiceError(e.message)) - } else ZIO.fail(Error.PermissionNotAvailable(entity.id, s"AccessToken is not RPT.")) + .mapError(e => ServiceError(e.message)) + } else ZIO.fail(PermissionNotAvailable(entity.id, s"AccessToken is not RPT.")) permittedResources <- keycloakClient .checkPermissions(rpt) .logError("Fail to list resource permissions on keycloak") - .mapError(e => Error.ServiceError(e.message)) + .mapError(e => ServiceError(e.message)) permittedWallet <- getPermittedWallet(permittedResources) } yield permittedWallet.map(_.id) } - private def getPermittedWallet(resourceIds: Seq[String]): ZIO[WalletAdministrationContext, Error, Seq[Wallet]] = { + private def getPermittedWallet( + resourceIds: Seq[String] + ): ZIO[WalletAdministrationContext, PermissionManagementServiceError, Seq[Wallet]] = { val walletIds = resourceIds.flatMap(id => Try(UUID.fromString(id)).toOption).map(WalletId.fromUUID) walletManagementService .getWallets(walletIds) @@ -189,17 +189,27 @@ case class KeycloakPermissionManagementService( object KeycloakPermissionManagementService { val layer: URLayer[ AuthzClient & KeycloakClient & WalletManagementService, - PermissionManagement.Service[KeycloakEntity] + PermissionManagementService[KeycloakEntity] ] = ZLayer.fromFunction(KeycloakPermissionManagementService(_, _, _)) - val disabled: ULayer[PermissionManagement.Service[KeycloakEntity]] = + val disabled: ULayer[PermissionManagementService[KeycloakEntity]] = ZLayer.succeed { - val notEnabledError = ZIO.fail(PermissionManagement.Error.ServiceError("Keycloak is not enabled")) - new PermissionManagement.Service[KeycloakEntity] { - override def grantWalletToUser(walletId: WalletId, entity: KeycloakEntity): IO[Error, Unit] = notEnabledError - override def revokeWalletFromUser(walletId: WalletId, entity: KeycloakEntity): IO[Error, Unit] = notEnabledError - override def listWalletPermissions(entity: KeycloakEntity): IO[Error, Seq[WalletId]] = notEnabledError + val notEnabledError = ZIO.fail(ServiceError("Keycloak is not enabled")) + new PermissionManagementService[KeycloakEntity] { + override def grantWalletToUser( + walletId: WalletId, + entity: KeycloakEntity + ): IO[PermissionManagementServiceError, Unit] = notEnabledError + + override def revokeWalletFromUser( + walletId: WalletId, + entity: KeycloakEntity + ): IO[PermissionManagementServiceError, Unit] = notEnabledError + + override def listWalletPermissions( + entity: KeycloakEntity + ): IO[PermissionManagementServiceError, Seq[WalletId]] = notEnabledError } } } diff --git a/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/iam/wallet/http/controller/WalletManagementController.scala b/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/iam/wallet/http/controller/WalletManagementController.scala index 2f070e39cc..ed271ffc85 100644 --- a/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/iam/wallet/http/controller/WalletManagementController.scala +++ b/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/iam/wallet/http/controller/WalletManagementController.scala @@ -6,7 +6,7 @@ import org.hyperledger.identus.api.http.{ErrorResponse, RequestContext} import org.hyperledger.identus.api.http.model.{CollectionStats, PaginationInput} import org.hyperledger.identus.api.util.PaginationUtils import org.hyperledger.identus.iam.authentication.oidc.KeycloakEntity -import org.hyperledger.identus.iam.authorization.core.PermissionManagement +import org.hyperledger.identus.iam.authorization.core.PermissionManagementService import org.hyperledger.identus.iam.wallet.http.model.{ CreateWalletRequest, CreateWalletUmaPermissionRequest, @@ -41,26 +41,11 @@ trait WalletManagementController { )(implicit rc: RequestContext): ZIO[WalletAdministrationContext, ErrorResponse, Unit] } -object WalletManagementController { - given permissionManagementErrorConversion: Conversion[PermissionManagement.Error, ErrorResponse] = { - case e: PermissionManagement.Error.PermissionNotFoundById => ErrorResponse.badRequest(detail = Some(e.message)) - case e: PermissionManagement.Error.ServiceError => ErrorResponse.internalServerError(detail = Some(e.message)) - case e: PermissionManagement.Error.UnexpectedError => ErrorResponse.internalServerError(detail = Some(e.message)) - case e: PermissionManagement.Error.UserNotFoundById => ErrorResponse.badRequest(detail = Some(e.message)) - case e: PermissionManagement.Error.WalletNotFoundById => ErrorResponse.badRequest(detail = Some(e.message)) - case e: PermissionManagement.Error.WalletNotFoundByUserId => ErrorResponse.badRequest(detail = Some(e.message)) - case e: PermissionManagement.Error.WalletResourceNotFoundById => ErrorResponse.badRequest(detail = Some(e.message)) - case e: PermissionManagement.Error.PermissionNotAvailable => ErrorResponse.badRequest(detail = Some(e.message)) - } -} - class WalletManagementControllerImpl( walletService: WalletManagementService, - permissionService: PermissionManagement.Service[BaseEntity], + permissionService: PermissionManagementService[BaseEntity], ) extends WalletManagementController { - import WalletManagementController.given - override def listWallet( paginationInput: PaginationInput )(implicit rc: RequestContext): ZIO[WalletAdministrationContext, ErrorResponse, WalletDetailPage] = { @@ -152,6 +137,6 @@ class WalletManagementControllerImpl( } object WalletManagementControllerImpl { - val layer: URLayer[WalletManagementService & PermissionManagement.Service[BaseEntity], WalletManagementController] = + val layer: URLayer[WalletManagementService & PermissionManagementService[BaseEntity], WalletManagementController] = ZLayer.fromFunction(WalletManagementControllerImpl(_, _)) } diff --git a/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/oid4vci/CredentialIssuerServerEndpoints.scala b/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/oid4vci/CredentialIssuerServerEndpoints.scala index c1fb766d0d..f3bd3be37d 100644 --- a/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/oid4vci/CredentialIssuerServerEndpoints.scala +++ b/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/oid4vci/CredentialIssuerServerEndpoints.scala @@ -2,6 +2,7 @@ package org.hyperledger.identus.oid4vci import org.hyperledger.identus.agent.walletapi.model.BaseEntity import org.hyperledger.identus.api.http.ErrorResponse +import org.hyperledger.identus.iam.authentication.* import org.hyperledger.identus.iam.authentication.{ Authenticator, Authorizer, diff --git a/cloud-agent/service/server/src/test/scala/org/hyperledger/identus/agent/server/AgentInitializationSpec.scala b/cloud-agent/service/server/src/test/scala/org/hyperledger/identus/agent/server/AgentInitializationSpec.scala index 9fbd912937..33db03632e 100644 --- a/cloud-agent/service/server/src/test/scala/org/hyperledger/identus/agent/server/AgentInitializationSpec.scala +++ b/cloud-agent/service/server/src/test/scala/org/hyperledger/identus/agent/server/AgentInitializationSpec.scala @@ -103,7 +103,7 @@ object AgentInitializationSpec extends ZIOSpecDefault, PostgresTestContainerSupp _ <- AgentInitialization.run.overrideConfig(seed = Some("0" * 128)) actualSeed <- ZIO .serviceWithZIO[WalletSecretStorage]( - _.getWalletSeed + _.findWalletSeed .provide(ZLayer.succeed(WalletAccessContext(WalletId.default))) ) } yield assert(actualSeed.get.toByteArray)(equalTo(Array.fill[Byte](64)(0))) diff --git a/cloud-agent/service/server/src/test/scala/org/hyperledger/identus/iam/authorization/core/EntityPermissionManagementSpec.scala b/cloud-agent/service/server/src/test/scala/org/hyperledger/identus/iam/authorization/core/EntityPermissionManagementSpec.scala index d1e1b9e1ac..6bb296c9a6 100644 --- a/cloud-agent/service/server/src/test/scala/org/hyperledger/identus/iam/authorization/core/EntityPermissionManagementSpec.scala +++ b/cloud-agent/service/server/src/test/scala/org/hyperledger/identus/iam/authorization/core/EntityPermissionManagementSpec.scala @@ -12,7 +12,10 @@ import org.hyperledger.identus.agent.walletapi.sql.{ JdbcWalletNonSecretStorage, JdbcWalletSecretStorage } -import org.hyperledger.identus.iam.authorization.core.PermissionManagement.Error.{ServiceError, WalletNotFoundById} +import org.hyperledger.identus.iam.authorization.core.PermissionManagementServiceError.{ + ServiceError, + WalletNotFoundById +} import org.hyperledger.identus.shared.crypto.ApolloSpecHelper import org.hyperledger.identus.shared.models.{WalletAdministrationContext, WalletId} import org.hyperledger.identus.sharedtest.containers.PostgresTestContainerSupport @@ -48,7 +51,7 @@ object EntityPermissionManagementSpec extends ZIOSpecDefault, PostgresTestContai test("grant wallet access to the user") { for { entityService <- ZIO.service[EntityService] - permissionService <- ZIO.service[PermissionManagement.Service[Entity]] + permissionService <- ZIO.service[PermissionManagementService[Entity]] walletService <- ZIO.service[WalletManagementService] wallet1 <- walletService .createWallet(Wallet("test")) @@ -73,7 +76,7 @@ object EntityPermissionManagementSpec extends ZIOSpecDefault, PostgresTestContai test("revoke wallet is not support") { for { entityService <- ZIO.service[EntityService] - permissionService <- ZIO.service[PermissionManagement.Service[Entity]] + permissionService <- ZIO.service[PermissionManagementService[Entity]] walletService <- ZIO.service[WalletManagementService] wallet1 <- walletService .createWallet(Wallet("test")) @@ -94,7 +97,7 @@ object EntityPermissionManagementSpec extends ZIOSpecDefault, PostgresTestContai val walletId2 = WalletId.random for { entityService <- ZIO.service[EntityService] - permissionService <- ZIO.service[PermissionManagement.Service[Entity]] + permissionService <- ZIO.service[PermissionManagementService[Entity]] walletService <- ZIO.service[WalletManagementService] wallet1 <- walletService .createWallet(Wallet("test", walletId1)) @@ -117,7 +120,7 @@ object EntityPermissionManagementSpec extends ZIOSpecDefault, PostgresTestContai val walletId2 = WalletId.random for { entityService <- ZIO.service[EntityService] - permissionService <- ZIO.service[PermissionManagement.Service[Entity]] + permissionService <- ZIO.service[PermissionManagementService[Entity]] walletService <- ZIO.service[WalletManagementService] wallet1 <- walletService .createWallet(Wallet("test", walletId1)) diff --git a/cloud-agent/service/server/src/test/scala/org/hyperledger/identus/iam/authorization/keycloak/admin/KeycloakPermissionManagementServiceSpec.scala b/cloud-agent/service/server/src/test/scala/org/hyperledger/identus/iam/authorization/keycloak/admin/KeycloakPermissionManagementServiceSpec.scala index c215c0494e..c4e98d6d17 100644 --- a/cloud-agent/service/server/src/test/scala/org/hyperledger/identus/iam/authorization/keycloak/admin/KeycloakPermissionManagementServiceSpec.scala +++ b/cloud-agent/service/server/src/test/scala/org/hyperledger/identus/iam/authorization/keycloak/admin/KeycloakPermissionManagementServiceSpec.scala @@ -5,8 +5,8 @@ import org.hyperledger.identus.agent.walletapi.service.{WalletManagementService, import org.hyperledger.identus.agent.walletapi.sql.{JdbcWalletNonSecretStorage, JdbcWalletSecretStorage} import org.hyperledger.identus.iam.authentication.oidc.* import org.hyperledger.identus.iam.authentication.AuthenticationError.ResourceNotPermitted -import org.hyperledger.identus.iam.authorization.core.PermissionManagement -import org.hyperledger.identus.iam.authorization.core.PermissionManagement.Error.{UnexpectedError, WalletNotFoundById} +import org.hyperledger.identus.iam.authorization.core.PermissionManagementService +import org.hyperledger.identus.iam.authorization.core.PermissionManagementServiceError.WalletNotFoundById import org.hyperledger.identus.shared.crypto.ApolloSpecHelper import org.hyperledger.identus.shared.models.{WalletAccessContext, WalletAdministrationContext, WalletId} import org.hyperledger.identus.sharedtest.containers.{ @@ -72,7 +72,7 @@ object KeycloakPermissionManagementServiceSpec user <- createUser(username = username, password = password) entity = KeycloakEntity(id = UUID.fromString(user.getId)) - permissionService <- ZIO.service[PermissionManagement.Service[KeycloakEntity]] + permissionService <- ZIO.service[PermissionManagementService[KeycloakEntity]] _ <- permissionService.grantWalletToUser(wallet.id, entity) token <- client.getAccessToken(username, password).map(_.access_token) @@ -95,7 +95,7 @@ object KeycloakPermissionManagementServiceSpec user <- createUser(username = username, password = password) entity = KeycloakEntity(id = UUID.fromString(user.getId)) - permissionService <- ZIO.service[PermissionManagement.Service[KeycloakEntity]] + permissionService <- ZIO.service[PermissionManagementService[KeycloakEntity]] _ <- permissionService.grantWalletToUser(wallet.id, entity) token <- client.getAccessToken(username, password).map(_.access_token) @@ -116,7 +116,7 @@ object KeycloakPermissionManagementServiceSpec private val failureCasesSuite = suite("Failure Cases Suite")( test("grant wallet access to the user with invalid wallet id") { for { - permissionService <- ZIO.service[PermissionManagement.Service[KeycloakEntity]] + permissionService <- ZIO.service[PermissionManagementService[KeycloakEntity]] entity = KeycloakEntity(id = UUID.randomUUID()) exit <- permissionService.grantWalletToUser(WalletId.random, entity).exit } yield assert(exit)(fails(isSubtype[WalletNotFoundById](anything))) @@ -128,9 +128,9 @@ object KeycloakPermissionManagementServiceSpec walletService <- ZIO.service[WalletManagementService] wallet <- walletService.createWallet(Wallet("test_1")) entity = KeycloakEntity(id = UUID.randomUUID()) - permissionService <- ZIO.service[PermissionManagement.Service[KeycloakEntity]] + permissionService <- ZIO.service[PermissionManagementService[KeycloakEntity]] exit <- permissionService.grantWalletToUser(wallet.id, entity).exit - } yield assert(exit)(fails(isSubtype[UnexpectedError](anything))) + } yield assert(exit)(dies(hasMessage(equalTo(s"Error creating policy for resource [${wallet.id}]")))) } ).provideSomeLayer(ZLayer.succeed(WalletAdministrationContext.Admin())) @@ -152,7 +152,7 @@ object KeycloakPermissionManagementServiceSpec user <- createUser(username = username, password = password) entity = KeycloakEntity(id = UUID.fromString(user.getId)) - permissionService <- ZIO.service[PermissionManagement.Service[KeycloakEntity]] + permissionService <- ZIO.service[PermissionManagementService[KeycloakEntity]] _ <- permissionService .grantWalletToUser(wallet.id, entity) .provideSomeLayer(ZLayer.succeed(WalletAdministrationContext.SelfService(Seq(walletId)))) @@ -179,7 +179,7 @@ object KeycloakPermissionManagementServiceSpec user <- createUser(username = username, password = password) entity = KeycloakEntity(id = UUID.fromString(user.getId)) - permissionService <- ZIO.service[PermissionManagement.Service[KeycloakEntity]] + permissionService <- ZIO.service[PermissionManagementService[KeycloakEntity]] _ <- permissionService .grantWalletToUser(wallet.id, entity) .provideSomeLayer(ZLayer.succeed(WalletAdministrationContext.Admin())) @@ -216,7 +216,7 @@ object KeycloakPermissionManagementServiceSpec user <- createUser(username = username, password = password) entity = KeycloakEntity(id = UUID.fromString(user.getId)) - permissionService <- ZIO.service[PermissionManagement.Service[KeycloakEntity]] + permissionService <- ZIO.service[PermissionManagementService[KeycloakEntity]] exit <- permissionService .grantWalletToUser(WalletId.random, entity) .provideSomeLayer(ZLayer.succeed(WalletAdministrationContext.SelfService(Seq(walletId)))) @@ -239,7 +239,7 @@ object KeycloakPermissionManagementServiceSpec user <- createUser(username = username, password = password) entity = KeycloakEntity(id = UUID.fromString(user.getId)) - permissionService <- ZIO.service[PermissionManagement.Service[KeycloakEntity]] + permissionService <- ZIO.service[PermissionManagementService[KeycloakEntity]] _ <- permissionService .grantWalletToUser(wallet.id, entity) .provideSomeLayer(ZLayer.succeed(WalletAdministrationContext.Admin())) diff --git a/cloud-agent/service/wallet-api/src/main/scala/org/hyperledger/identus/agent/walletapi/service/handler/DIDCreateHandler.scala b/cloud-agent/service/wallet-api/src/main/scala/org/hyperledger/identus/agent/walletapi/service/handler/DIDCreateHandler.scala index 4138581bc0..1c2f239b55 100644 --- a/cloud-agent/service/wallet-api/src/main/scala/org/hyperledger/identus/agent/walletapi/service/handler/DIDCreateHandler.scala +++ b/cloud-agent/service/wallet-api/src/main/scala/org/hyperledger/identus/agent/walletapi/service/handler/DIDCreateHandler.scala @@ -29,7 +29,7 @@ private[walletapi] class DIDCreateHandler( val operationFactory = OperationFactory(apollo) for { walletId <- ZIO.serviceWith[WalletAccessContext](_.walletId) - seed <- walletSecretStorage.getWalletSeed + seed <- walletSecretStorage.findWalletSeed .someOrElseZIO(ZIO.dieMessage(s"Wallet seed for wallet $walletId does not exist")) didIndex <- nonSecretStorage .getMaxDIDIndex() diff --git a/cloud-agent/service/wallet-api/src/main/scala/org/hyperledger/identus/agent/walletapi/service/handler/DIDUpdateHandler.scala b/cloud-agent/service/wallet-api/src/main/scala/org/hyperledger/identus/agent/walletapi/service/handler/DIDUpdateHandler.scala index 73d5d2eb57..de517075dd 100644 --- a/cloud-agent/service/wallet-api/src/main/scala/org/hyperledger/identus/agent/walletapi/service/handler/DIDUpdateHandler.scala +++ b/cloud-agent/service/wallet-api/src/main/scala/org/hyperledger/identus/agent/walletapi/service/handler/DIDUpdateHandler.scala @@ -40,7 +40,7 @@ private[walletapi] class DIDUpdateHandler( val did = state.createOperation.did for { walletId <- ZIO.serviceWith[WalletAccessContext](_.walletId) - seed <- walletSecretStorage.getWalletSeed + seed <- walletSecretStorage.findWalletSeed .someOrElseZIO(ZIO.dieMessage(s"Wallet seed for wallet $walletId does not exist")) keyCounter <- nonSecretStorage .getHdKeyCounter(did) diff --git a/cloud-agent/service/wallet-api/src/main/scala/org/hyperledger/identus/agent/walletapi/sql/JdbcWalletSecretStorage.scala b/cloud-agent/service/wallet-api/src/main/scala/org/hyperledger/identus/agent/walletapi/sql/JdbcWalletSecretStorage.scala index 3325880268..824b64b73d 100644 --- a/cloud-agent/service/wallet-api/src/main/scala/org/hyperledger/identus/agent/walletapi/sql/JdbcWalletSecretStorage.scala +++ b/cloud-agent/service/wallet-api/src/main/scala/org/hyperledger/identus/agent/walletapi/sql/JdbcWalletSecretStorage.scala @@ -36,7 +36,7 @@ class JdbcWalletSecretStorage(xa: Transactor[ContextAwareTask]) extends WalletSe } yield () } - override def getWalletSeed: URIO[WalletAccessContext, Option[WalletSeed]] = { + override def findWalletSeed: URIO[WalletAccessContext, Option[WalletSeed]] = { val cxnIO = sql""" | SELECT seed diff --git a/cloud-agent/service/wallet-api/src/main/scala/org/hyperledger/identus/agent/walletapi/storage/WalletSecretStorage.scala b/cloud-agent/service/wallet-api/src/main/scala/org/hyperledger/identus/agent/walletapi/storage/WalletSecretStorage.scala index f3da8a4684..b8c6f179d9 100644 --- a/cloud-agent/service/wallet-api/src/main/scala/org/hyperledger/identus/agent/walletapi/storage/WalletSecretStorage.scala +++ b/cloud-agent/service/wallet-api/src/main/scala/org/hyperledger/identus/agent/walletapi/storage/WalletSecretStorage.scala @@ -6,5 +6,5 @@ import zio.* trait WalletSecretStorage { def setWalletSeed(seed: WalletSeed): URIO[WalletAccessContext, Unit] - def getWalletSeed: URIO[WalletAccessContext, Option[WalletSeed]] + def findWalletSeed: URIO[WalletAccessContext, Option[WalletSeed]] } diff --git a/cloud-agent/service/wallet-api/src/main/scala/org/hyperledger/identus/agent/walletapi/util/KeyResolver.scala b/cloud-agent/service/wallet-api/src/main/scala/org/hyperledger/identus/agent/walletapi/util/KeyResolver.scala index 038752f898..81a5fa8035 100644 --- a/cloud-agent/service/wallet-api/src/main/scala/org/hyperledger/identus/agent/walletapi/util/KeyResolver.scala +++ b/cloud-agent/service/wallet-api/src/main/scala/org/hyperledger/identus/agent/walletapi/util/KeyResolver.scala @@ -29,7 +29,7 @@ class KeyResolver( } private def deriveHdKey(path: ManagedDIDHdKeyPath): RIO[WalletAccessContext, Option[Secp256k1KeyPair]] = - walletSecretStorage.getWalletSeed.flatMap { + walletSecretStorage.findWalletSeed.flatMap { case None => ZIO.none case Some(seed) => apollo.secp256k1.deriveKeyPair(seed.toByteArray)(path.derivationPath*).asSome } diff --git a/cloud-agent/service/wallet-api/src/main/scala/org/hyperledger/identus/agent/walletapi/vault/VaultWalletSecretStorage.scala b/cloud-agent/service/wallet-api/src/main/scala/org/hyperledger/identus/agent/walletapi/vault/VaultWalletSecretStorage.scala index 6c688e3145..f9ef9cd2b2 100644 --- a/cloud-agent/service/wallet-api/src/main/scala/org/hyperledger/identus/agent/walletapi/vault/VaultWalletSecretStorage.scala +++ b/cloud-agent/service/wallet-api/src/main/scala/org/hyperledger/identus/agent/walletapi/vault/VaultWalletSecretStorage.scala @@ -20,7 +20,7 @@ class VaultWalletSecretStorage(vaultKV: VaultKVClient) extends WalletSecretStora } yield () } - override def getWalletSeed: URIO[WalletAccessContext, Option[WalletSeed]] = { + override def findWalletSeed: URIO[WalletAccessContext, Option[WalletSeed]] = { for { walletId <- ZIO.serviceWith[WalletAccessContext](_.walletId) path = walletSeedPath(walletId) diff --git a/cloud-agent/service/wallet-api/src/test/scala/org/hyperledger/identus/agent/walletapi/memory/WalletSecretStorageInMemory.scala b/cloud-agent/service/wallet-api/src/test/scala/org/hyperledger/identus/agent/walletapi/memory/WalletSecretStorageInMemory.scala index 27ea027dee..91fed64a93 100644 --- a/cloud-agent/service/wallet-api/src/test/scala/org/hyperledger/identus/agent/walletapi/memory/WalletSecretStorageInMemory.scala +++ b/cloud-agent/service/wallet-api/src/test/scala/org/hyperledger/identus/agent/walletapi/memory/WalletSecretStorageInMemory.scala @@ -14,7 +14,7 @@ class WalletSecretStorageInMemory(storeRef: Ref[Map[WalletId, WalletSeed]]) exte } yield () } - override def getWalletSeed: URIO[WalletAccessContext, Option[WalletSeed]] = { + override def findWalletSeed: URIO[WalletAccessContext, Option[WalletSeed]] = { for { walletId <- ZIO.serviceWith[WalletAccessContext](_.walletId) seed <- storeRef.get.map(_.get(walletId)) diff --git a/cloud-agent/service/wallet-api/src/test/scala/org/hyperledger/identus/agent/walletapi/service/WalletManagementServiceSpec.scala b/cloud-agent/service/wallet-api/src/test/scala/org/hyperledger/identus/agent/walletapi/service/WalletManagementServiceSpec.scala index 6be6eef494..be4d5e174c 100644 --- a/cloud-agent/service/wallet-api/src/test/scala/org/hyperledger/identus/agent/walletapi/service/WalletManagementServiceSpec.scala +++ b/cloud-agent/service/wallet-api/src/test/scala/org/hyperledger/identus/agent/walletapi/service/WalletManagementServiceSpec.scala @@ -88,7 +88,7 @@ object WalletManagementServiceSpec secretStorage <- ZIO.service[WalletSecretStorage] createdWallet <- svc.createWallet(Wallet("wallet-1")) listedWallets <- svc.listWallets().map(_._1) - seed <- secretStorage.getWalletSeed.provide(ZLayer.succeed(WalletAccessContext(createdWallet.id))) + seed <- secretStorage.findWalletSeed.provide(ZLayer.succeed(WalletAccessContext(createdWallet.id))) } yield assert(listedWallets)(hasSameElements(Seq(createdWallet))) && assert(seed)(isSome) }, @@ -99,7 +99,7 @@ object WalletManagementServiceSpec createdWallets <- ZIO.foreach(1 to 10)(i => svc.createWallet(Wallet(s"wallet-$i"))) listedWallets <- svc.listWallets().map(_._1) seeds <- ZIO.foreach(listedWallets) { wallet => - secretStorage.getWalletSeed.provide(ZLayer.succeed(WalletAccessContext(wallet.id))) + secretStorage.findWalletSeed.provide(ZLayer.succeed(WalletAccessContext(wallet.id))) } } yield assert(createdWallets)(hasSameElements(listedWallets)) && assert(seeds)(forall(isSome)) @@ -111,7 +111,7 @@ object WalletManagementServiceSpec seed1 = WalletSeed.fromByteArray(Array.fill[Byte](64)(0)).toOption.get createdWallet <- svc.createWallet(Wallet("wallet-1"), Some(seed1)) listedWallets <- svc.listWallets().map(_._1) - seed2 <- secretStorage.getWalletSeed.provide(ZLayer.succeed(WalletAccessContext(createdWallet.id))) + seed2 <- secretStorage.findWalletSeed.provide(ZLayer.succeed(WalletAccessContext(createdWallet.id))) } yield assert(listedWallets)(hasSameElements(Seq(createdWallet))) && assert(seed2)(isSome(equalTo(seed1))) }, @@ -123,7 +123,7 @@ object WalletManagementServiceSpec createdWallets <- ZIO.foreach(seeds1) { seed => svc.createWallet(Wallet("test-wallet"), Some(seed)) } listedWallets <- svc.listWallets().map(_._1) seeds2 <- ZIO.foreach(listedWallets) { wallet => - secretStorage.getWalletSeed.provide(ZLayer.succeed(WalletAccessContext(wallet.id))) + secretStorage.findWalletSeed.provide(ZLayer.succeed(WalletAccessContext(wallet.id))) } } yield assert(createdWallets)(hasSameElements(listedWallets)) && assert(seeds2.flatten)(hasSameElements(seeds1)) diff --git a/cloud-agent/service/wallet-api/src/test/scala/org/hyperledger/identus/agent/walletapi/storage/WalletSecretStorageSpec.scala b/cloud-agent/service/wallet-api/src/test/scala/org/hyperledger/identus/agent/walletapi/storage/WalletSecretStorageSpec.scala index d9b87b2f34..c201d7036b 100644 --- a/cloud-agent/service/wallet-api/src/test/scala/org/hyperledger/identus/agent/walletapi/storage/WalletSecretStorageSpec.scala +++ b/cloud-agent/service/wallet-api/src/test/scala/org/hyperledger/identus/agent/walletapi/storage/WalletSecretStorageSpec.scala @@ -46,9 +46,9 @@ object WalletSecretStorageSpec extends ZIOSpecDefault, PostgresTestContainerSupp .map(_.id) walletAccessCtx = ZLayer.succeed(WalletAccessContext(walletId)) seed = WalletSeed.fromByteArray(Array.fill[Byte](64)(0)).toOption.get - seedBefore <- storage.getWalletSeed.provide(walletAccessCtx) + seedBefore <- storage.findWalletSeed.provide(walletAccessCtx) _ <- storage.setWalletSeed(seed).provide(walletAccessCtx) - seedAfter <- storage.getWalletSeed.provide(walletAccessCtx) + seedAfter <- storage.findWalletSeed.provide(walletAccessCtx) } yield assert(seedBefore)(isNone) && assert(seedAfter)(isSome(equalTo(seed))) }, @@ -69,7 +69,7 @@ object WalletSecretStorageSpec extends ZIOSpecDefault, PostgresTestContainerSupp seeds <- ZIO .foreach(wallets) { wallet => val walletAccessCtx = ZLayer.succeed(WalletAccessContext(wallet.id)) - storage.getWalletSeed.provideSomeLayer(walletAccessCtx) + storage.findWalletSeed.provideSomeLayer(walletAccessCtx) } .map(_.flatten) } yield assert(seeds.size)(equalTo(10)) && assert(seeds)(isDistinct) diff --git a/pollux/vc-jwt/src/main/scala/org/hyperledger/identus/pollux/vc/jwt/Proof.scala b/pollux/vc-jwt/src/main/scala/org/hyperledger/identus/pollux/vc/jwt/Proof.scala index 0ee6ffebbf..8bf6ab2086 100644 --- a/pollux/vc-jwt/src/main/scala/org/hyperledger/identus/pollux/vc/jwt/Proof.scala +++ b/pollux/vc-jwt/src/main/scala/org/hyperledger/identus/pollux/vc/jwt/Proof.scala @@ -2,7 +2,6 @@ package org.hyperledger.identus.pollux.vc.jwt import cats.implicits.* import com.nimbusds.jose.{JWSAlgorithm, JWSHeader, JWSObject, Payload} -import com.nimbusds.jose.crypto.bc.BouncyCastleProviderSingleton import com.nimbusds.jose.crypto.ECDSASigner import com.nimbusds.jwt.SignedJWT import io.circe.* @@ -104,7 +103,6 @@ object EcdsaSecp256k1Signature2019ProofGenerator { } object EddsaJcs2022ProofGenerator { - private val provider = BouncyCastleProviderSingleton.getInstance private val ed25519MultiBaseHeader: Array[Byte] = Array(-19, 1) // 0xed01 private def pkToMultiKey(pk: Ed25519PublicKey): MultiKey = { From fc1cf5157f5503143c23da54c8ea6fe78a776640 Mon Sep 17 00:00:00 2001 From: patlo-iog Date: Fri, 5 Jul 2024 19:05:46 +0700 Subject: [PATCH 03/13] fix: use Put and Get for DID in doobie statement (#1250) Signed-off-by: Pat Losoponkul --- .../identus/agent/walletapi/sql/JdbcDIDNonSecretStorage.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cloud-agent/service/wallet-api/src/main/scala/org/hyperledger/identus/agent/walletapi/sql/JdbcDIDNonSecretStorage.scala b/cloud-agent/service/wallet-api/src/main/scala/org/hyperledger/identus/agent/walletapi/sql/JdbcDIDNonSecretStorage.scala index c2e3f44611..4bc6154035 100644 --- a/cloud-agent/service/wallet-api/src/main/scala/org/hyperledger/identus/agent/walletapi/sql/JdbcDIDNonSecretStorage.scala +++ b/cloud-agent/service/wallet-api/src/main/scala/org/hyperledger/identus/agent/walletapi/sql/JdbcDIDNonSecretStorage.scala @@ -429,7 +429,7 @@ class JdbcDIDNonSecretStorage(xa: Transactor[ContextAwareTask], xb: Transactor[T | wallet_id | FROM public.prism_did_wallet_state | WHERE - | did = ${prismDid.toString} + | did = ${prismDid} """.stripMargin .query[WalletId] .option From ee0385361fc6da3f71d9c3f65cc0bac7425d9c58 Mon Sep 17 00:00:00 2001 From: patlo-iog Date: Mon, 8 Jul 2024 13:42:29 +0700 Subject: [PATCH 04/13] test: e2e test for oid4vci authorization code flow (#1252) Signed-off-by: Pat Losoponkul Signed-off-by: Hyperledger Bot Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Hyperledger Bot --- .../server/jobs/BackgroundJobError.scala | 2 +- .../server/jobs/ConnectBackgroundJobs.scala | 6 +- .../server/jobs/PresentBackgroundJobs.scala | 2 +- .../CredentialIssuerServerEndpoints.scala | 7 - .../OIDCCredentialIssuerServiceSpec.scala | 2 - .../model/error/DIDSecretStorageError.scala | 2 +- .../repository/ConnectionRepository.scala | 3 +- .../ConnectionRepositorySpecSuite.scala | 1 - examples/.nickel/versions.ncl | 4 +- examples/mt-keycloak-vault/compose.yaml | 2 +- examples/mt-keycloak/compose.yaml | 2 +- examples/mt/compose.yaml | 2 +- examples/st-multi/compose.yaml | 6 +- examples/st-oid4vci/compose.yaml | 4 +- examples/st-vault/compose.yaml | 2 +- examples/st/compose.yaml | 2 +- .../identus/mercury/model/error/package.scala | 2 +- .../identus/shared/models/Failure.scala | 2 +- tests/integration-tests/build.gradle.kts | 11 ++ tests/integration-tests/hosts_test | 2 + .../test/kotlin/abilities/ListenToEvents.kt | 7 + .../test/kotlin/config/services/Keycloak.kt | 66 ++++++- .../eu/europa/ec/eudi/openid4vci/Types.kt | 27 +++ .../src/test/kotlin/steps/Setup.kt | 23 ++- .../steps/oid4vci/IssueCredentialSteps.kt | 172 ++++++++++++++++++ .../src/test/resources/configs/basic.conf | 9 +- .../test/resources/configs/mt_keycloak.conf | 9 +- .../configs/mt_keycloak_agent_role.conf | 14 ++ .../resources/configs/mt_keycloak_vault.conf | 14 ++ .../resources/configs/mt_vault_approle.conf | 14 ++ .../resources/configs/mt_vault_token.conf | 14 ++ .../resources/configs/two_agents_basic.conf | 14 ++ .../configs/two_agents_sharing_keycloak.conf | 14 ++ .../resources/containers/keycloak-oid4vci.yml | 6 +- .../features/oid4vci/issue_jwt.feature | 22 +++ .../src/test/resources/logback-test.xml | 2 + 36 files changed, 445 insertions(+), 48 deletions(-) create mode 100644 tests/integration-tests/hosts_test create mode 100644 tests/integration-tests/src/test/kotlin/eu/europa/ec/eudi/openid4vci/Types.kt create mode 100644 tests/integration-tests/src/test/kotlin/steps/oid4vci/IssueCredentialSteps.kt create mode 100644 tests/integration-tests/src/test/resources/features/oid4vci/issue_jwt.feature diff --git a/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/agent/server/jobs/BackgroundJobError.scala b/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/agent/server/jobs/BackgroundJobError.scala index 3ac9e438b9..e1e5539c3a 100644 --- a/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/agent/server/jobs/BackgroundJobError.scala +++ b/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/agent/server/jobs/BackgroundJobError.scala @@ -1,7 +1,7 @@ package org.hyperledger.identus.agent.server.jobs import org.hyperledger.identus.mercury.HttpResponse -import org.hyperledger.identus.shared.models._ +import org.hyperledger.identus.shared.models.* sealed trait BackgroundJobError( override val statusCode: org.hyperledger.identus.shared.models.StatusCode, diff --git a/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/agent/server/jobs/ConnectBackgroundJobs.scala b/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/agent/server/jobs/ConnectBackgroundJobs.scala index 5ed92b4441..c16c4143c6 100644 --- a/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/agent/server/jobs/ConnectBackgroundJobs.scala +++ b/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/agent/server/jobs/ConnectBackgroundJobs.scala @@ -6,8 +6,10 @@ import org.hyperledger.identus.agent.walletapi.model.error.DIDSecretStorageError import org.hyperledger.identus.agent.walletapi.model.error.DIDSecretStorageError.{KeyNotFoundError, WalletNotFoundError} import org.hyperledger.identus.agent.walletapi.service.ManagedDIDService import org.hyperledger.identus.agent.walletapi.storage.DIDNonSecretStorage -import org.hyperledger.identus.connect.core.model.error.ConnectionServiceError.InvalidStateForOperation -import org.hyperledger.identus.connect.core.model.error.ConnectionServiceError.RecordIdNotFound +import org.hyperledger.identus.connect.core.model.error.ConnectionServiceError.{ + InvalidStateForOperation, + RecordIdNotFound +} import org.hyperledger.identus.connect.core.model.ConnectionRecord import org.hyperledger.identus.connect.core.model.ConnectionRecord.* import org.hyperledger.identus.connect.core.service.ConnectionService diff --git a/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/agent/server/jobs/PresentBackgroundJobs.scala b/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/agent/server/jobs/PresentBackgroundJobs.scala index b89cd14d2a..d82db1532a 100644 --- a/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/agent/server/jobs/PresentBackgroundJobs.scala +++ b/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/agent/server/jobs/PresentBackgroundJobs.scala @@ -37,7 +37,7 @@ import zio.json.* import zio.json.ast.Json import zio.metrics.* import zio.prelude.Validation -import zio.prelude.ZValidation.{Failure => ZFailure, *} +import zio.prelude.ZValidation.{Failure as ZFailure, *} import java.time.{Clock, Instant, ZoneId} diff --git a/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/oid4vci/CredentialIssuerServerEndpoints.scala b/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/oid4vci/CredentialIssuerServerEndpoints.scala index f3bd3be37d..9a356efb14 100644 --- a/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/oid4vci/CredentialIssuerServerEndpoints.scala +++ b/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/oid4vci/CredentialIssuerServerEndpoints.scala @@ -3,13 +3,6 @@ package org.hyperledger.identus.oid4vci import org.hyperledger.identus.agent.walletapi.model.BaseEntity import org.hyperledger.identus.api.http.ErrorResponse import org.hyperledger.identus.iam.authentication.* -import org.hyperledger.identus.iam.authentication.{ - Authenticator, - Authorizer, - DefaultAuthenticator, - Oid4vciAuthenticatorFactory, - SecurityLogic -} import org.hyperledger.identus.oid4vci.controller.CredentialIssuerController import org.hyperledger.identus.oid4vci.http.{CredentialErrorResponse, ExtendedErrorResponse, NonceResponse} import org.hyperledger.identus.LogUtils.* diff --git a/cloud-agent/service/server/src/test/scala/org/hyperledger/identus/oid4vci/domain/OIDCCredentialIssuerServiceSpec.scala b/cloud-agent/service/server/src/test/scala/org/hyperledger/identus/oid4vci/domain/OIDCCredentialIssuerServiceSpec.scala index ad4d0e5a19..44ef28be22 100644 --- a/cloud-agent/service/server/src/test/scala/org/hyperledger/identus/oid4vci/domain/OIDCCredentialIssuerServiceSpec.scala +++ b/cloud-agent/service/server/src/test/scala/org/hyperledger/identus/oid4vci/domain/OIDCCredentialIssuerServiceSpec.scala @@ -1,7 +1,6 @@ package org.hyperledger.identus.oid4vci.domain import com.nimbusds.jose.* -import org.bouncycastle.util.encoders.Hex import org.hyperledger.identus.agent.walletapi.memory.GenericSecretStorageInMemory import org.hyperledger.identus.agent.walletapi.service.{ManagedDIDService, MockManagedDIDService} import org.hyperledger.identus.agent.walletapi.storage.{DIDNonSecretStorage, MockDIDNonSecretStorage} @@ -94,7 +93,6 @@ object OIDCCredentialIssuerServiceSpec val longFormDid = PrismDID.buildLongFormFromOperation(holderOp) val keyIndex = holderDidData.publicKeys.find(_.purpose == VerificationRelationship.AssertionMethod).get.id val kid = longFormDid.toString + "#" + keyIndex - val encodedKey = Hex.toHexString(holderKp.privateKey.getEncoded) makeJwtProof(kid, nonce, aud, iat, holderKp.privateKey) } diff --git a/cloud-agent/service/wallet-api/src/main/scala/org/hyperledger/identus/agent/walletapi/model/error/DIDSecretStorageError.scala b/cloud-agent/service/wallet-api/src/main/scala/org/hyperledger/identus/agent/walletapi/model/error/DIDSecretStorageError.scala index 85a305541e..9112f841c0 100644 --- a/cloud-agent/service/wallet-api/src/main/scala/org/hyperledger/identus/agent/walletapi/model/error/DIDSecretStorageError.scala +++ b/cloud-agent/service/wallet-api/src/main/scala/org/hyperledger/identus/agent/walletapi/model/error/DIDSecretStorageError.scala @@ -1,7 +1,7 @@ package org.hyperledger.identus.agent.walletapi.model.error import org.hyperledger.identus.mercury.model.DidId -import org.hyperledger.identus.shared.models._ +import org.hyperledger.identus.shared.models.* sealed trait DIDSecretStorageError( override val statusCode: StatusCode, diff --git a/connect/core/src/main/scala/org/hyperledger/identus/connect/core/repository/ConnectionRepository.scala b/connect/core/src/main/scala/org/hyperledger/identus/connect/core/repository/ConnectionRepository.scala index 9ad70b3160..9d25fc133a 100644 --- a/connect/core/src/main/scala/org/hyperledger/identus/connect/core/repository/ConnectionRepository.scala +++ b/connect/core/src/main/scala/org/hyperledger/identus/connect/core/repository/ConnectionRepository.scala @@ -3,8 +3,7 @@ package org.hyperledger.identus.connect.core.repository import org.hyperledger.identus.connect.core.model.ConnectionRecord import org.hyperledger.identus.connect.core.model.ConnectionRecord.ProtocolState import org.hyperledger.identus.mercury.protocol.connection.* -import org.hyperledger.identus.shared.models.Failure -import org.hyperledger.identus.shared.models.WalletAccessContext +import org.hyperledger.identus.shared.models.{Failure, WalletAccessContext} import zio.{UIO, URIO} import java.util.UUID diff --git a/connect/core/src/test/scala/org/hyperledger/identus/connect/core/repository/ConnectionRepositorySpecSuite.scala b/connect/core/src/test/scala/org/hyperledger/identus/connect/core/repository/ConnectionRepositorySpecSuite.scala index ac7b1a6f6c..cb795b00b2 100644 --- a/connect/core/src/test/scala/org/hyperledger/identus/connect/core/repository/ConnectionRepositorySpecSuite.scala +++ b/connect/core/src/test/scala/org/hyperledger/identus/connect/core/repository/ConnectionRepositorySpecSuite.scala @@ -6,7 +6,6 @@ import org.hyperledger.identus.mercury.model.DidId import org.hyperledger.identus.mercury.protocol.connection.{ConnectionRequest, ConnectionResponse} import org.hyperledger.identus.mercury.protocol.invitation.v2.Invitation import org.hyperledger.identus.shared.models.* -import org.hyperledger.identus.shared.models.{WalletAccessContext, WalletId} import zio.{Cause, Exit, ZIO, ZLayer} import zio.test.* import zio.Exit.Failure diff --git a/examples/.nickel/versions.ncl b/examples/.nickel/versions.ncl index 5f6ee3f616..5fd5259c61 100644 --- a/examples/.nickel/versions.ncl +++ b/examples/.nickel/versions.ncl @@ -1,8 +1,8 @@ { # identus - agent = "1.36.1-SNAPSHOT", + agent = "1.37.0", node = "2.4.0", - identusKeycloak = "0.1.0", + identusKeycloak = "0.2.0", # 3rd party caddy = "2.7.6-alpine", mockServer = "5.15.0", diff --git a/examples/mt-keycloak-vault/compose.yaml b/examples/mt-keycloak-vault/compose.yaml index daf6939b36..e322eae3ea 100644 --- a/examples/mt-keycloak-vault/compose.yaml +++ b/examples/mt-keycloak-vault/compose.yaml @@ -53,7 +53,7 @@ services: SECRET_STORAGE_BACKEND: vault VAULT_ADDR: http://vault-default:8200 VAULT_TOKEN: admin - image: ghcr.io/hyperledger/identus-cloud-agent:1.36.1-SNAPSHOT + image: ghcr.io/hyperledger/identus-cloud-agent:1.37.0 restart: always caddy-default: configs: diff --git a/examples/mt-keycloak/compose.yaml b/examples/mt-keycloak/compose.yaml index 3ebc37d1ad..9ec537bb58 100644 --- a/examples/mt-keycloak/compose.yaml +++ b/examples/mt-keycloak/compose.yaml @@ -51,7 +51,7 @@ services: PRISM_NODE_PORT: '50053' REST_SERVICE_URL: http://caddy-default:8080/prism-agent SECRET_STORAGE_BACKEND: postgres - image: ghcr.io/hyperledger/identus-cloud-agent:1.36.1-SNAPSHOT + image: ghcr.io/hyperledger/identus-cloud-agent:1.37.0 restart: always caddy-default: configs: diff --git a/examples/mt/compose.yaml b/examples/mt/compose.yaml index 84e4f7a3d1..053839c12a 100644 --- a/examples/mt/compose.yaml +++ b/examples/mt/compose.yaml @@ -44,7 +44,7 @@ services: PRISM_NODE_PORT: '50053' REST_SERVICE_URL: http://caddy-default:8080/prism-agent SECRET_STORAGE_BACKEND: postgres - image: ghcr.io/hyperledger/identus-cloud-agent:1.36.1-SNAPSHOT + image: ghcr.io/hyperledger/identus-cloud-agent:1.37.0 restart: always caddy-default: configs: diff --git a/examples/st-multi/compose.yaml b/examples/st-multi/compose.yaml index 34650ad413..2607de85f9 100644 --- a/examples/st-multi/compose.yaml +++ b/examples/st-multi/compose.yaml @@ -76,7 +76,7 @@ services: PRISM_NODE_PORT: '50053' REST_SERVICE_URL: http://caddy-holder:8081/prism-agent SECRET_STORAGE_BACKEND: postgres - image: ghcr.io/hyperledger/identus-cloud-agent:1.36.1-SNAPSHOT + image: ghcr.io/hyperledger/identus-cloud-agent:1.37.0 restart: always agent-issuer: depends_on: @@ -106,7 +106,7 @@ services: PRISM_NODE_PORT: '50053' REST_SERVICE_URL: http://caddy-issuer:8080/prism-agent SECRET_STORAGE_BACKEND: postgres - image: ghcr.io/hyperledger/identus-cloud-agent:1.36.1-SNAPSHOT + image: ghcr.io/hyperledger/identus-cloud-agent:1.37.0 restart: always agent-verifier: depends_on: @@ -136,7 +136,7 @@ services: PRISM_NODE_PORT: '50053' REST_SERVICE_URL: http://caddy-verifier:8082/prism-agent SECRET_STORAGE_BACKEND: postgres - image: ghcr.io/hyperledger/identus-cloud-agent:1.36.1-SNAPSHOT + image: ghcr.io/hyperledger/identus-cloud-agent:1.37.0 restart: always caddy-holder: configs: diff --git a/examples/st-oid4vci/compose.yaml b/examples/st-oid4vci/compose.yaml index dde0a828ff..7d411ff244 100644 --- a/examples/st-oid4vci/compose.yaml +++ b/examples/st-oid4vci/compose.yaml @@ -44,7 +44,7 @@ services: PRISM_NODE_PORT: '50053' REST_SERVICE_URL: http://caddy-issuer:8080/prism-agent SECRET_STORAGE_BACKEND: postgres - image: ghcr.io/hyperledger/identus-cloud-agent:1.36.1-SNAPSHOT + image: ghcr.io/hyperledger/identus-cloud-agent:1.37.0 restart: always caddy-issuer: configs: @@ -105,7 +105,7 @@ services: IDENTUS_URL: http://caddy-issuer:8080/prism-agent KEYCLOAK_ADMIN: admin KEYCLOAK_ADMIN_PASSWORD: admin - image: ghcr.io/hyperledger/identus-keycloak-plugins:0.1.0 + image: ghcr.io/hyperledger/identus-keycloak-plugins:0.2.0 ports: - 9980:8080 restart: always diff --git a/examples/st-vault/compose.yaml b/examples/st-vault/compose.yaml index 911a4a1f42..89cbcaaf0f 100644 --- a/examples/st-vault/compose.yaml +++ b/examples/st-vault/compose.yaml @@ -46,7 +46,7 @@ services: SECRET_STORAGE_BACKEND: vault VAULT_ADDR: http://vault-issuer:8200 VAULT_TOKEN: admin - image: ghcr.io/hyperledger/identus-cloud-agent:1.36.1-SNAPSHOT + image: ghcr.io/hyperledger/identus-cloud-agent:1.37.0 restart: always caddy-issuer: configs: diff --git a/examples/st/compose.yaml b/examples/st/compose.yaml index 167a2a199e..fe40fcd7bd 100644 --- a/examples/st/compose.yaml +++ b/examples/st/compose.yaml @@ -44,7 +44,7 @@ services: PRISM_NODE_PORT: '50053' REST_SERVICE_URL: http://caddy-issuer:8080/prism-agent SECRET_STORAGE_BACKEND: postgres - image: ghcr.io/hyperledger/identus-cloud-agent:1.36.1-SNAPSHOT + image: ghcr.io/hyperledger/identus-cloud-agent:1.37.0 restart: always caddy-issuer: configs: diff --git a/mercury/models/src/main/scala/org/hyperledger/identus/mercury/model/error/package.scala b/mercury/models/src/main/scala/org/hyperledger/identus/mercury/model/error/package.scala index 4ea83a1357..9886e80901 100644 --- a/mercury/models/src/main/scala/org/hyperledger/identus/mercury/model/error/package.scala +++ b/mercury/models/src/main/scala/org/hyperledger/identus/mercury/model/error/package.scala @@ -1,6 +1,6 @@ package org.hyperledger.identus.mercury.model -import org.hyperledger.identus.shared.models._ +import org.hyperledger.identus.shared.models.* package object error { diff --git a/shared/core/src/main/scala/org/hyperledger/identus/shared/models/Failure.scala b/shared/core/src/main/scala/org/hyperledger/identus/shared/models/Failure.scala index 7a7c800ac9..c2ea5dc53f 100644 --- a/shared/core/src/main/scala/org/hyperledger/identus/shared/models/Failure.scala +++ b/shared/core/src/main/scala/org/hyperledger/identus/shared/models/Failure.scala @@ -1,7 +1,7 @@ package org.hyperledger.identus.shared.models import zio.{URIO, ZIO} -import zio.json._ +import zio.json.* trait Failure { def namespace: String diff --git a/tests/integration-tests/build.gradle.kts b/tests/integration-tests/build.gradle.kts index 00ba4ab3cd..1f56e07876 100644 --- a/tests/integration-tests/build.gradle.kts +++ b/tests/integration-tests/build.gradle.kts @@ -45,6 +45,10 @@ dependencies { testImplementation("com.nimbusds:nimbus-jose-jwt:9.40") testImplementation("org.bouncycastle:bcprov-jdk18on:1.78.1") testImplementation("com.google.crypto.tink:tink:1.13.0") + testImplementation("io.iohk.atala.prism.apollo:apollo-jvm:1.3.4") + // OID4VCI + testImplementation("org.htmlunit:htmlunit:4.3.0") + testImplementation("eu.europa.ec.eudi:eudi-lib-jvm-openid4vci-kt:0.3.2") } serenity { @@ -61,6 +65,12 @@ tasks.test { finalizedBy("aggregate", "reports") testLogging.showStandardStreams = true systemProperty("cucumber.filter.tags", System.getProperty("cucumber.filter.tags")) + // Since the test runs on host and system-unter-test runs in containers, + // We need to make the test on host resolves host.docker.internal same as the containerized services, + // because some spec (e.g. OID4VCI) requires domain to be the same. + // + // The OID4VCI library does not allow mixing host.docker.internal and localhost + systemProperty("jdk.net.hosts.file", "hosts_test") } kotlin { @@ -91,6 +101,7 @@ afterEvaluate { systemProperty("PRISM_NODE_VERSION", System.getenv("PRISM_NODE_VERSION") ?: "") systemProperty("AGENT_VERSION", System.getenv("AGENT_VERSION") ?: "") systemProperty("cucumber.filter.tags", System.getProperty("cucumber.filter.tags")) + systemProperty("jdk.net.hosts.file", "hosts_test") finalizedBy("aggregate", "reports") outputs.upToDateWhen { false } } diff --git a/tests/integration-tests/hosts_test b/tests/integration-tests/hosts_test new file mode 100644 index 0000000000..26590d4c39 --- /dev/null +++ b/tests/integration-tests/hosts_test @@ -0,0 +1,2 @@ +127.0.0.1 localhost +127.0.0.1 host.docker.internal \ No newline at end of file diff --git a/tests/integration-tests/src/test/kotlin/abilities/ListenToEvents.kt b/tests/integration-tests/src/test/kotlin/abilities/ListenToEvents.kt index 43c92230ed..8c721cce04 100644 --- a/tests/integration-tests/src/test/kotlin/abilities/ListenToEvents.kt +++ b/tests/integration-tests/src/test/kotlin/abilities/ListenToEvents.kt @@ -29,6 +29,7 @@ open class ListenToEvents( var credentialEvents: MutableList = mutableListOf() var presentationEvents: MutableList = mutableListOf() var didEvents: MutableList = mutableListOf() + var authCodeCallbackEvents: MutableList> = mutableListOf() private fun route(application: Application) { application.routing { @@ -46,6 +47,12 @@ open class ListenToEvents( } call.respond(HttpStatusCode.OK) } + get("/auth-cb") { + val authCode = call.parameters["code"]!! + val state = call.parameters["state"]!! + authCodeCallbackEvents.add(Pair(authCode, state)) + call.respond(HttpStatusCode.OK, "Login Successfully") + } } } diff --git a/tests/integration-tests/src/test/kotlin/config/services/Keycloak.kt b/tests/integration-tests/src/test/kotlin/config/services/Keycloak.kt index fbe8c94602..d73a8814b5 100644 --- a/tests/integration-tests/src/test/kotlin/config/services/Keycloak.kt +++ b/tests/integration-tests/src/test/kotlin/config/services/Keycloak.kt @@ -15,17 +15,20 @@ import java.io.File data class Keycloak( @ConfigAlias("http_port") val httpPort: Int, val realm: String = "atala-demo", - @ConfigAlias("client_id") val clientId: String = "prism-agent", - @ConfigAlias("client_secret") val clientSecret: String = "prism-agent-demo-secret", + @ConfigAlias("client_id") val clientId: String = "cloud-agent", + @ConfigAlias("client_secret") val clientSecret: String = "cloud-agent-secret", @ConfigAlias("keep_running") override val keepRunning: Boolean = false, @ConfigAlias("compose_file") val keycloakComposeFile: String = "src/test/resources/containers/keycloak.yml", + @ConfigAlias("logger_name") val loggerName: String = "keycloak", @ConfigAlias("extra_envs") val extraEnvs: Map = emptyMap(), + @ConfigAlias("extra_clients") val extraClients: Map = emptyMap(), + @ConfigAlias("extra_scopes") val extraScopes: List = emptyList(), ) : ServiceBase() { private val logger = Logger.get() private val keycloakEnvConfig: Map = extraEnvs + mapOf( "KEYCLOAK_HTTP_PORT" to httpPort.toString(), ) - override val logServices: List = listOf("keycloak") + override val logServices: List = listOf(loggerName) private val keycloakClientRoles: List = AgentRole.entries.map { it.roleName } override val container: ComposeContainer = ComposeContainer(File(keycloakComposeFile)).withEnv(keycloakEnvConfig) @@ -43,8 +46,10 @@ data class Keycloak( logger.info("Setting up Keycloak") initRequestBuilder() createRealm() - createClient() + createAgentClient() + createPublicClients() createClientRoles() + createScopes() createUsers(users) } @@ -93,7 +98,7 @@ data class Keycloak( .then().statusCode(HttpStatus.SC_CREATED) } - private fun createClient() { + private fun createAgentClient() { RestAssured.given().spec(requestBuilder) .body( mapOf( @@ -108,6 +113,55 @@ data class Keycloak( .then().statusCode(HttpStatus.SC_CREATED) } + private fun createPublicClients() { + extraClients.forEach { client -> + RestAssured.given().spec(requestBuilder) + .body( + mapOf( + "id" to client.key, + "publicClient" to true, + "consentRequired" to true, + "redirectUris" to client.value.redirectUris, + ), + ) + .post("/admin/realms/$realm/clients") + .then().statusCode(HttpStatus.SC_CREATED) + } + } + + private fun createScopes() { + extraScopes.forEach { scope -> + val response = RestAssured.given().spec(requestBuilder) + .body( + mapOf( + "name" to scope, + "description" to scope, + "protocol" to "openid-connect", + "attributes" to mapOf( + "consent.screen.text" to scope, + "display.on.consent.screen" to true, + "include.in.token.scope" to true, + "gui.order" to "", + ), + ), + ) + .post("/admin/realms/$realm/client-scopes") + .thenReturn() + response.then().statusCode(HttpStatus.SC_CREATED) + val clientScopeId = response.getHeader("Location").split("/").last() + mapClientsScopeToClient(clientScopeId) + } + } + + private fun mapClientsScopeToClient(clientScopeId: String) { + extraClients.keys.forEach { client -> + RestAssured.given().spec(requestBuilder) + .put("/admin/realms/$realm/clients/$client/optional-client-scopes/$clientScopeId") + .then() + .statusCode(HttpStatus.SC_NO_CONTENT) + } + } + private fun createClientRoles() { keycloakClientRoles.forEach { roleName -> RestAssured.given().spec(requestBuilder) @@ -171,3 +225,5 @@ data class Keycloak( .then().statusCode(HttpStatus.SC_NO_CONTENT) } } + +data class KeycloakPublicClientConfig(val redirectUris: List = listOf()) diff --git a/tests/integration-tests/src/test/kotlin/eu/europa/ec/eudi/openid4vci/Types.kt b/tests/integration-tests/src/test/kotlin/eu/europa/ec/eudi/openid4vci/Types.kt new file mode 100644 index 0000000000..bae29d46a2 --- /dev/null +++ b/tests/integration-tests/src/test/kotlin/eu/europa/ec/eudi/openid4vci/Types.kt @@ -0,0 +1,27 @@ +package eu.europa.ec.eudi.openid4vci + +import java.net.URI +import java.net.URL + +/** + * https://github.com/eu-digital-identity-wallet/eudi-lib-jvm-openid4vci-kt/blob/e81802f3b90639b97e32a6fd1c06c20e5ff53f27/src/main/kotlin/eu/europa/ec/eudi/openid4vci/Types.kt#L45 + * + * This overrides the implementation in EUDI to relax the HTTPS requirement making it easier for testing purpose + */ +@JvmInline +value class HttpsUrl private constructor(val value: URL) { + + override fun toString(): String = value.toString() + + companion object { + + /** + * Parses the provided [value] as a [URI] and tries creates a new [HttpsUrl]. + */ + operator fun invoke(value: String): Result = runCatching { + val uri = URI.create(value) + // require(uri.scheme.contentEquals("https", true)) { "URL must use https protocol" } + HttpsUrl(uri.toURL()) + } + } +} diff --git a/tests/integration-tests/src/test/kotlin/steps/Setup.kt b/tests/integration-tests/src/test/kotlin/steps/Setup.kt index 37701683fd..07038f919d 100644 --- a/tests/integration-tests/src/test/kotlin/steps/Setup.kt +++ b/tests/integration-tests/src/test/kotlin/steps/Setup.kt @@ -1,12 +1,14 @@ package steps import abilities.ListenToEvents +import com.nimbusds.jose.crypto.bc.BouncyCastleProviderSingleton import com.sksamuel.hoplite.ConfigException import com.sksamuel.hoplite.ConfigLoader import common.TestConstants import config.* import io.cucumber.java.AfterAll import io.cucumber.java.BeforeAll +import io.ktor.server.util.url import io.restassured.RestAssured import io.restassured.builder.RequestSpecBuilder import net.serenitybdd.screenplay.Actor @@ -16,6 +18,7 @@ import net.serenitybdd.screenplay.rest.abilities.CallAnApi import org.apache.http.HttpStatus import org.hyperledger.identus.client.models.CreateWalletRequest import org.hyperledger.identus.client.models.CreateWebhookNotification +import java.security.Security import java.util.UUID object Setup { @@ -123,6 +126,7 @@ object Setup { } if (role.webhook != null) { actor.whoCan(ListenToEvents.at(role.webhook.url, role.webhook.localPort)) + actor.remember("webhookUrl", role.webhook.url) if (role.webhook.initRequired) { registerWebhook(actor, role.webhook.url.toExternalForm()) } @@ -130,12 +134,18 @@ object Setup { actor.remember("baseUrl", role.url.toExternalForm()) } if (config.services?.keycloakOid4vci != null) { - val role = config.roles.find { it.name == "Issuer" } ?: throw ConfigException("Issuer role does not exist") - val url = role.oid4vciAuthServer ?: throw ConfigException("Issuer's oid4vci_auth_server must be provided") - val actor = cast.actorNamed(role.name) - actor.remember("OID4VCI_AUTH_SERVER_URL", url.toExternalForm()) - actor.remember("OID4VCI_AUTH_SERVER_CLIENT_ID", config.services.keycloakOid4vci.clientId) - actor.remember("OID4VCI_AUTH_SERVER_CLIENT_SECRET", config.services.keycloakOid4vci.clientSecret) + val issuerRole = config.roles.find { it.name == "Issuer" } ?: throw ConfigException("Issuer role does not exist") + val issuerActor = cast.actorNamed(issuerRole.name) + with(issuerActor) { + val url = issuerRole.oid4vciAuthServer ?: throw ConfigException("Issuer's oid4vci_auth_server must be provided") + remember("OID4VCI_AUTH_SERVER_URL", url.toExternalForm()) + remember("OID4VCI_AUTH_SERVER_CLIENT_ID", config.services.keycloakOid4vci.clientId) + remember("OID4VCI_AUTH_SERVER_CLIENT_SECRET", config.services.keycloakOid4vci.clientSecret) + } + + val holderRole = config.roles.find { it.name == "Holder" } ?: throw ConfigException("Holder role does not exist") + val holderActor = cast.actorNamed(holderRole.name) + holderActor.remember("OID4VCI_AUTH_SERVER_CLIENT_ID", "holder") } OnStage.setTheStage(cast) } @@ -189,6 +199,7 @@ object Setup { @BeforeAll fun init() { + Security.insertProviderAt(BouncyCastleProviderSingleton.getInstance(), 2) Setup.initServices() Setup.initActors() } diff --git a/tests/integration-tests/src/test/kotlin/steps/oid4vci/IssueCredentialSteps.kt b/tests/integration-tests/src/test/kotlin/steps/oid4vci/IssueCredentialSteps.kt new file mode 100644 index 0000000000..218f233004 --- /dev/null +++ b/tests/integration-tests/src/test/kotlin/steps/oid4vci/IssueCredentialSteps.kt @@ -0,0 +1,172 @@ +package steps.oid4vci + +import abilities.ListenToEvents +import com.nimbusds.jose.JWSAlgorithm +import com.nimbusds.jose.jwk.JWK +import eu.europa.ec.eudi.openid4vci.* +import interactions.Post +import io.cucumber.java.en.Then +import io.cucumber.java.en.When +import io.iohk.atala.automation.extensions.get +import io.iohk.atala.automation.serenity.ensure.Ensure +import kotlinx.coroutines.runBlocking +import net.serenitybdd.rest.SerenityRest +import net.serenitybdd.screenplay.Actor +import org.apache.http.HttpStatus +import org.htmlunit.* +import org.htmlunit.html.HtmlPage +import org.htmlunit.html.HtmlPasswordInput +import org.htmlunit.html.HtmlSubmitInput +import org.htmlunit.html.HtmlTextInput +import org.hyperledger.identus.apollo.base64.base64UrlEncoded +import org.hyperledger.identus.apollo.utils.KMMECSecp256k1PrivateKey +import org.hyperledger.identus.apollo.utils.decodeHex +import org.hyperledger.identus.client.models.* +import org.hyperledger.identus.client.models.CredentialOfferRequest +import java.net.URI +import java.net.URL + +class IssueCredentialSteps { + @When("{actor} creates an offer using {string} configuration with {string} form DID") + fun issuerCreateCredentialOffer(issuer: Actor, configurationId: String, didForm: String) { + val credentialIssuer = issuer.recall("oid4vciCredentialIssuer") + val claims = linkedMapOf( + "name" to "Alice", + "age" to 42, + ) + val did: String = if (didForm == "short") { + issuer.recall("shortFormDid") + } else { + issuer.recall("longFormDid") + } + issuer.attemptsTo( + Post.to("/oid4vci/issuers/${credentialIssuer.id}/credential-offers") + .with { + it.body( + CredentialOfferRequest( + credentialConfigurationId = configurationId, + issuingDID = did, + claims = claims, + ), + ) + }, + Ensure.thatTheLastResponse().statusCode().isEqualTo(HttpStatus.SC_CREATED), + ) + val offerUri = SerenityRest.lastResponse().get().credentialOffer + issuer.remember("oid4vciOffer", offerUri) + } + + @When("{actor} receives oid4vci offer from {actor}") + fun holderReceivesOfferFromIssuer(holder: Actor, issuer: Actor) { + val offerUri = issuer.recall("oid4vciOffer") + holder.remember("oid4vciOffer", offerUri) + } + + @When("{actor} resolves oid4vci issuer metadata and login via front-end channel") + fun holderResolvesIssuerMetadata(holder: Actor) { + val offerUri = holder.recall("oid4vciOffer") + val credentialOffer = runBlocking { + CredentialOfferRequestResolver().resolve(offerUri).getOrThrow() + } + val redirectUrl = holder.recall("webhookUrl") + val openId4VCIConfig = OpenId4VCIConfig( + clientId = holder.recall("OID4VCI_AUTH_SERVER_CLIENT_ID"), + authFlowRedirectionURI = URI.create("$redirectUrl/auth-cb"), + keyGenerationConfig = KeyGenerationConfig.ecOnly(com.nimbusds.jose.jwk.Curve.SECP256K1), + credentialResponseEncryptionPolicy = CredentialResponseEncryptionPolicy.SUPPORTED, + parUsage = ParUsage.Never, + ) + val issuer = Issuer.make(openId4VCIConfig, credentialOffer).getOrThrow() + val authorizationRequest = runBlocking { + issuer.prepareAuthorizationRequest().getOrThrow() + } + val authResponse = keycloakLoginViaBrowser(authorizationRequest.authorizationCodeURL.value, holder) + val authorizedRequest = + with(issuer) { + runBlocking { + val authCode = AuthorizationCode(authResponse.first) + authorizationRequest.authorizeWithAuthorizationCode(authCode, authResponse.second).getOrThrow() + } + } + holder.remember("eudiCredentialOffer", credentialOffer) + holder.remember("eudiAuthorizedRequest", authorizedRequest) + holder.remember("eudiIssuer", issuer) + } + + @When("{actor} presents the access token with JWT proof on CredentialEndpoint") + fun holderPresentsTokenOnCredentialEdpoint(holder: Actor) { + val credentialOffer = holder.recall("eudiCredentialOffer") + val issuer = holder.recall("eudiIssuer") + val authorizedRequest = holder.recall("eudiAuthorizedRequest") + val requestPayload = IssuanceRequestPayload.ConfigurationBased(credentialOffer.credentialConfigurationIdentifiers.first(), null) + val submissionOutcome = with(issuer) { + when (authorizedRequest) { + is AuthorizedRequest.NoProofRequired -> throw Exception("Not supported yet") + is AuthorizedRequest.ProofRequired -> runBlocking { + authorizedRequest.requestSingle( + requestPayload, + popSigner(), + ) + } + }.getOrThrow() + } + holder.remember("eudiSubmissionOutcome", submissionOutcome) + } + + @Then("{actor} sees credential issued successfully from CredentialEndpoint") + fun holderSeesCredentialIssuedSuccessfully(holder: Actor) { + val credentials = when (val submissionOutcome = holder.recall("eudiSubmissionOutcome")) { + is SubmissionOutcome.Success -> submissionOutcome.credentials + else -> throw Exception("Issuance failed. $submissionOutcome") + } + holder.attemptsTo( + Ensure.that(credentials).hasSize(1), + ) + } + + private fun popSigner(): PopSigner { + val privateKeyHex = "d93c6485e30aad4d6522313553e58d235693f7007b822676e5e1e9a667655b69" + val did = "did:prism:4a2bc09be65136f604d1564e2fced1a1cdbce9deb9b64ee396afc95fc0b01c59:CnsKeRI6CgZhdXRoLTEQBEouCglzZWNwMjU2azESIQOx16yykO2nDcmM-NeQeVipxmuaF38KasIA8gycJCHWJhI7CgdtYXN0ZXIwEAFKLgoJc2VjcDI1NmsxEiECKrfbf1_p7YT5aRJspBLct5zDyL6aicEam1Gycq5xKy0" + val kid = "$did#auth-1" + val privateKey = KMMECSecp256k1PrivateKey.secp256k1FromByteArray(privateKeyHex.decodeHex()) + val point = privateKey.getPublicKey().getCurvePoint() + val jwk = JWK.parse( + mapOf( + "kty" to "EC", + "crv" to "secp256k1", + "x" to point.x.base64UrlEncoded, + "y" to point.y.base64UrlEncoded, + "d" to privateKey.raw.base64UrlEncoded, + ), + ) + return PopSigner.jwtPopSigner( + privateKey = jwk, + algorithm = JWSAlgorithm.ES256K, + publicKey = JwtBindingKey.Did(identity = kid), + ) + } + + /** + * @return A tuple of authorization code and authorization server state + */ + private fun keycloakLoginViaBrowser(loginUrl: URL, actor: Actor): Pair { + val client = WebClient() + + // step 1 - login with username and password + val loginPage = client.getPage(loginUrl) + val loginForm = loginPage.forms.first() + loginForm.getInputByName("username").type(actor.name) + loginForm.getInputByName("password").type(actor.name) + val postLoginPage = loginForm.getInputByName("login").click() + + // If it is the first time user is logged in, Keycloak ask for consent by returning HtmlPage. + if (postLoginPage is HtmlPage) { + // step 2 - give client consent to access the scopes + val consentForm = postLoginPage.forms.first() + consentForm.getInputByName("accept").click() + } + + client.close() + return ListenToEvents.with(actor).authCodeCallbackEvents.last() + } +} diff --git a/tests/integration-tests/src/test/resources/configs/basic.conf b/tests/integration-tests/src/test/resources/configs/basic.conf index 954af03916..af91c7bf2a 100644 --- a/tests/integration-tests/src/test/resources/configs/basic.conf +++ b/tests/integration-tests/src/test/resources/configs/basic.conf @@ -6,10 +6,15 @@ services = { } keycloak_oid4vci = { http_port = 9981 + logger_name = "keycloak_oid4vci" compose_file = "src/test/resources/containers/keycloak-oid4vci.yml" realm = "oid4vci-holder" + extra_scopes = ["StudentProfile"] extra_envs = { - IDENTUS_URL = "${ISSUER_AGENT_URL:-http://localhost:8080}" + IDENTUS_URL = "${ISSUER_AGENT_URL:-http://host.docker.internal:8080}" + } + extra_clients = { + holder = { redirectUris = ["${HOLDER_WEBHOOK_URL:-http://host.docker.internal:9956}/*"] } } } } @@ -40,7 +45,7 @@ roles = [ url = "${ISSUER_WEBHOOK_URL:-http://host.docker.internal:9955}" init_required = true } - oid4vci_auth_server = "http://localhost:9981" + oid4vci_auth_server = "http://host.docker.internal:9981/realms/oid4vci-holder" }, { name = "Holder" diff --git a/tests/integration-tests/src/test/resources/configs/mt_keycloak.conf b/tests/integration-tests/src/test/resources/configs/mt_keycloak.conf index b240ff1705..2e8d6da9a2 100644 --- a/tests/integration-tests/src/test/resources/configs/mt_keycloak.conf +++ b/tests/integration-tests/src/test/resources/configs/mt_keycloak.conf @@ -9,10 +9,15 @@ services = { } keycloak_oid4vci = { http_port = 9981 + logger_name = "keycloak_oid4vci" compose_file = "src/test/resources/containers/keycloak-oid4vci.yml" realm = "oid4vci-holder" + extra_scopes = ["StudentProfile"] extra_envs = { - IDENTUS_URL = "${ISSUER_AGENT_URL:-http://localhost:8080}" + IDENTUS_URL = "${ISSUER_AGENT_URL:-http://host.docker.internal:8080}" + } + extra_clients = { + holder = { redirectUris = ["${HOLDER_WEBHOOK_URL:-http://host.docker.internal:9956}/*"] } } } } @@ -43,7 +48,7 @@ roles = [ url = "${ISSUER_WEBHOOK_URL:-http://host.docker.internal:9955}" init_required = true } - oid4vci_auth_server = "http://localhost:9981" + oid4vci_auth_server = "http://host.docker.internal:9981/realms/oid4vci-holder" }, { name = "Holder" diff --git a/tests/integration-tests/src/test/resources/configs/mt_keycloak_agent_role.conf b/tests/integration-tests/src/test/resources/configs/mt_keycloak_agent_role.conf index d3ba070f5a..41d8550074 100644 --- a/tests/integration-tests/src/test/resources/configs/mt_keycloak_agent_role.conf +++ b/tests/integration-tests/src/test/resources/configs/mt_keycloak_agent_role.conf @@ -7,6 +7,19 @@ services = { keycloak = { http_port = 9980 } + keycloak_oid4vci = { + http_port = 9981 + logger_name = "keycloak_oid4vci" + compose_file = "src/test/resources/containers/keycloak-oid4vci.yml" + realm = "oid4vci-holder" + extra_scopes = ["StudentProfile"] + extra_envs = { + IDENTUS_URL = "${ISSUER_AGENT_URL:-http://host.docker.internal:8080}" + } + extra_clients = { + holder = { redirectUris = ["${HOLDER_WEBHOOK_URL:-http://host.docker.internal:9956}/*"] } + } + } } # Specify agents that are required to be created before running tests @@ -35,6 +48,7 @@ roles = [ url = "${ISSUER_WEBHOOK_URL:-http://host.docker.internal:9955}" init_required = true } + oid4vci_auth_server = "http://host.docker.internal:9981/realms/oid4vci-holder" }, { name = "Holder" diff --git a/tests/integration-tests/src/test/resources/configs/mt_keycloak_vault.conf b/tests/integration-tests/src/test/resources/configs/mt_keycloak_vault.conf index 66957bf226..37271e2a5f 100644 --- a/tests/integration-tests/src/test/resources/configs/mt_keycloak_vault.conf +++ b/tests/integration-tests/src/test/resources/configs/mt_keycloak_vault.conf @@ -7,6 +7,19 @@ services = { keycloak = { http_port = 9980 } + keycloak_oid4vci = { + http_port = 9981 + logger_name = "keycloak_oid4vci" + compose_file = "src/test/resources/containers/keycloak-oid4vci.yml" + realm = "oid4vci-holder" + extra_scopes = ["StudentProfile"] + extra_envs = { + IDENTUS_URL = "${ISSUER_AGENT_URL:-http://host.docker.internal:8080}" + } + extra_clients = { + holder = { redirectUris = ["${HOLDER_WEBHOOK_URL:-http://host.docker.internal:9956}/*"] } + } + } vault = { http_port = 8200 auth_type = "APP_ROLE" @@ -41,6 +54,7 @@ roles = [ url = "${ISSUER_WEBHOOK_URL:-http://host.docker.internal:9955}" init_required = true } + oid4vci_auth_server = "http://host.docker.internal:9981/realms/oid4vci-holder" }, { name = "Holder" diff --git a/tests/integration-tests/src/test/resources/configs/mt_vault_approle.conf b/tests/integration-tests/src/test/resources/configs/mt_vault_approle.conf index b50656519a..aa81ec52fd 100644 --- a/tests/integration-tests/src/test/resources/configs/mt_vault_approle.conf +++ b/tests/integration-tests/src/test/resources/configs/mt_vault_approle.conf @@ -8,6 +8,19 @@ services = { http_port = 8200, vault_auth_type = "APP_ROLE" } + keycloak_oid4vci = { + http_port = 9981 + logger_name = "keycloak_oid4vci" + compose_file = "src/test/resources/containers/keycloak-oid4vci.yml" + realm = "oid4vci-holder" + extra_scopes = ["StudentProfile"] + extra_envs = { + IDENTUS_URL = "${ISSUER_AGENT_URL:-http://host.docker.internal:8080}" + } + extra_clients = { + holder = { redirectUris = ["${HOLDER_WEBHOOK_URL:-http://host.docker.internal:9956}/*"] } + } + } } # Specify agents that are required to be created before running tests @@ -37,6 +50,7 @@ roles = [ url = "${ISSUER_WEBHOOK_URL:-http://host.docker.internal:9955}" init_required = true } + oid4vci_auth_server = "http://host.docker.internal:9981/realms/oid4vci-holder" }, { name = "Holder" diff --git a/tests/integration-tests/src/test/resources/configs/mt_vault_token.conf b/tests/integration-tests/src/test/resources/configs/mt_vault_token.conf index e6493e2126..85a52323de 100644 --- a/tests/integration-tests/src/test/resources/configs/mt_vault_token.conf +++ b/tests/integration-tests/src/test/resources/configs/mt_vault_token.conf @@ -8,6 +8,19 @@ services = { http_port = 8200, vault_auth_type = TOKEN } + keycloak_oid4vci = { + http_port = 9981 + logger_name = "keycloak_oid4vci" + compose_file = "src/test/resources/containers/keycloak-oid4vci.yml" + realm = "oid4vci-holder" + extra_scopes = ["StudentProfile"] + extra_envs = { + IDENTUS_URL = "${ISSUER_AGENT_URL:-http://host.docker.internal:8080}" + } + extra_clients = { + holder = { redirectUris = ["${HOLDER_WEBHOOK_URL:-http://host.docker.internal:9956}/*"] } + } + } } # Specify agents that are required to be created before running tests @@ -37,6 +50,7 @@ roles = [ url = "${ISSUER_WEBHOOK_URL:-http://host.docker.internal:9955}" init_required = true } + oid4vci_auth_server = "http://host.docker.internal:9981/realms/oid4vci-holder" }, { name = "Holder" diff --git a/tests/integration-tests/src/test/resources/configs/two_agents_basic.conf b/tests/integration-tests/src/test/resources/configs/two_agents_basic.conf index 0ffcb90173..f95568f44f 100644 --- a/tests/integration-tests/src/test/resources/configs/two_agents_basic.conf +++ b/tests/integration-tests/src/test/resources/configs/two_agents_basic.conf @@ -4,6 +4,19 @@ services = { http_port = 50053 version = "${PRISM_NODE_VERSION}" } + keycloak_oid4vci = { + http_port = 9981 + logger_name = "keycloak_oid4vci" + compose_file = "src/test/resources/containers/keycloak-oid4vci.yml" + realm = "oid4vci-holder" + extra_scopes = ["StudentProfile"] + extra_envs = { + IDENTUS_URL = "${ISSUER_AGENT_URL:-http://host.docker.internal:8080}" + } + extra_clients = { + holder = { redirectUris = ["${HOLDER_WEBHOOK_URL:-http://host.docker.internal:9956}/*"] } + } + } } # Specify agents that are required to be created before running tests @@ -39,6 +52,7 @@ roles = [ url = "${ISSUER_WEBHOOK_URL:-http://host.docker.internal:9955}" init_required = true } + oid4vci_auth_server = "http://host.docker.internal:9981/realms/oid4vci-holder" }, { name = "Holder" diff --git a/tests/integration-tests/src/test/resources/configs/two_agents_sharing_keycloak.conf b/tests/integration-tests/src/test/resources/configs/two_agents_sharing_keycloak.conf index 9824e1ff4c..c67f2312f9 100644 --- a/tests/integration-tests/src/test/resources/configs/two_agents_sharing_keycloak.conf +++ b/tests/integration-tests/src/test/resources/configs/two_agents_sharing_keycloak.conf @@ -7,6 +7,19 @@ services = { keycloak = { http_port = 9980 } + keycloak_oid4vci = { + http_port = 9981 + logger_name = "keycloak_oid4vci" + compose_file = "src/test/resources/containers/keycloak-oid4vci.yml" + realm = "oid4vci-holder" + extra_scopes = ["StudentProfile"] + extra_envs = { + IDENTUS_URL = "${ISSUER_AGENT_URL:-http://host.docker.internal:8080}" + } + extra_clients = { + holder = { redirectUris = ["${HOLDER_WEBHOOK_URL:-http://host.docker.internal:9956}/*"] } + } + } } # Specify agents that are required to be created before running tests @@ -44,6 +57,7 @@ roles = [ url = "${ISSUER_WEBHOOK_URL:-http://host.docker.internal:9955}" init_required = true } + oid4vci_auth_server = "http://host.docker.internal:9981/realms/oid4vci-holder" }, { name = "Holder" diff --git a/tests/integration-tests/src/test/resources/containers/keycloak-oid4vci.yml b/tests/integration-tests/src/test/resources/containers/keycloak-oid4vci.yml index 2dae22b4ae..dcfb8c4274 100644 --- a/tests/integration-tests/src/test/resources/containers/keycloak-oid4vci.yml +++ b/tests/integration-tests/src/test/resources/containers/keycloak-oid4vci.yml @@ -3,11 +3,13 @@ version: "3.8" services: keycloak: - image: ghcr.io/hyperledger/identus-keycloak-plugins:0.1.0 + image: ghcr.io/hyperledger/identus-keycloak-plugins:0.2.0 ports: - "${KEYCLOAK_HTTP_PORT}:8080" environment: KEYCLOAK_ADMIN: admin KEYCLOAK_ADMIN_PASSWORD: admin IDENTUS_URL: - command: start-dev --health-enabled=true --hostname-url=http://localhost:${KEYCLOAK_HTTP_PORT} + command: start-dev --health-enabled=true --hostname-url=http://host.docker.internal:${KEYCLOAK_HTTP_PORT} + extra_hosts: + - "host.docker.internal:host-gateway" diff --git a/tests/integration-tests/src/test/resources/features/oid4vci/issue_jwt.feature b/tests/integration-tests/src/test/resources/features/oid4vci/issue_jwt.feature new file mode 100644 index 0000000000..2f30658ad8 --- /dev/null +++ b/tests/integration-tests/src/test/resources/features/oid4vci/issue_jwt.feature @@ -0,0 +1,22 @@ +@oid4vci +Feature: Issue JWT Credentials using OID4VCI authorization code flow + +Background: + Given Issuer has a published DID for JWT + And Issuer has published STUDENT_SCHEMA schema + And Issuer has an existing oid4vci issuer + And Issuer has "StudentProfile" credential configuration created from STUDENT_SCHEMA + +Scenario: Issuing credential with published PRISM DID + When Issuer creates an offer using "StudentProfile" configuration with "short" form DID + And Holder receives oid4vci offer from Issuer + And Holder resolves oid4vci issuer metadata and login via front-end channel + And Holder presents the access token with JWT proof on CredentialEndpoint + Then Holder sees credential issued successfully from CredentialEndpoint + +Scenario: Issuing credential with unpublished PRISM DID + When Issuer creates an offer using "StudentProfile" configuration with "long" form DID + And Holder receives oid4vci offer from Issuer + And Holder resolves oid4vci issuer metadata and login via front-end channel + And Holder presents the access token with JWT proof on CredentialEndpoint + Then Holder sees credential issued successfully from CredentialEndpoint diff --git a/tests/integration-tests/src/test/resources/logback-test.xml b/tests/integration-tests/src/test/resources/logback-test.xml index 357d669652..b28cee7002 100644 --- a/tests/integration-tests/src/test/resources/logback-test.xml +++ b/tests/integration-tests/src/test/resources/logback-test.xml @@ -13,4 +13,6 @@ + + From de53f1db15a25e4d66cba1b191fc6e591b42284b Mon Sep 17 00:00:00 2001 From: Yurii Shynbuiev - IOHK Date: Tue, 9 Jul 2024 03:12:49 +0700 Subject: [PATCH 05/13] feat: upgrade docusaurus and semantic-release packages Signed-off-by: Yurii Shynbuiev - IOHK --- docs/docusaurus/credentials/issue.md | 4 ++-- docs/docusaurus/schemas/credential-schema.md | 2 +- docs/docusaurus/secrets/seed-generation.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/docusaurus/credentials/issue.md b/docs/docusaurus/credentials/issue.md index 086178a18e..30646fa8e0 100644 --- a/docs/docusaurus/credentials/issue.md +++ b/docs/docusaurus/credentials/issue.md @@ -183,7 +183,7 @@ The `issuingDID` and `connectionId` properties come from completing the pre-requ ::: - 📌 **Note:** Claims can also include the `exp` Expiration Time attribute, which is part of JWT claims. `exp` attribute is disclosable if specified and can have a value in epoch time (in seconds), indicating when the SDJWT credential expires for more details - +[RFC5719](https://datatracker.ietf.org/doc/html/rfc7519#page-9) Once the request initiates, a new credential record for the issuer gets created with a unique ID. The state of this record is now `OfferPending`. @@ -352,7 +352,7 @@ curl -X POST "http://localhost:8090/cloud-agent/issue-credentials/records/$holde } ``` 2. `keyId`: This is optional field but must be specified to choose which key bounds to the verifiable credential. - For more information on key-binding, . + For more information on key-binding, [ietf-oauth-selective-disclosure-jwt](https://datatracker.ietf.org/doc/draft-ietf-oauth-selective-disclosure-jwt). Currently, we only support the EdDSA algorithm and curve Ed25519. The specified keyId should be of type Ed25519. The purpose of the keyId should be authentication. diff --git a/docs/docusaurus/schemas/credential-schema.md b/docs/docusaurus/schemas/credential-schema.md index c992b1afd0..0630211be9 100644 --- a/docs/docusaurus/schemas/credential-schema.md +++ b/docs/docusaurus/schemas/credential-schema.md @@ -166,7 +166,7 @@ DID of the identity which authored the credential schema. ### schema (JSON Schema) A valid [JSON-SCHEMA](https://json-schema.org/) where the credential schema semantic gets defined. -JSON Schema must be composed according to schema. +JSON Schema must be composed according to the [Metaschema](https://json-schema.org/draft/2020-12/schema) schema. **Example:** ```json diff --git a/docs/docusaurus/secrets/seed-generation.md b/docs/docusaurus/secrets/seed-generation.md index 5d9e805819..38bb865224 100644 --- a/docs/docusaurus/secrets/seed-generation.md +++ b/docs/docusaurus/secrets/seed-generation.md @@ -52,5 +52,5 @@ users can write down their mnemonic phrase, making it more convenient to keep tr By using BIP39, users have options to choose a mnemonic phrase length as well as a passphrase. There are many tools for generating a BIP39 seed including but not limited to: -- (use the BIP39 seed field which provides a 128-chars hex string) +- [BIP39](https://iancoleman.io/bip39/) (use the BIP39 seed field which provides a 128-chars hex string) - [BIP39 - implementations section](https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki#other-implementations) also provides a list of implementations From fe22d22d5c9c6f180c803c4e647cb8b2b147ccf9 Mon Sep 17 00:00:00 2001 From: Yurii Shynbuiev - IOHK Date: Wed, 10 Jul 2024 15:33:53 +0700 Subject: [PATCH 06/13] chore: update codeowners files [skip ci] (#1255) Signed-off-by: Yurii Shynbuiev - IOHK --- .github/CODEOWNERS | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index b4fb5ea65d..6fcb2aad43 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,11 +1,11 @@ # Castor: -/castor/ @patlo-iog @yshyn-iohk +/castor/ @patlo-iog @yshyn-iohk @shotexa # Connect: -/connect/ @bvoiturier @FabioPinheiro @mineme0110 +/connect/ @bvoiturier @FabioPinheiro @mineme0110 @shotexa # Pollux: -/pollux/ @patlo-iog @CryptoKnightIOG @mineme0110 +/pollux/ @patlo-iog @CryptoKnightIOG @mineme0110 @shotexa # Cloud Agent: /cloud-agent/ @bvoiturier @yshyn-iohk @patlo-iog @@ -14,13 +14,16 @@ /.github/ @mineme0110 @patlo-iog # PRISM Node: -/prism-node/ @shotexa +/prism-node/ @shotexa @patlo-iog # Shared: /shared/ @patlo-iog @FabioPinheiro @mineme0110 @yshyn-iohk @bvoiturier @shotexa @CryptoKnightIOG # E2E tests: -/tests/ @todorkoleviohk @amagyar-iohk @yshyn-iohk @patlo-iog @mineme0110 +/tests/ @amagyar-iohk @yshyn-iohk @patlo-iog @mineme0110 @blockchaintimothy @shotexa @FabioPinheiro + +# infrastructure +/infrastructure/ @patlo-iog @yshyn-iohk @womfoo @milosbackonja @mineme0110 @amagyar-iohk # Docs: /docs/ @bvoiturier @yshyn-iohk From fa5121aea694f2ec55402b4d5556a22e77e36c69 Mon Sep 17 00:00:00 2001 From: Yurii Shynbuiev - IOHK Date: Thu, 11 Jul 2024 16:07:50 +0700 Subject: [PATCH 07/13] chore: update CODEOWNERS to have identus-maintainers team only [skip-ci] (#1257) Signed-off-by: Yurii Shynbuiev --- .github/CODEOWNERS | 31 +------------------------------ 1 file changed, 1 insertion(+), 30 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 6fcb2aad43..dd72d47379 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,30 +1 @@ -# Castor: -/castor/ @patlo-iog @yshyn-iohk @shotexa - -# Connect: -/connect/ @bvoiturier @FabioPinheiro @mineme0110 @shotexa - -# Pollux: -/pollux/ @patlo-iog @CryptoKnightIOG @mineme0110 @shotexa - -# Cloud Agent: -/cloud-agent/ @bvoiturier @yshyn-iohk @patlo-iog - -# CI pipelines: -/.github/ @mineme0110 @patlo-iog - -# PRISM Node: -/prism-node/ @shotexa @patlo-iog - -# Shared: -/shared/ @patlo-iog @FabioPinheiro @mineme0110 @yshyn-iohk @bvoiturier @shotexa @CryptoKnightIOG - -# E2E tests: -/tests/ @amagyar-iohk @yshyn-iohk @patlo-iog @mineme0110 @blockchaintimothy @shotexa @FabioPinheiro - -# infrastructure -/infrastructure/ @patlo-iog @yshyn-iohk @womfoo @milosbackonja @mineme0110 @amagyar-iohk - -# Docs: -/docs/ @bvoiturier @yshyn-iohk -*.md @petevielhaber +* @hyperledger/identus-maintainers From ee2c239c9abaa0805953499c5d9fe36b6f577099 Mon Sep 17 00:00:00 2001 From: patlo-iog Date: Fri, 12 Jul 2024 12:38:12 +0700 Subject: [PATCH 08/13] docs: add local stack examples [skip ci] (#939) Signed-off-by: Pat Losoponkul Signed-off-by: Hyperledger Bot Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Hyperledger Bot --- examples/.nickel/caddy.ncl | 2 +- examples/.nickel/stack.ncl | 14 ++-- examples/README.md | 74 +++++++++++++++++++ examples/mt-keycloak-vault/README.md | 16 ++++ examples/mt-keycloak-vault/compose.yaml | 18 ++--- .../{tests => hurl}/01_create_users.hurl | 6 +- .../{tests => hurl}/02_jwt_flow.hurl | 42 +++++------ .../mt-keycloak-vault/{tests => hurl}/local | 0 examples/mt-keycloak/README.md | 11 +++ examples/mt-keycloak/compose.yaml | 18 ++--- .../{tests => hurl}/01_create_users.hurl | 6 +- .../{tests => hurl}/02_jwt_flow.hurl | 44 +++++------ examples/mt-keycloak/{tests => hurl}/local | 0 examples/mt/README.md | 5 ++ examples/mt/compose.yaml | 6 +- examples/st-multi/README.me | 7 ++ examples/st-multi/compose.yaml | 18 ++--- .../st-multi/{tests => hurl}/01_jwt_flow.hurl | 44 +++++------ examples/st-multi/{tests => hurl}/local | 0 examples/st-oid4vci/compose.yaml | 6 +- examples/st-vault/README.md | 10 +++ examples/st-vault/compose.yaml | 6 +- examples/st/README.md | 5 ++ examples/st/compose.yaml | 6 +- 24 files changed, 246 insertions(+), 118 deletions(-) create mode 100644 examples/README.md create mode 100644 examples/mt-keycloak-vault/README.md rename examples/mt-keycloak-vault/{tests => hurl}/01_create_users.hurl (95%) rename examples/mt-keycloak-vault/{tests => hurl}/02_jwt_flow.hurl (81%) rename examples/mt-keycloak-vault/{tests => hurl}/local (100%) create mode 100644 examples/mt-keycloak/README.md rename examples/mt-keycloak/{tests => hurl}/01_create_users.hurl (95%) rename examples/mt-keycloak/{tests => hurl}/02_jwt_flow.hurl (81%) rename examples/mt-keycloak/{tests => hurl}/local (100%) create mode 100644 examples/mt/README.md create mode 100644 examples/st-multi/README.me rename examples/st-multi/{tests => hurl}/01_jwt_flow.hurl (74%) rename examples/st-multi/{tests => hurl}/local (100%) create mode 100644 examples/st-vault/README.md create mode 100644 examples/st/README.md diff --git a/examples/.nickel/caddy.ncl b/examples/.nickel/caddy.ncl index 1f3ccf94b7..03aa4e0bd7 100644 --- a/examples/.nickel/caddy.ncl +++ b/examples/.nickel/caddy.ncl @@ -25,7 +25,7 @@ in handle_path /didcomm* { reverse_proxy %{args.agent.host}:%{std.to_string args.agent.didcommPort} } - handle_path /prism-agent* { + handle_path /agent* { reverse_proxy %{args.agent.host}:%{std.to_string args.agent.restPort} } handle_path /keycloak* { diff --git a/examples/.nickel/stack.ncl b/examples/.nickel/stack.ncl index a7e713a090..e722ae0551 100644 --- a/examples/.nickel/stack.ncl +++ b/examples/.nickel/stack.ncl @@ -159,7 +159,7 @@ in agentDb = makeSharedDbConfig "agent", node = { host = "node" }, didcommServiceUrl = "http://%{hosts.caddy}:%{std.to_string args.port}/didcomm", - restServiceUrl = "http://%{hosts.caddy}:%{std.to_string args.port}/prism-agent", + restServiceUrl = "http://%{hosts.caddy}:%{std.to_string args.port}/agent", apikeyEnabled = args.apikeyEnabled, } & ( @@ -200,12 +200,12 @@ in version = V.hurl, hurlDir = "../.shared/hurl/simple_realm", variables = { - HURL_KEYCLOAK_BASE_URL = "http://%{hosts.keycloak}:8080", - HURL_KEYCLOAK_ADMIN_USER = "admin", - HURL_KEYCLOAK_ADMIN_PASSWORD = "admin", - HURL_KEYCLOAK_REALM = "identus", - HURL_KEYCLOAK_CLIENT_ID = "agent", - HURL_KEYCLOAK_CLIENT_SECRET = "agent-secret", + HURL_keycloak_base_url = "http://%{hosts.keycloak}:8080", + HURL_keycloak_admin_user = "admin", + HURL_keycloak_admin_password = "admin", + HURL_keycloak_realm = "identus", + HURL_keycloak_client_id = "agent", + HURL_keycloak_client_secret = "agent-secret", } }, } diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000000..670b327f54 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,74 @@ +# How to run examples + +## Prerequisites + +- docker-compose version >= `2.23.1` + +## Running examples + +Most of the examples should follow the same pattern. +Simply go to each example directory and spin up the docker-compose of each example. + +```bash +cd +docker-compose up +``` + +If some example requires a different command, it should be provided in its own local README. + +Once finished, `docker-compose down --volumes` can be used to clean docker volumes to avoid unexpected behavior for the next run. + +## Examples + +| example | description | +|---------------------|---------------------------------------------------------------------------| +| `st` | single-tenant configuration without external services (except database) | +| `st-multi` | 3 instances of single-tenant configuration | +| `st-vault` | single-tenant with Vault for secret storage | +| `st-oid4vci` | single-tenant agent with Keycloak as external Issuer Authorization Server | +| `mt` | multi-tenant configuration using built-in IAM | +| `mt-keycloak` | multi-tenant configuration using Keycloak for IAM | +| `mt-keycloak-vault` | multi-tenant configuration using Keycloak and Vault | + +## Testing examples + +Some example directories may contain a sub-directory called `hurl`. +Hurl is a CLI tool for testing HTTP requests and can be installed according to [this documentation](https://hurl.dev/docs/installation.html). +If the example contains a sub-directory named `hurl`, the example can be tested against HTTP calls with the following commands. + +```bash +cd ./hurl +hurl --variables-file ./local *.hurl --test +``` + +# Contributing + +All of the docker-compose files in examples are generated using [Nickel](https://nickel-lang.org/). +They are defined in a shared `.nickel` directory and generated using the `build.sh` script. + +## Prerequisites + +- [Nickel](https://nickel-lang.org/) version >= `1.5` installed + +## Generate example compose files + +To generate the docker-compose config for all examples, run + +```bash +cd .nickel +./build.sh +``` + +## Updating example compose files + +To update the configuration, simply edit the `*.ncl` config in the `.nickel` directory and regenerate the docker-compose files. + +## Adding new examples + +To add a new example with docker-compose file, simply create a new configuration key in the `root.ncl` and add a new entry in the `build.sh` script. +You may need to create the target example directory if it does not already exist. + +## Example with bootstrapping script + +If any example requires initialize steps, it should be made part of the docker-compose `depends_on` construct. +Ideally, infrastructure bootstrapping should be automatic (database, IAM), but not necessarily application bootstrapping (tenant onboarding). diff --git a/examples/mt-keycloak-vault/README.md b/examples/mt-keycloak-vault/README.md new file mode 100644 index 0000000000..fc6d4a2ac2 --- /dev/null +++ b/examples/mt-keycloak-vault/README.md @@ -0,0 +1,16 @@ +## Configuration + +| Exposed Service | Description | +|---------------------------------|--------------------------| +| `localhost:8080/prism-agent` | Multi-tenant Cloud Agent | +| `localhost:8080/keycloak/admin` | Keycloak | +| `localhost:8200` | Vault | + +__Keycloak__ + +- Admin user `admin` +- Admin password `admin` + +__Vault__ + +- Root token `admin` diff --git a/examples/mt-keycloak-vault/compose.yaml b/examples/mt-keycloak-vault/compose.yaml index e322eae3ea..f86bdffcd3 100644 --- a/examples/mt-keycloak-vault/compose.yaml +++ b/examples/mt-keycloak-vault/compose.yaml @@ -5,7 +5,7 @@ configs: handle_path /didcomm* { reverse_proxy agent-default:8090 } - handle_path /prism-agent* { + handle_path /agent* { reverse_proxy agent-default:8085 } handle_path /keycloak* { @@ -46,10 +46,10 @@ services: POLLUX_DB_PASSWORD: postgres POLLUX_DB_PORT: '5432' POLLUX_DB_USER: postgres - POLLUX_STATUS_LIST_REGISTRY_PUBLIC_URL: http://caddy-default:8080/prism-agent + POLLUX_STATUS_LIST_REGISTRY_PUBLIC_URL: http://caddy-default:8080/agent PRISM_NODE_HOST: node PRISM_NODE_PORT: '50053' - REST_SERVICE_URL: http://caddy-default:8080/prism-agent + REST_SERVICE_URL: http://caddy-default:8080/agent SECRET_STORAGE_BACKEND: vault VAULT_ADDR: http://vault-default:8200 VAULT_TOKEN: admin @@ -103,12 +103,12 @@ services: - /hurl/*.hurl - --test environment: - HURL_KEYCLOAK_ADMIN_PASSWORD: admin - HURL_KEYCLOAK_ADMIN_USER: admin - HURL_KEYCLOAK_BASE_URL: http://keycloak-default:8080 - HURL_KEYCLOAK_CLIENT_ID: agent - HURL_KEYCLOAK_CLIENT_SECRET: agent-secret - HURL_KEYCLOAK_REALM: identus + HURL_keycloak_admin_password: admin + HURL_keycloak_admin_user: admin + HURL_keycloak_base_url: http://keycloak-default:8080 + HURL_keycloak_client_id: agent + HURL_keycloak_client_secret: agent-secret + HURL_keycloak_realm: identus image: ghcr.io/orange-opensource/hurl:4.2.0 volumes: - ../.shared/hurl/simple_realm:/hurl diff --git a/examples/mt-keycloak-vault/tests/01_create_users.hurl b/examples/mt-keycloak-vault/hurl/01_create_users.hurl similarity index 95% rename from examples/mt-keycloak-vault/tests/01_create_users.hurl rename to examples/mt-keycloak-vault/hurl/01_create_users.hurl index 9a412b9668..2576c26564 100644 --- a/examples/mt-keycloak-vault/tests/01_create_users.hurl +++ b/examples/mt-keycloak-vault/hurl/01_create_users.hurl @@ -66,7 +66,7 @@ HTTP 200 issuer_access_token: jsonpath "$.access_token" # Create Issuer wallet -POST {{ agent_url }}/prism-agent/wallets +POST {{ agent_url }}/agent/wallets Authorization: Bearer {{ issuer_access_token }} { "name": "issuer-wallet" @@ -85,7 +85,7 @@ HTTP 200 holder_access_token: jsonpath "$.access_token" # Create Holder wallet -POST {{ agent_url }}/prism-agent/wallets +POST {{ agent_url }}/agent/wallets Authorization: Bearer {{ holder_access_token }} { "name": "holder-wallet" @@ -104,7 +104,7 @@ HTTP 200 verifier_access_token: jsonpath "$.access_token" # Create Verifier wallet -POST {{ agent_url }}/prism-agent/wallets +POST {{ agent_url }}/agent/wallets Authorization: Bearer {{ verifier_access_token }} { "name": "verifier-wallet" diff --git a/examples/mt-keycloak-vault/tests/02_jwt_flow.hurl b/examples/mt-keycloak-vault/hurl/02_jwt_flow.hurl similarity index 81% rename from examples/mt-keycloak-vault/tests/02_jwt_flow.hurl rename to examples/mt-keycloak-vault/hurl/02_jwt_flow.hurl index c1ceb033b4..4f7ca2e697 100644 --- a/examples/mt-keycloak-vault/tests/02_jwt_flow.hurl +++ b/examples/mt-keycloak-vault/hurl/02_jwt_flow.hurl @@ -39,7 +39,7 @@ verifier_access_token: jsonpath "$.access_token" # Prerequisites ############################## # Issuer create DID -POST {{ agent_url }}/prism-agent/did-registrar/dids +POST {{ agent_url }}/agent/did-registrar/dids Authorization: Bearer {{ issuer_access_token }} { "documentTemplate": { @@ -54,10 +54,10 @@ Authorization: Bearer {{ issuer_access_token }} } HTTP 201 [Captures] -issuer_did: jsonpath "$.longFormDid" regex "(did:prism:[a-z0-9]+):.+$" +issuer_did: jsonpath "$.longFormDid" # regex "(did:prism:[a-z0-9]+):.+$" # Holder create DID -POST {{ agent_url }}/prism-agent/did-registrar/dids +POST {{ agent_url }}/agent/did-registrar/dids Authorization: Bearer {{ holder_access_token }} { "documentTemplate": { @@ -78,7 +78,7 @@ holder_did: jsonpath "$.longFormDid" regex "(did:prism:[a-z0-9]+):.+$" # Issuance Connection ############################## # Inviter create connection -POST {{ agent_url }}/prism-agent/connections +POST {{ agent_url }}/agent/connections Authorization: Bearer {{ issuer_access_token }} { "label": "My Connection" @@ -89,7 +89,7 @@ raw_invitation: jsonpath "$.invitation.invitationUrl" regex ".*_oob=(.*)$" issuer_connection_id: jsonpath "$.connectionId" # Invitee accept connection -POST {{ agent_url }}/prism-agent/connection-invitations +POST {{ agent_url }}/agent/connection-invitations Authorization: Bearer {{ holder_access_token }} { "invitation": "{{ raw_invitation }}" @@ -99,7 +99,7 @@ HTTP 200 holder_connection_id: jsonpath "$.connectionId" # Wait for inviter connection status -GET {{ agent_url }}/prism-agent/connections/{{ issuer_connection_id }} +GET {{ agent_url }}/agent/connections/{{ issuer_connection_id }} Authorization: Bearer {{ issuer_access_token }} [Options] retry: -1 @@ -108,7 +108,7 @@ HTTP 200 jsonpath "$.state" == "ConnectionResponseSent" # Wait for invitee connection status -GET {{ agent_url }}/prism-agent/connections/{{ holder_connection_id }} +GET {{ agent_url }}/agent/connections/{{ holder_connection_id }} Authorization: Bearer {{ holder_access_token }} [Options] retry: -1 @@ -120,7 +120,7 @@ jsonpath "$.state" == "ConnectionResponseReceived" # Issuance ############################## # Issuer create credential offer -POST {{ agent_url }}/prism-agent/issue-credentials/credential-offers +POST {{ agent_url }}/agent/issue-credentials/credential-offers Authorization: Bearer {{ issuer_access_token }} { "claims": { @@ -138,7 +138,7 @@ issuer_cred_record_id: jsonpath "$.recordId" didcomm_issuing_thid: jsonpath "$.thid" # Holder wait for OfferReceived state -GET {{ agent_url }}/prism-agent/issue-credentials/records +GET {{ agent_url }}/agent/issue-credentials/records Authorization: Bearer {{ holder_access_token }} [QueryStringParams] thid: {{ didcomm_issuing_thid }} @@ -151,7 +151,7 @@ jsonpath "$.contents[0].protocolState" == "OfferReceived" holder_cred_record_id: jsonpath "$.contents[0].recordId" # Holder accept a credential-offer -POST {{ agent_url }}/prism-agent/issue-credentials/records/{{ holder_cred_record_id }}/accept-offer +POST {{ agent_url }}/agent/issue-credentials/records/{{ holder_cred_record_id }}/accept-offer Authorization: Bearer {{ holder_access_token }} { "subjectId": "{{ holder_did }}" @@ -159,7 +159,7 @@ Authorization: Bearer {{ holder_access_token }} HTTP 200 # Holder wait for CredentialReceived state -GET {{ agent_url }}/prism-agent/issue-credentials/records/{{ holder_cred_record_id }} +GET {{ agent_url }}/agent/issue-credentials/records/{{ holder_cred_record_id }} Authorization: Bearer {{ holder_access_token }} [Options] retry: -1 @@ -168,7 +168,7 @@ HTTP 200 jsonpath "$.protocolState" == "CredentialReceived" # Issuer wait for CredentialSent state -GET {{ agent_url }}/prism-agent/issue-credentials/records/{{ issuer_cred_record_id }} +GET {{ agent_url }}/agent/issue-credentials/records/{{ issuer_cred_record_id }} Authorization: Bearer {{ issuer_access_token }} [Options] retry: -1 @@ -180,7 +180,7 @@ jsonpath "$.protocolState" == "CredentialSent" # Presentation Connection ############################## # Inviter create connection -POST {{ agent_url }}/prism-agent/connections +POST {{ agent_url }}/agent/connections Authorization: Bearer {{ verifier_access_token }} { "label": "My Connection" @@ -191,7 +191,7 @@ raw_invitation: jsonpath "$.invitation.invitationUrl" regex ".*_oob=(.*)$" verifier_connection_id: jsonpath "$.connectionId" # Invitee accept connection -POST {{ agent_url }}/prism-agent/connection-invitations +POST {{ agent_url }}/agent/connection-invitations Authorization: Bearer {{ holder_access_token }} { "invitation": "{{ raw_invitation }}" @@ -201,7 +201,7 @@ HTTP 200 holder_connection_id: jsonpath "$.connectionId" # Wait for inviter connection status -GET {{ agent_url }}/prism-agent/connections/{{ verifier_connection_id }} +GET {{ agent_url }}/agent/connections/{{ verifier_connection_id }} Authorization: Bearer {{ verifier_access_token }} [Options] retry: -1 @@ -210,7 +210,7 @@ HTTP 200 jsonpath "$.state" == "ConnectionResponseSent" # Wait for invitee connection status -GET {{ agent_url }}/prism-agent/connections/{{ holder_connection_id }} +GET {{ agent_url }}/agent/connections/{{ holder_connection_id }} Authorization: Bearer {{ holder_access_token }} [Options] retry: -1 @@ -222,7 +222,7 @@ jsonpath "$.state" == "ConnectionResponseReceived" # Presentation ############################## # Verifier create presentation request -POST {{ agent_url }}/prism-agent/present-proof/presentations +POST {{ agent_url }}/agent/present-proof/presentations Authorization: Bearer {{ verifier_access_token }} { "connectionId": "{{ verifier_connection_id }}", @@ -238,7 +238,7 @@ verifier_presentation_id: jsonpath "$.presentationId" didcomm_presentation_thid: jsonpath "$.thid" # Holder wait for RequestReceived state -GET {{ agent_url }}/prism-agent/present-proof/presentations +GET {{ agent_url }}/agent/present-proof/presentations Authorization: Bearer {{ holder_access_token }} [QueryStringParams] thid: {{ didcomm_presentation_thid }} @@ -251,7 +251,7 @@ jsonpath "$.contents[0].status" == "RequestReceived" holder_presentation_id: jsonpath "$.contents[0].presentationId" # Holder accept presentation request -PATCH {{ agent_url }}/prism-agent/present-proof/presentations/{{ holder_presentation_id }} +PATCH {{ agent_url }}/agent/present-proof/presentations/{{ holder_presentation_id }} Authorization: Bearer {{ holder_access_token }} { "action": "request-accept", @@ -260,7 +260,7 @@ Authorization: Bearer {{ holder_access_token }} HTTP 200 # Holder wait for PresentationSent state -GET {{ agent_url }}/prism-agent/present-proof/presentations +GET {{ agent_url }}/agent/present-proof/presentations Authorization: Bearer {{ holder_access_token }} [QueryStringParams] thid: {{ didcomm_presentation_thid }} @@ -271,7 +271,7 @@ HTTP 200 jsonpath "$.contents[0].status" == "PresentationSent" # Verfiier wait for PresentationVerified state -GET {{ agent_url }}/prism-agent/present-proof/presentations +GET {{ agent_url }}/agent/present-proof/presentations Authorization: Bearer {{ verifier_access_token }} [QueryStringParams] thid: {{ didcomm_presentation_thid }} diff --git a/examples/mt-keycloak-vault/tests/local b/examples/mt-keycloak-vault/hurl/local similarity index 100% rename from examples/mt-keycloak-vault/tests/local rename to examples/mt-keycloak-vault/hurl/local diff --git a/examples/mt-keycloak/README.md b/examples/mt-keycloak/README.md new file mode 100644 index 0000000000..2e1d454908 --- /dev/null +++ b/examples/mt-keycloak/README.md @@ -0,0 +1,11 @@ +## Configuration + +| Exposed Service | Description | +|---------------------------------|--------------------------| +| `localhost:8080/prism-agent` | Multi-tenant Cloud Agent | +| `localhost:8080/keycloak/admin` | Keycloak | + +__Keycloak__ + +- Admin user `admin` +- Admin password `admin` diff --git a/examples/mt-keycloak/compose.yaml b/examples/mt-keycloak/compose.yaml index 9ec537bb58..8672828e72 100644 --- a/examples/mt-keycloak/compose.yaml +++ b/examples/mt-keycloak/compose.yaml @@ -5,7 +5,7 @@ configs: handle_path /didcomm* { reverse_proxy agent-default:8090 } - handle_path /prism-agent* { + handle_path /agent* { reverse_proxy agent-default:8085 } handle_path /keycloak* { @@ -46,10 +46,10 @@ services: POLLUX_DB_PASSWORD: postgres POLLUX_DB_PORT: '5432' POLLUX_DB_USER: postgres - POLLUX_STATUS_LIST_REGISTRY_PUBLIC_URL: http://caddy-default:8080/prism-agent + POLLUX_STATUS_LIST_REGISTRY_PUBLIC_URL: http://caddy-default:8080/agent PRISM_NODE_HOST: node PRISM_NODE_PORT: '50053' - REST_SERVICE_URL: http://caddy-default:8080/prism-agent + REST_SERVICE_URL: http://caddy-default:8080/agent SECRET_STORAGE_BACKEND: postgres image: ghcr.io/hyperledger/identus-cloud-agent:1.37.0 restart: always @@ -101,12 +101,12 @@ services: - /hurl/*.hurl - --test environment: - HURL_KEYCLOAK_ADMIN_PASSWORD: admin - HURL_KEYCLOAK_ADMIN_USER: admin - HURL_KEYCLOAK_BASE_URL: http://keycloak-default:8080 - HURL_KEYCLOAK_CLIENT_ID: agent - HURL_KEYCLOAK_CLIENT_SECRET: agent-secret - HURL_KEYCLOAK_REALM: identus + HURL_keycloak_admin_password: admin + HURL_keycloak_admin_user: admin + HURL_keycloak_base_url: http://keycloak-default:8080 + HURL_keycloak_client_id: agent + HURL_keycloak_client_secret: agent-secret + HURL_keycloak_realm: identus image: ghcr.io/orange-opensource/hurl:4.2.0 volumes: - ../.shared/hurl/simple_realm:/hurl diff --git a/examples/mt-keycloak/tests/01_create_users.hurl b/examples/mt-keycloak/hurl/01_create_users.hurl similarity index 95% rename from examples/mt-keycloak/tests/01_create_users.hurl rename to examples/mt-keycloak/hurl/01_create_users.hurl index 9a412b9668..2576c26564 100644 --- a/examples/mt-keycloak/tests/01_create_users.hurl +++ b/examples/mt-keycloak/hurl/01_create_users.hurl @@ -66,7 +66,7 @@ HTTP 200 issuer_access_token: jsonpath "$.access_token" # Create Issuer wallet -POST {{ agent_url }}/prism-agent/wallets +POST {{ agent_url }}/agent/wallets Authorization: Bearer {{ issuer_access_token }} { "name": "issuer-wallet" @@ -85,7 +85,7 @@ HTTP 200 holder_access_token: jsonpath "$.access_token" # Create Holder wallet -POST {{ agent_url }}/prism-agent/wallets +POST {{ agent_url }}/agent/wallets Authorization: Bearer {{ holder_access_token }} { "name": "holder-wallet" @@ -104,7 +104,7 @@ HTTP 200 verifier_access_token: jsonpath "$.access_token" # Create Verifier wallet -POST {{ agent_url }}/prism-agent/wallets +POST {{ agent_url }}/agent/wallets Authorization: Bearer {{ verifier_access_token }} { "name": "verifier-wallet" diff --git a/examples/mt-keycloak/tests/02_jwt_flow.hurl b/examples/mt-keycloak/hurl/02_jwt_flow.hurl similarity index 81% rename from examples/mt-keycloak/tests/02_jwt_flow.hurl rename to examples/mt-keycloak/hurl/02_jwt_flow.hurl index 2169d2f777..4565f4e877 100644 --- a/examples/mt-keycloak/tests/02_jwt_flow.hurl +++ b/examples/mt-keycloak/hurl/02_jwt_flow.hurl @@ -39,7 +39,7 @@ verifier_access_token: jsonpath "$.access_token" # Prerequisites ############################## # Issuer create DID -POST {{ agent_url }}/prism-agent/did-registrar/dids +POST {{ agent_url }}/agent/did-registrar/dids Authorization: Bearer {{ issuer_access_token }} { "documentTemplate": { @@ -57,12 +57,12 @@ HTTP 201 issuer_did: jsonpath "$.longFormDid" regex "(did:prism:[a-z0-9]+):.+$" # Issuer publish DID -POST {{ agent_url }}/prism-agent/did-registrar/dids/{{ issuer_did }}/publications +POST {{ agent_url }}/agent/did-registrar/dids/{{ issuer_did }}/publications Authorization: Bearer {{ issuer_access_token }} HTTP 202 # Issuer wait for DID to be published -GET {{ agent_url }}/prism-agent/did-registrar/dids/{{ issuer_did }} +GET {{ agent_url }}/agent/did-registrar/dids/{{ issuer_did }} Authorization: Bearer {{ issuer_access_token }} [Options] retry: -1 @@ -71,7 +71,7 @@ HTTP 200 jsonpath "$.status" == "PUBLISHED" # Holder create DID -POST {{ agent_url }}/prism-agent/did-registrar/dids +POST {{ agent_url }}/agent/did-registrar/dids Authorization: Bearer {{ holder_access_token }} { "documentTemplate": { @@ -92,7 +92,7 @@ holder_did: jsonpath "$.longFormDid" # Issuance Connection ############################## # Inviter create connection -POST {{ agent_url }}/prism-agent/connections +POST {{ agent_url }}/agent/connections Authorization: Bearer {{ issuer_access_token }} { "label": "My Connection" @@ -103,7 +103,7 @@ raw_invitation: jsonpath "$.invitation.invitationUrl" regex ".*_oob=(.*)$" issuer_connection_id: jsonpath "$.connectionId" # Invitee accept connection -POST {{ agent_url }}/prism-agent/connection-invitations +POST {{ agent_url }}/agent/connection-invitations Authorization: Bearer {{ holder_access_token }} { "invitation": "{{ raw_invitation }}" @@ -113,7 +113,7 @@ HTTP 200 holder_connection_id: jsonpath "$.connectionId" # Wait for inviter connection status -GET {{ agent_url }}/prism-agent/connections/{{ issuer_connection_id }} +GET {{ agent_url }}/agent/connections/{{ issuer_connection_id }} Authorization: Bearer {{ issuer_access_token }} [Options] retry: -1 @@ -122,7 +122,7 @@ HTTP 200 jsonpath "$.state" == "ConnectionResponseSent" # Wait for invitee connection status -GET {{ agent_url }}/prism-agent/connections/{{ holder_connection_id }} +GET {{ agent_url }}/agent/connections/{{ holder_connection_id }} Authorization: Bearer {{ holder_access_token }} [Options] retry: -1 @@ -134,7 +134,7 @@ jsonpath "$.state" == "ConnectionResponseReceived" # Issuance ############################## # Issuer create credential offer -POST {{ agent_url }}/prism-agent/issue-credentials/credential-offers +POST {{ agent_url }}/agent/issue-credentials/credential-offers Authorization: Bearer {{ issuer_access_token }} { "claims": { @@ -152,7 +152,7 @@ issuer_cred_record_id: jsonpath "$.recordId" didcomm_issuing_thid: jsonpath "$.thid" # Holder wait for OfferReceived state -GET {{ agent_url }}/prism-agent/issue-credentials/records +GET {{ agent_url }}/agent/issue-credentials/records Authorization: Bearer {{ holder_access_token }} [QueryStringParams] thid: {{ didcomm_issuing_thid }} @@ -165,7 +165,7 @@ jsonpath "$.contents[0].protocolState" == "OfferReceived" holder_cred_record_id: jsonpath "$.contents[0].recordId" # Holder accept a credential-offer -POST {{ agent_url }}/prism-agent/issue-credentials/records/{{ holder_cred_record_id }}/accept-offer +POST {{ agent_url }}/agent/issue-credentials/records/{{ holder_cred_record_id }}/accept-offer Authorization: Bearer {{ holder_access_token }} { "subjectId": "{{ holder_did }}" @@ -173,7 +173,7 @@ Authorization: Bearer {{ holder_access_token }} HTTP 200 # Holder wait for CredentialReceived state -GET {{ agent_url }}/prism-agent/issue-credentials/records/{{ holder_cred_record_id }} +GET {{ agent_url }}/agent/issue-credentials/records/{{ holder_cred_record_id }} Authorization: Bearer {{ holder_access_token }} [Options] retry: -1 @@ -182,7 +182,7 @@ HTTP 200 jsonpath "$.protocolState" == "CredentialReceived" # Issuer wait for CredentialSent state -GET {{ agent_url }}/prism-agent/issue-credentials/records/{{ issuer_cred_record_id }} +GET {{ agent_url }}/agent/issue-credentials/records/{{ issuer_cred_record_id }} Authorization: Bearer {{ issuer_access_token }} [Options] retry: -1 @@ -194,7 +194,7 @@ jsonpath "$.protocolState" == "CredentialSent" # Presentation Connection ############################## # Inviter create connection -POST {{ agent_url }}/prism-agent/connections +POST {{ agent_url }}/agent/connections Authorization: Bearer {{ verifier_access_token }} { "label": "My Connection" @@ -205,7 +205,7 @@ raw_invitation: jsonpath "$.invitation.invitationUrl" regex ".*_oob=(.*)$" verifier_connection_id: jsonpath "$.connectionId" # Invitee accept connection -POST {{ agent_url }}/prism-agent/connection-invitations +POST {{ agent_url }}/agent/connection-invitations Authorization: Bearer {{ holder_access_token }} { "invitation": "{{ raw_invitation }}" @@ -215,7 +215,7 @@ HTTP 200 holder_connection_id: jsonpath "$.connectionId" # Wait for inviter connection status -GET {{ agent_url }}/prism-agent/connections/{{ verifier_connection_id }} +GET {{ agent_url }}/agent/connections/{{ verifier_connection_id }} Authorization: Bearer {{ verifier_access_token }} [Options] retry: -1 @@ -224,7 +224,7 @@ HTTP 200 jsonpath "$.state" == "ConnectionResponseSent" # Wait for invitee connection status -GET {{ agent_url }}/prism-agent/connections/{{ holder_connection_id }} +GET {{ agent_url }}/agent/connections/{{ holder_connection_id }} Authorization: Bearer {{ holder_access_token }} [Options] retry: -1 @@ -236,7 +236,7 @@ jsonpath "$.state" == "ConnectionResponseReceived" # Presentation ############################## # Verifier create presentation request -POST {{ agent_url }}/prism-agent/present-proof/presentations +POST {{ agent_url }}/agent/present-proof/presentations Authorization: Bearer {{ verifier_access_token }} { "connectionId": "{{ verifier_connection_id }}", @@ -252,7 +252,7 @@ verifier_presentation_id: jsonpath "$.presentationId" didcomm_presentation_thid: jsonpath "$.thid" # Holder wait for RequestReceived state -GET {{ agent_url }}/prism-agent/present-proof/presentations +GET {{ agent_url }}/agent/present-proof/presentations Authorization: Bearer {{ holder_access_token }} [QueryStringParams] thid: {{ didcomm_presentation_thid }} @@ -265,7 +265,7 @@ jsonpath "$.contents[0].status" == "RequestReceived" holder_presentation_id: jsonpath "$.contents[0].presentationId" # Holder accept presentation request -PATCH {{ agent_url }}/prism-agent/present-proof/presentations/{{ holder_presentation_id }} +PATCH {{ agent_url }}/agent/present-proof/presentations/{{ holder_presentation_id }} Authorization: Bearer {{ holder_access_token }} { "action": "request-accept", @@ -274,7 +274,7 @@ Authorization: Bearer {{ holder_access_token }} HTTP 200 # Holder wait for PresentationSent state -GET {{ agent_url }}/prism-agent/present-proof/presentations +GET {{ agent_url }}/agent/present-proof/presentations Authorization: Bearer {{ holder_access_token }} [QueryStringParams] thid: {{ didcomm_presentation_thid }} @@ -285,7 +285,7 @@ HTTP 200 jsonpath "$.contents[0].status" == "PresentationSent" # Verfiier wait for PresentationVerified state -GET {{ agent_url }}/prism-agent/present-proof/presentations +GET {{ agent_url }}/agent/present-proof/presentations Authorization: Bearer {{ verifier_access_token }} [QueryStringParams] thid: {{ didcomm_presentation_thid }} diff --git a/examples/mt-keycloak/tests/local b/examples/mt-keycloak/hurl/local similarity index 100% rename from examples/mt-keycloak/tests/local rename to examples/mt-keycloak/hurl/local diff --git a/examples/mt/README.md b/examples/mt/README.md new file mode 100644 index 0000000000..7cb3752063 --- /dev/null +++ b/examples/mt/README.md @@ -0,0 +1,5 @@ +## Configuration + +| Exposed Service | Description | +|------------------------------|--------------------------| +| `localhost:8080/prism-agent` | Multi-tenant Cloud Agent | diff --git a/examples/mt/compose.yaml b/examples/mt/compose.yaml index 053839c12a..bc1858a68f 100644 --- a/examples/mt/compose.yaml +++ b/examples/mt/compose.yaml @@ -5,7 +5,7 @@ configs: handle_path /didcomm* { reverse_proxy agent-default:8090 } - handle_path /prism-agent* { + handle_path /agent* { reverse_proxy agent-default:8085 } handle_path /keycloak* { @@ -39,10 +39,10 @@ services: POLLUX_DB_PASSWORD: postgres POLLUX_DB_PORT: '5432' POLLUX_DB_USER: postgres - POLLUX_STATUS_LIST_REGISTRY_PUBLIC_URL: http://caddy-default:8080/prism-agent + POLLUX_STATUS_LIST_REGISTRY_PUBLIC_URL: http://caddy-default:8080/agent PRISM_NODE_HOST: node PRISM_NODE_PORT: '50053' - REST_SERVICE_URL: http://caddy-default:8080/prism-agent + REST_SERVICE_URL: http://caddy-default:8080/agent SECRET_STORAGE_BACKEND: postgres image: ghcr.io/hyperledger/identus-cloud-agent:1.37.0 restart: always diff --git a/examples/st-multi/README.me b/examples/st-multi/README.me new file mode 100644 index 0000000000..9483ed9d44 --- /dev/null +++ b/examples/st-multi/README.me @@ -0,0 +1,7 @@ +## Configuration + +|Exposed Service|Description| +|-|-| +|`localhost:8080/prism-agent`|Single-tenant Cloud Agent#1 (issuer)| +|`localhost:8081/prism-agent`|Single-tenant Cloud Agent#2 (holder)| +|`localhost:8082/prism-agent`|Single-tenant Cloud Agent#3 (verifier)| diff --git a/examples/st-multi/compose.yaml b/examples/st-multi/compose.yaml index 2607de85f9..54f501fce6 100644 --- a/examples/st-multi/compose.yaml +++ b/examples/st-multi/compose.yaml @@ -5,7 +5,7 @@ configs: handle_path /didcomm* { reverse_proxy agent-holder:8090 } - handle_path /prism-agent* { + handle_path /agent* { reverse_proxy agent-holder:8085 } handle_path /keycloak* { @@ -21,7 +21,7 @@ configs: handle_path /didcomm* { reverse_proxy agent-issuer:8090 } - handle_path /prism-agent* { + handle_path /agent* { reverse_proxy agent-issuer:8085 } handle_path /keycloak* { @@ -37,7 +37,7 @@ configs: handle_path /didcomm* { reverse_proxy agent-verifier:8090 } - handle_path /prism-agent* { + handle_path /agent* { reverse_proxy agent-verifier:8085 } handle_path /keycloak* { @@ -71,10 +71,10 @@ services: POLLUX_DB_PASSWORD: postgres POLLUX_DB_PORT: '5432' POLLUX_DB_USER: postgres - POLLUX_STATUS_LIST_REGISTRY_PUBLIC_URL: http://caddy-holder:8081/prism-agent + POLLUX_STATUS_LIST_REGISTRY_PUBLIC_URL: http://caddy-holder:8081/agent PRISM_NODE_HOST: node PRISM_NODE_PORT: '50053' - REST_SERVICE_URL: http://caddy-holder:8081/prism-agent + REST_SERVICE_URL: http://caddy-holder:8081/agent SECRET_STORAGE_BACKEND: postgres image: ghcr.io/hyperledger/identus-cloud-agent:1.37.0 restart: always @@ -101,10 +101,10 @@ services: POLLUX_DB_PASSWORD: postgres POLLUX_DB_PORT: '5432' POLLUX_DB_USER: postgres - POLLUX_STATUS_LIST_REGISTRY_PUBLIC_URL: http://caddy-issuer:8080/prism-agent + POLLUX_STATUS_LIST_REGISTRY_PUBLIC_URL: http://caddy-issuer:8080/agent PRISM_NODE_HOST: node PRISM_NODE_PORT: '50053' - REST_SERVICE_URL: http://caddy-issuer:8080/prism-agent + REST_SERVICE_URL: http://caddy-issuer:8080/agent SECRET_STORAGE_BACKEND: postgres image: ghcr.io/hyperledger/identus-cloud-agent:1.37.0 restart: always @@ -131,10 +131,10 @@ services: POLLUX_DB_PASSWORD: postgres POLLUX_DB_PORT: '5432' POLLUX_DB_USER: postgres - POLLUX_STATUS_LIST_REGISTRY_PUBLIC_URL: http://caddy-verifier:8082/prism-agent + POLLUX_STATUS_LIST_REGISTRY_PUBLIC_URL: http://caddy-verifier:8082/agent PRISM_NODE_HOST: node PRISM_NODE_PORT: '50053' - REST_SERVICE_URL: http://caddy-verifier:8082/prism-agent + REST_SERVICE_URL: http://caddy-verifier:8082/agent SECRET_STORAGE_BACKEND: postgres image: ghcr.io/hyperledger/identus-cloud-agent:1.37.0 restart: always diff --git a/examples/st-multi/tests/01_jwt_flow.hurl b/examples/st-multi/hurl/01_jwt_flow.hurl similarity index 74% rename from examples/st-multi/tests/01_jwt_flow.hurl rename to examples/st-multi/hurl/01_jwt_flow.hurl index 94bc540275..7e5e1b9823 100644 --- a/examples/st-multi/tests/01_jwt_flow.hurl +++ b/examples/st-multi/hurl/01_jwt_flow.hurl @@ -2,7 +2,7 @@ # Prerequisites ############################## # Issuer create DID -POST {{ issuer_url }}/prism-agent/did-registrar/dids +POST {{ issuer_url }}/agent/did-registrar/dids { "documentTemplate": { "publicKeys": [ @@ -19,11 +19,11 @@ HTTP 201 issuer_did: jsonpath "$.longFormDid" regex "(did:prism:[a-z0-9]+):.+$" # Issuer publish DID -POST {{ issuer_url }}/prism-agent/did-registrar/dids/{{ issuer_did }}/publications +POST {{ issuer_url }}/agent/did-registrar/dids/{{ issuer_did }}/publications HTTP 202 # Issuer wait for DID to be published -GET {{ issuer_url }}/prism-agent/did-registrar/dids/{{ issuer_did }} +GET {{ issuer_url }}/agent/did-registrar/dids/{{ issuer_did }} [Options] retry: -1 HTTP 200 @@ -31,7 +31,7 @@ HTTP 200 jsonpath "$.status" == "PUBLISHED" # Holder create DID -POST {{ holder_url }}/prism-agent/did-registrar/dids +POST {{ holder_url }}/agent/did-registrar/dids { "documentTemplate": { "publicKeys": [ @@ -51,7 +51,7 @@ holder_did: jsonpath "$.longFormDid" # Issuance Connection ############################## # Inviter create connection -POST {{ issuer_url }}/prism-agent/connections +POST {{ issuer_url }}/agent/connections { "label": "My Connection" } @@ -61,7 +61,7 @@ raw_invitation: jsonpath "$.invitation.invitationUrl" regex ".*_oob=(.*)$" issuer_connection_id: jsonpath "$.connectionId" # Invitee accept connection -POST {{ holder_url }}/prism-agent/connection-invitations +POST {{ holder_url }}/agent/connection-invitations { "invitation": "{{ raw_invitation }}" } @@ -70,7 +70,7 @@ HTTP 200 holder_connection_id: jsonpath "$.connectionId" # Wait for inviter connection status -GET {{ issuer_url }}/prism-agent/connections/{{ issuer_connection_id }} +GET {{ issuer_url }}/agent/connections/{{ issuer_connection_id }} [Options] retry: -1 HTTP 200 @@ -78,7 +78,7 @@ HTTP 200 jsonpath "$.state" == "ConnectionResponseSent" # Wait for invitee connection status -GET {{ holder_url }}/prism-agent/connections/{{ holder_connection_id }} +GET {{ holder_url }}/agent/connections/{{ holder_connection_id }} [Options] retry: -1 HTTP 200 @@ -89,7 +89,7 @@ jsonpath "$.state" == "ConnectionResponseReceived" # Issuance ############################## # Issuer create credential offer -POST {{ issuer_url }}/prism-agent/issue-credentials/credential-offers +POST {{ issuer_url }}/agent/issue-credentials/credential-offers { "claims": { "emailAddress": "alice@wonderland.com", @@ -106,7 +106,7 @@ issuer_cred_record_id: jsonpath "$.recordId" didcomm_issuing_thid: jsonpath "$.thid" # Holder wait for OfferReceived state -GET {{ holder_url }}/prism-agent/issue-credentials/records +GET {{ holder_url }}/agent/issue-credentials/records [QueryStringParams] thid: {{ didcomm_issuing_thid }} [Options] @@ -118,14 +118,14 @@ jsonpath "$.contents[0].protocolState" == "OfferReceived" holder_cred_record_id: jsonpath "$.contents[0].recordId" # Holder accept a credential-offer -POST {{ holder_url }}/prism-agent/issue-credentials/records/{{ holder_cred_record_id }}/accept-offer +POST {{ holder_url }}/agent/issue-credentials/records/{{ holder_cred_record_id }}/accept-offer { "subjectId": "{{ holder_did }}" } HTTP 200 # Holder wait for CredentialReceived state -GET {{ holder_url }}/prism-agent/issue-credentials/records/{{ holder_cred_record_id }} +GET {{ holder_url }}/agent/issue-credentials/records/{{ holder_cred_record_id }} [Options] retry: -1 HTTP 200 @@ -133,7 +133,7 @@ HTTP 200 jsonpath "$.protocolState" == "CredentialReceived" # Issuer wait for CredentialSent state -GET {{ issuer_url }}/prism-agent/issue-credentials/records/{{ issuer_cred_record_id }} +GET {{ issuer_url }}/agent/issue-credentials/records/{{ issuer_cred_record_id }} [Options] retry: -1 HTTP 200 @@ -144,7 +144,7 @@ jsonpath "$.protocolState" == "CredentialSent" # Presentation Connection ############################## # Inviter create connection -POST {{ verifier_url }}/prism-agent/connections +POST {{ verifier_url }}/agent/connections { "label": "My Connection" } @@ -154,7 +154,7 @@ raw_invitation: jsonpath "$.invitation.invitationUrl" regex ".*_oob=(.*)$" verifier_connection_id: jsonpath "$.connectionId" # Invitee accept connection -POST {{ holder_url }}/prism-agent/connection-invitations +POST {{ holder_url }}/agent/connection-invitations { "invitation": "{{ raw_invitation }}" } @@ -163,7 +163,7 @@ HTTP 200 holder_connection_id: jsonpath "$.connectionId" # Wait for inviter connection status -GET {{ verifier_url }}/prism-agent/connections/{{ verifier_connection_id }} +GET {{ verifier_url }}/agent/connections/{{ verifier_connection_id }} [Options] retry: -1 HTTP 200 @@ -171,7 +171,7 @@ HTTP 200 jsonpath "$.state" == "ConnectionResponseSent" # Wait for invitee connection status -GET {{ holder_url }}/prism-agent/connections/{{ holder_connection_id }} +GET {{ holder_url }}/agent/connections/{{ holder_connection_id }} [Options] retry: -1 HTTP 200 @@ -182,7 +182,7 @@ jsonpath "$.state" == "ConnectionResponseReceived" # Presentation ############################## # Verifier create presentation request -POST {{ verifier_url }}/prism-agent/present-proof/presentations +POST {{ verifier_url }}/agent/present-proof/presentations { "connectionId": "{{ verifier_connection_id }}", "proofs":[], @@ -197,7 +197,7 @@ verifier_presentation_id: jsonpath "$.presentationId" didcomm_presentation_thid: jsonpath "$.thid" # Holder wait for RequestReceived state -GET {{ holder_url }}/prism-agent/present-proof/presentations +GET {{ holder_url }}/agent/present-proof/presentations [QueryStringParams] thid: {{ didcomm_presentation_thid }} [Options] @@ -209,7 +209,7 @@ jsonpath "$.contents[0].status" == "RequestReceived" holder_presentation_id: jsonpath "$.contents[0].presentationId" # Holder accept presentation request -PATCH {{ holder_url }}/prism-agent/present-proof/presentations/{{ holder_presentation_id }} +PATCH {{ holder_url }}/agent/present-proof/presentations/{{ holder_presentation_id }} { "action": "request-accept", "proofId": ["{{ holder_cred_record_id }}"] @@ -217,7 +217,7 @@ PATCH {{ holder_url }}/prism-agent/present-proof/presentations/{{ holder_present HTTP 200 # Holder wait for PresentationSent state -GET {{ holder_url }}/prism-agent/present-proof/presentations +GET {{ holder_url }}/agent/present-proof/presentations [QueryStringParams] thid: {{ didcomm_presentation_thid }} [Options] @@ -227,7 +227,7 @@ HTTP 200 jsonpath "$.contents[0].status" == "PresentationSent" # Verfiier wait for PresentationVerified state -GET {{ verifier_url }}/prism-agent/present-proof/presentations +GET {{ verifier_url }}/agent/present-proof/presentations [QueryStringParams] thid: {{ didcomm_presentation_thid }} [Options] diff --git a/examples/st-multi/tests/local b/examples/st-multi/hurl/local similarity index 100% rename from examples/st-multi/tests/local rename to examples/st-multi/hurl/local diff --git a/examples/st-oid4vci/compose.yaml b/examples/st-oid4vci/compose.yaml index 7d411ff244..5de842daa2 100644 --- a/examples/st-oid4vci/compose.yaml +++ b/examples/st-oid4vci/compose.yaml @@ -5,7 +5,7 @@ configs: handle_path /didcomm* { reverse_proxy agent-issuer:8090 } - handle_path /prism-agent* { + handle_path /agent* { reverse_proxy agent-issuer:8085 } handle_path /keycloak* { @@ -39,10 +39,10 @@ services: POLLUX_DB_PASSWORD: postgres POLLUX_DB_PORT: '5432' POLLUX_DB_USER: postgres - POLLUX_STATUS_LIST_REGISTRY_PUBLIC_URL: http://caddy-issuer:8080/prism-agent + POLLUX_STATUS_LIST_REGISTRY_PUBLIC_URL: http://caddy-issuer:8080/agent PRISM_NODE_HOST: node PRISM_NODE_PORT: '50053' - REST_SERVICE_URL: http://caddy-issuer:8080/prism-agent + REST_SERVICE_URL: http://caddy-issuer:8080/agent SECRET_STORAGE_BACKEND: postgres image: ghcr.io/hyperledger/identus-cloud-agent:1.37.0 restart: always diff --git a/examples/st-vault/README.md b/examples/st-vault/README.md new file mode 100644 index 0000000000..338d74d18b --- /dev/null +++ b/examples/st-vault/README.md @@ -0,0 +1,10 @@ +## Configuration + +| Exposed Service | Description | +|------------------------------|---------------------------| +| `localhost:8080/prism-agent` | Single-tenant Cloud Agent | +| `localhost:8200` | Vault | + +__Vault__ + +- Root token `admin` diff --git a/examples/st-vault/compose.yaml b/examples/st-vault/compose.yaml index 89cbcaaf0f..a903091b13 100644 --- a/examples/st-vault/compose.yaml +++ b/examples/st-vault/compose.yaml @@ -5,7 +5,7 @@ configs: handle_path /didcomm* { reverse_proxy agent-issuer:8090 } - handle_path /prism-agent* { + handle_path /agent* { reverse_proxy agent-issuer:8085 } handle_path /keycloak* { @@ -39,10 +39,10 @@ services: POLLUX_DB_PASSWORD: postgres POLLUX_DB_PORT: '5432' POLLUX_DB_USER: postgres - POLLUX_STATUS_LIST_REGISTRY_PUBLIC_URL: http://caddy-issuer:8080/prism-agent + POLLUX_STATUS_LIST_REGISTRY_PUBLIC_URL: http://caddy-issuer:8080/agent PRISM_NODE_HOST: node PRISM_NODE_PORT: '50053' - REST_SERVICE_URL: http://caddy-issuer:8080/prism-agent + REST_SERVICE_URL: http://caddy-issuer:8080/agent SECRET_STORAGE_BACKEND: vault VAULT_ADDR: http://vault-issuer:8200 VAULT_TOKEN: admin diff --git a/examples/st/README.md b/examples/st/README.md new file mode 100644 index 0000000000..703a0bc6ad --- /dev/null +++ b/examples/st/README.md @@ -0,0 +1,5 @@ +## Configuration + +| Exposed Service | Description | +|------------------------|---------------------------| +| `localhost:8080/agent` | Single-tenant Cloud Agent | diff --git a/examples/st/compose.yaml b/examples/st/compose.yaml index fe40fcd7bd..9efa65e481 100644 --- a/examples/st/compose.yaml +++ b/examples/st/compose.yaml @@ -5,7 +5,7 @@ configs: handle_path /didcomm* { reverse_proxy agent-issuer:8090 } - handle_path /prism-agent* { + handle_path /agent* { reverse_proxy agent-issuer:8085 } handle_path /keycloak* { @@ -39,10 +39,10 @@ services: POLLUX_DB_PASSWORD: postgres POLLUX_DB_PORT: '5432' POLLUX_DB_USER: postgres - POLLUX_STATUS_LIST_REGISTRY_PUBLIC_URL: http://caddy-issuer:8080/prism-agent + POLLUX_STATUS_LIST_REGISTRY_PUBLIC_URL: http://caddy-issuer:8080/agent PRISM_NODE_HOST: node PRISM_NODE_PORT: '50053' - REST_SERVICE_URL: http://caddy-issuer:8080/prism-agent + REST_SERVICE_URL: http://caddy-issuer:8080/agent SECRET_STORAGE_BACKEND: postgres image: ghcr.io/hyperledger/identus-cloud-agent:1.37.0 restart: always From c30e310eb0a0911e7d7681aa9bb58b2fb1fe3ce2 Mon Sep 17 00:00:00 2001 From: patlo-iog Date: Fri, 12 Jul 2024 18:04:56 +0700 Subject: [PATCH 09/13] build: make sure -Dquill.macro.log=false is passed when quill compiles (#1264) Signed-off-by: Pat Losoponkul --- .sbtopts | 1 + build.sbt | 1 - project/build.properties | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) create mode 100644 .sbtopts diff --git a/.sbtopts b/.sbtopts new file mode 100644 index 0000000000..2872000dd8 --- /dev/null +++ b/.sbtopts @@ -0,0 +1 @@ +-Dquill.macro.log=false diff --git a/build.sbt b/build.sbt index ddf3a5750c..48824dfe14 100644 --- a/build.sbt +++ b/build.sbt @@ -32,7 +32,6 @@ inThisBuild( "-feature", "-deprecation", "-unchecked", - "-Dquill.macro.log=false", // disable quill macro logs "-Wunused:all", "-Wconf:any:warning", // TODO: change unused imports to errors, Wconf configuration string is different from scala 2, figure out how! // TODO "-feature", diff --git a/project/build.properties b/project/build.properties index 081fdbbc76..ee4c672cd0 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1 +1 @@ -sbt.version=1.10.0 +sbt.version=1.10.1 From fafeee6c385586c28cdc04c3cd5aebe40734de6c Mon Sep 17 00:00:00 2001 From: Allain Magyar Date: Mon, 15 Jul 2024 08:52:16 -0300 Subject: [PATCH 10/13] test: add sd-jwt integration test (#1260) Signed-off-by: Allain Magyar Signed-off-by: Hyperledger Bot Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Hyperledger Bot --- .../test/kotlin/abilities/ListenToEvents.kt | 39 +++ .../src/test/kotlin/common/DidPurpose.kt | 25 +- .../src/test/kotlin/models/JwtCredential.kt | 3 +- .../src/test/kotlin/models/SdJwtClaim.kt | 7 + .../test/kotlin/steps/common/CommonSteps.kt | 67 +++-- .../steps/connection/ConnectionSteps.kt | 38 ++- .../kotlin/steps/credentials/AnoncredSteps.kt | 50 ++++ .../steps/credentials/CredentialSteps.kt | 79 ++++++ .../credentials/IssueCredentialsSteps.kt | 242 ------------------ .../steps/credentials/JwtCredentialSteps.kt | 110 ++++++++ .../credentials/RevokeCredentialSteps.kt | 34 ++- .../steps/credentials/SdJwtCredentialSteps.kt | 137 ++++++++++ .../kotlin/steps/did/DeactivateDidSteps.kt | 16 +- .../test/kotlin/steps/did/PublishDidSteps.kt | 37 +-- .../test/kotlin/steps/did/UpdateDidSteps.kt | 154 ++++++----- ...entProofSteps.kt => AnoncredProofSteps.kt} | 58 +---- .../kotlin/steps/proofs/HolderProofSteps.kt | 35 +++ .../test/kotlin/steps/proofs/JwtProofSteps.kt | 52 ++++ .../kotlin/steps/proofs/PresentProofSteps.kt | 123 --------- .../kotlin/steps/proofs/SdJwtProofSteps.kt | 99 +++++++ .../kotlin/steps/proofs/VerifierProofSteps.kt | 32 +++ .../anoncred/issuance.feature} | 6 +- .../anoncred/present_proof.feature} | 6 +- .../features/credential/jwt/issuance.feature | 41 +++ .../jwt}/present_proof.feature | 16 +- .../jwt/revocation.feature} | 16 +- .../credential/sdjwt/issuance.feature | 37 +++ .../credential/sdjwt/present_proof.feature | 35 +++ .../issue_jwt_with_published_did.feature | 34 --- .../issue_jwt_with_unpublished_did.feature | 14 - 30 files changed, 1014 insertions(+), 628 deletions(-) create mode 100644 tests/integration-tests/src/test/kotlin/models/SdJwtClaim.kt create mode 100644 tests/integration-tests/src/test/kotlin/steps/credentials/AnoncredSteps.kt create mode 100644 tests/integration-tests/src/test/kotlin/steps/credentials/CredentialSteps.kt delete mode 100644 tests/integration-tests/src/test/kotlin/steps/credentials/IssueCredentialsSteps.kt create mode 100644 tests/integration-tests/src/test/kotlin/steps/credentials/JwtCredentialSteps.kt create mode 100644 tests/integration-tests/src/test/kotlin/steps/credentials/SdJwtCredentialSteps.kt rename tests/integration-tests/src/test/kotlin/steps/proofs/{AnoncredsPresentProofSteps.kt => AnoncredProofSteps.kt} (61%) create mode 100644 tests/integration-tests/src/test/kotlin/steps/proofs/HolderProofSteps.kt create mode 100644 tests/integration-tests/src/test/kotlin/steps/proofs/JwtProofSteps.kt delete mode 100644 tests/integration-tests/src/test/kotlin/steps/proofs/PresentProofSteps.kt create mode 100644 tests/integration-tests/src/test/kotlin/steps/proofs/SdJwtProofSteps.kt create mode 100644 tests/integration-tests/src/test/kotlin/steps/proofs/VerifierProofSteps.kt rename tests/integration-tests/src/test/resources/features/{credentials/issue_anoncred_with_published_did.feature => credential/anoncred/issuance.feature} (78%) rename tests/integration-tests/src/test/resources/features/{proofs/present_proof_anoncred.feature => credential/anoncred/present_proof.feature} (86%) create mode 100644 tests/integration-tests/src/test/resources/features/credential/jwt/issuance.feature rename tests/integration-tests/src/test/resources/features/{proofs => credential/jwt}/present_proof.feature (60%) rename tests/integration-tests/src/test/resources/features/{revocation/revoke_jwt_credential.feature => credential/jwt/revocation.feature} (55%) create mode 100644 tests/integration-tests/src/test/resources/features/credential/sdjwt/issuance.feature create mode 100644 tests/integration-tests/src/test/resources/features/credential/sdjwt/present_proof.feature delete mode 100644 tests/integration-tests/src/test/resources/features/credentials/issue_jwt_with_published_did.feature delete mode 100644 tests/integration-tests/src/test/resources/features/credentials/issue_jwt_with_unpublished_did.feature diff --git a/tests/integration-tests/src/test/kotlin/abilities/ListenToEvents.kt b/tests/integration-tests/src/test/kotlin/abilities/ListenToEvents.kt index 8c721cce04..4ea1481a90 100644 --- a/tests/integration-tests/src/test/kotlin/abilities/ListenToEvents.kt +++ b/tests/integration-tests/src/test/kotlin/abilities/ListenToEvents.kt @@ -14,6 +14,9 @@ import models.* import net.serenitybdd.screenplay.Ability import net.serenitybdd.screenplay.Actor import net.serenitybdd.screenplay.HasTeardown +import net.serenitybdd.screenplay.Question +import org.hyperledger.identus.client.models.Connection +import org.hyperledger.identus.client.models.IssueCredentialRecord import java.net.URL import java.time.OffsetDateTime @@ -64,6 +67,42 @@ open class ListenToEvents( fun with(actor: Actor): ListenToEvents { return actor.abilityTo(ListenToEvents::class.java) } + + fun presentationProofStatus(actor: Actor): Question { + return Question.about("presentation status").answeredBy { + val proofEvent = with(actor).presentationEvents.lastOrNull { + it.data.thid == actor.recall("thid") + } + proofEvent?.data?.status + } + } + + fun connectionState(actor: Actor): Question { + return Question.about("connection state").answeredBy { + val lastEvent = with(actor).connectionEvents.lastOrNull { + it.data.thid == actor.recall("connection").thid + } + lastEvent?.data?.state + } + } + + fun credentialState(actor: Actor): Question { + return Question.about("credential state").answeredBy { + val credentialEvent = ListenToEvents.with(actor).credentialEvents.lastOrNull { + it.data.thid == actor.recall("thid") + } + credentialEvent?.data?.protocolState + } + } + + fun didStatus(actor: Actor): Question { + return Question.about("did status").answeredBy { + val didEvent = ListenToEvents.with(actor).didEvents.lastOrNull { + it.data.did == actor.recall("shortFormDid") + } + didEvent?.data?.status + } + } } init { diff --git a/tests/integration-tests/src/test/kotlin/common/DidPurpose.kt b/tests/integration-tests/src/test/kotlin/common/DidPurpose.kt index dee1243f98..7dcc6ec6e3 100644 --- a/tests/integration-tests/src/test/kotlin/common/DidPurpose.kt +++ b/tests/integration-tests/src/test/kotlin/common/DidPurpose.kt @@ -3,23 +3,30 @@ package common import org.hyperledger.identus.client.models.* enum class DidPurpose { - EMPTY { - override val publicKeys = emptyList() - override val services = emptyList() + CUSTOM { + override val publicKeys = mutableListOf() + override val services = mutableListOf() + }, + SD_JWT { + override val publicKeys = mutableListOf( + ManagedDIDKeyTemplate("auth-1", Purpose.AUTHENTICATION, Curve.ED25519), + ManagedDIDKeyTemplate("assertion-1", Purpose.ASSERTION_METHOD, Curve.ED25519), + ) + override val services = mutableListOf() }, JWT { - override val publicKeys = listOf( + override val publicKeys = mutableListOf( ManagedDIDKeyTemplate("auth-1", Purpose.AUTHENTICATION, Curve.SECP256K1), ManagedDIDKeyTemplate("auth-2", Purpose.AUTHENTICATION, Curve.ED25519), ManagedDIDKeyTemplate("assertion-1", Purpose.ASSERTION_METHOD, Curve.SECP256K1), ) - override val services = emptyList() + override val services = mutableListOf() }, ANONCRED { - override val publicKeys = emptyList() - override val services = emptyList() + override val publicKeys = mutableListOf() + override val services = mutableListOf() }, ; - abstract val publicKeys: List - abstract val services: List + abstract val publicKeys: MutableList + abstract val services: MutableList } diff --git a/tests/integration-tests/src/test/kotlin/models/JwtCredential.kt b/tests/integration-tests/src/test/kotlin/models/JwtCredential.kt index e4395e27e0..f1d3e71695 100644 --- a/tests/integration-tests/src/test/kotlin/models/JwtCredential.kt +++ b/tests/integration-tests/src/test/kotlin/models/JwtCredential.kt @@ -16,7 +16,6 @@ import java.io.Serializable import java.security.Provider import java.security.SecureRandom import java.time.OffsetDateTime -import java.util.Base64 import java.util.Date import kotlin.reflect.KClass @@ -146,7 +145,7 @@ class JwtCredential { } fun parseBase64(base64: String): JwtCredential { - val jwt = String(Base64.getDecoder().decode(base64)) + val jwt = Base64URL.from(base64).decodeToString() return parseJwt(jwt) } diff --git a/tests/integration-tests/src/test/kotlin/models/SdJwtClaim.kt b/tests/integration-tests/src/test/kotlin/models/SdJwtClaim.kt new file mode 100644 index 0000000000..0ce288c800 --- /dev/null +++ b/tests/integration-tests/src/test/kotlin/models/SdJwtClaim.kt @@ -0,0 +1,7 @@ +package models + +data class SdJwtClaim( + val salt: String, + val key: String, + val value: String, +) diff --git a/tests/integration-tests/src/test/kotlin/steps/common/CommonSteps.kt b/tests/integration-tests/src/test/kotlin/steps/common/CommonSteps.kt index bc885b15ee..d5c8428b13 100644 --- a/tests/integration-tests/src/test/kotlin/steps/common/CommonSteps.kt +++ b/tests/integration-tests/src/test/kotlin/steps/common/CommonSteps.kt @@ -12,29 +12,31 @@ import org.apache.http.HttpStatus import org.hyperledger.identus.client.models.Connection import org.hyperledger.identus.client.models.ConnectionsPage import steps.connection.ConnectionSteps -import steps.credentials.IssueCredentialsSteps +import steps.credentials.* import steps.did.PublishDidSteps import steps.schemas.CredentialSchemasSteps class CommonSteps { @Given("{actor} has a jwt issued credential from {actor}") - fun holderHasIssuedCredentialFromIssuer(holder: Actor, issuer: Actor) { + fun holderHasIssuedJwtCredentialFromIssuer(holder: Actor, issuer: Actor) { actorsHaveExistingConnection(issuer, holder) val publishDidSteps = PublishDidSteps() publishDidSteps.agentHasAnUnpublishedDID(holder, DidPurpose.JWT) publishDidSteps.agentHasAPublishedDID(issuer, DidPurpose.JWT) - val issueSteps = IssueCredentialsSteps() - issueSteps.issuerOffersACredential(issuer, holder, "short") - issueSteps.holderReceivesCredentialOffer(holder) - issueSteps.holderAcceptsCredentialOfferForJwt(holder) - issueSteps.acmeIssuesTheCredential(issuer) - issueSteps.bobHasTheCredentialIssued(holder) + val jwtCredentialSteps = JwtCredentialSteps() + val credentialSteps = CredentialSteps() + + jwtCredentialSteps.issuerOffersAJwtCredential(issuer, holder, "short") + credentialSteps.holderReceivesCredentialOffer(holder) + jwtCredentialSteps.holderAcceptsJwtCredentialOfferForJwt(holder) + credentialSteps.issuerIssuesTheCredential(issuer) + credentialSteps.holderReceivesTheIssuedCredential(holder) } @Given("{actor} has a jwt issued credential with {} schema from {actor}") - fun holderHasIssuedCredentialFromIssuerWithSchema( + fun holderHasIssuedJwtCredentialFromIssuerWithSchema( holder: Actor, schema: CredentialSchema, issuer: Actor, @@ -48,12 +50,47 @@ class CommonSteps { val schemaSteps = CredentialSchemasSteps() schemaSteps.agentHasAPublishedSchema(issuer, schema) - val issueSteps = IssueCredentialsSteps() - issueSteps.issuerOffersCredentialToHolderUsingSchema(issuer, holder, "short", schema) - issueSteps.holderReceivesCredentialOffer(holder) - issueSteps.holderAcceptsCredentialOfferForJwt(holder) - issueSteps.acmeIssuesTheCredential(issuer) - issueSteps.bobHasTheCredentialIssued(holder) + val jwtCredentialSteps = JwtCredentialSteps() + val credentialSteps = CredentialSteps() + jwtCredentialSteps.issuerOffersJwtCredentialToHolderUsingSchema(issuer, holder, "short", schema) + credentialSteps.holderReceivesCredentialOffer(holder) + jwtCredentialSteps.holderAcceptsJwtCredentialOfferForJwt(holder) + credentialSteps.issuerIssuesTheCredential(issuer) + credentialSteps.holderReceivesTheIssuedCredential(holder) + } + + @Given("{actor} has a sd-jwt issued credential from {actor}") + fun holderHasIssuedSdJwtCredentialFromIssuer(holder: Actor, issuer: Actor) { + actorsHaveExistingConnection(issuer, holder) + + val publishDidSteps = PublishDidSteps() + publishDidSteps.agentHasAnUnpublishedDID(holder, DidPurpose.SD_JWT) + publishDidSteps.agentHasAPublishedDID(issuer, DidPurpose.SD_JWT) + + val sdJwtCredentialSteps = SdJwtCredentialSteps() + val credentialSteps = CredentialSteps() + sdJwtCredentialSteps.issuerOffersSdJwtCredentialToHolder(issuer, holder) + credentialSteps.holderReceivesCredentialOffer(holder) + sdJwtCredentialSteps.holderAcceptsSdJwtCredentialOffer(holder) + credentialSteps.issuerIssuesTheCredential(issuer) + credentialSteps.holderReceivesTheIssuedCredential(holder) + } + + @Given("{actor} has a bound sd-jwt issued credential from {actor}") + fun holderHasIssuedSdJwtCredentialFromIssuerWithKeyBind(holder: Actor, issuer: Actor) { + actorsHaveExistingConnection(issuer, holder) + + val publishDidSteps = PublishDidSteps() + publishDidSteps.agentHasAnUnpublishedDID(holder, DidPurpose.SD_JWT) + publishDidSteps.agentHasAPublishedDID(issuer, DidPurpose.SD_JWT) + + val sdJwtCredentialSteps = SdJwtCredentialSteps() + val credentialSteps = CredentialSteps() + sdJwtCredentialSteps.issuerOffersSdJwtCredentialToHolder(issuer, holder) + credentialSteps.holderReceivesCredentialOffer(holder) + sdJwtCredentialSteps.holderAcceptsSdJwtCredentialOfferWithKeyBinding(holder, "auth-1") + credentialSteps.issuerIssuesTheCredential(issuer) + credentialSteps.holderReceivesTheIssuedCredential(holder) } @Given("{actor} and {actor} have an existing connection") diff --git a/tests/integration-tests/src/test/kotlin/steps/connection/ConnectionSteps.kt b/tests/integration-tests/src/test/kotlin/steps/connection/ConnectionSteps.kt index 886bb64b86..17df7e4bf3 100644 --- a/tests/integration-tests/src/test/kotlin/steps/connection/ConnectionSteps.kt +++ b/tests/integration-tests/src/test/kotlin/steps/connection/ConnectionSteps.kt @@ -8,13 +8,16 @@ import io.cucumber.java.en.Then import io.cucumber.java.en.When import io.iohk.atala.automation.extensions.get import io.iohk.atala.automation.serenity.ensure.Ensure -import io.iohk.atala.automation.utils.Wait +import io.iohk.atala.automation.serenity.interactions.PollingWait import net.serenitybdd.rest.SerenityRest import net.serenitybdd.screenplay.Actor import org.apache.http.HttpStatus.SC_CREATED import org.apache.http.HttpStatus.SC_OK import org.assertj.core.api.Assertions.assertThat +import org.hamcrest.CoreMatchers import org.hyperledger.identus.client.models.* +import org.hyperledger.identus.client.models.Connection.State.CONNECTION_RESPONSE_RECEIVED +import org.hyperledger.identus.client.models.Connection.State.CONNECTION_RESPONSE_SENT class ConnectionSteps { @@ -73,27 +76,22 @@ class ConnectionSteps { @When("{actor} receives the connection request and sends back the response") fun inviterReceivesTheConnectionRequest(inviter: Actor) { - Wait.until( - errorMessage = "Inviter connection didn't reach ${Connection.State.CONNECTION_RESPONSE_SENT} state", - ) { - val lastEvent = ListenToEvents.with(inviter).connectionEvents.lastOrNull { - it.data.thid == inviter.recall("connection").thid - } - lastEvent != null && lastEvent.data.state == Connection.State.CONNECTION_RESPONSE_SENT - } + inviter.attemptsTo( + PollingWait.until( + ListenToEvents.connectionState(inviter), + CoreMatchers.equalTo(CONNECTION_RESPONSE_SENT), + ), + ) } @When("{actor} receives the connection response") fun inviteeReceivesTheConnectionResponse(invitee: Actor) { - Wait.until( - errorMessage = "Invitee connection didn't reach ${Connection.State.CONNECTION_RESPONSE_RECEIVED} state.", - ) { - val lastEvent = ListenToEvents.with(invitee).connectionEvents.lastOrNull { - it.data.thid == invitee.recall("connection").thid - } - lastEvent != null && - lastEvent.data.state == Connection.State.CONNECTION_RESPONSE_RECEIVED - } + invitee.attemptsTo( + PollingWait.until( + ListenToEvents.connectionState(invitee), + CoreMatchers.equalTo(CONNECTION_RESPONSE_RECEIVED), + ), + ) } @Then("{actor} and {actor} have a connection") @@ -120,8 +118,8 @@ class ConnectionSteps { assertThat(inviter.recall("connection-with-${invitee.name}").theirDid) .isEqualTo(invitee.recall("connection-with-${inviter.name}").myDid) assertThat(inviter.recall("connection-with-${invitee.name}").state) - .isEqualTo(Connection.State.CONNECTION_RESPONSE_SENT) + .isEqualTo(CONNECTION_RESPONSE_SENT) assertThat(invitee.recall("connection-with-${inviter.name}").state) - .isEqualTo(Connection.State.CONNECTION_RESPONSE_RECEIVED) + .isEqualTo(CONNECTION_RESPONSE_RECEIVED) } } diff --git a/tests/integration-tests/src/test/kotlin/steps/credentials/AnoncredSteps.kt b/tests/integration-tests/src/test/kotlin/steps/credentials/AnoncredSteps.kt new file mode 100644 index 0000000000..2cddddb2b6 --- /dev/null +++ b/tests/integration-tests/src/test/kotlin/steps/credentials/AnoncredSteps.kt @@ -0,0 +1,50 @@ +package steps.credentials + +import interactions.Post +import interactions.body +import io.cucumber.java.en.When +import io.iohk.atala.automation.extensions.get +import io.iohk.atala.automation.serenity.ensure.Ensure +import net.serenitybdd.rest.SerenityRest +import net.serenitybdd.screenplay.Actor +import org.apache.http.HttpStatus.SC_CREATED +import org.apache.http.HttpStatus.SC_OK +import org.hyperledger.identus.client.models.* + +class AnoncredSteps { + + @When("{actor} accepts anoncred credential offer") + fun holderAcceptsCredentialOfferForAnoncred(holder: Actor) { + val recordId = holder.recall("recordId") + holder.attemptsTo( + Post.to("/issue-credentials/records/$recordId/accept-offer").body("{}"), + Ensure.thatTheLastResponse().statusCode().isEqualTo(SC_OK), + ) + } + + @When("{actor} offers anoncred to {actor}") + fun acmeOffersAnoncredToBob(issuer: Actor, holder: Actor) { + val credentialOfferRequest = CreateIssueCredentialRecordRequest( + credentialDefinitionId = issuer.recall("anoncredsCredentialDefinition").guid, + claims = linkedMapOf( + "name" to "Bob", + "age" to "21", + "sex" to "M", + ), + issuingDID = issuer.recall("shortFormDid"), + connectionId = issuer.recall("connection-with-${holder.name}").connectionId, + validityPeriod = 3600.0, + credentialFormat = "AnonCreds", + automaticIssuance = false, + ) + + issuer.attemptsTo( + Post.to("/issue-credentials/credential-offers").body(credentialOfferRequest), + Ensure.thatTheLastResponse().statusCode().isEqualTo(SC_CREATED), + ) + + val credentialRecord = SerenityRest.lastResponse().get() + issuer.remember("thid", credentialRecord.thid) + holder.remember("thid", credentialRecord.thid) + } +} diff --git a/tests/integration-tests/src/test/kotlin/steps/credentials/CredentialSteps.kt b/tests/integration-tests/src/test/kotlin/steps/credentials/CredentialSteps.kt new file mode 100644 index 0000000000..7c2251ec83 --- /dev/null +++ b/tests/integration-tests/src/test/kotlin/steps/credentials/CredentialSteps.kt @@ -0,0 +1,79 @@ +package steps.credentials + +import abilities.ListenToEvents +import interactions.Post +import io.cucumber.java.en.Then +import io.cucumber.java.en.When +import io.iohk.atala.automation.serenity.ensure.Ensure +import io.iohk.atala.automation.serenity.interactions.PollingWait +import net.serenitybdd.screenplay.Actor +import org.apache.http.HttpStatus.* +import org.hamcrest.CoreMatchers.equalTo +import org.hyperledger.identus.client.models.* +import org.hyperledger.identus.client.models.IssueCredentialRecord.ProtocolState.* + +class CredentialSteps { + + @When("{actor} receives the credential offer") + fun holderReceivesCredentialOffer(holder: Actor) { + holder.attemptsTo( + PollingWait.until( + ListenToEvents.credentialState(holder), + equalTo(OFFER_RECEIVED), + ), + ) + val recordId = ListenToEvents.with(holder).credentialEvents.last().data.recordId + holder.remember("recordId", recordId) + } + + @When("{actor} tries to issue the credential") + fun issuerTriesToIssueTheCredential(issuer: Actor) { + issuer.attemptsTo( + PollingWait.until( + ListenToEvents.credentialState(issuer), + equalTo(REQUEST_RECEIVED), + ), + ) + + val recordId = ListenToEvents.with(issuer).credentialEvents.last().data.recordId + + issuer.attemptsTo( + Post.to("/issue-credentials/records/$recordId/issue-credential"), + ) + } + + @When("{actor} issues the credential") + fun issuerIssuesTheCredential(issuer: Actor) { + issuerTriesToIssueTheCredential(issuer) + + issuer.attemptsTo( + Ensure.thatTheLastResponse().statusCode().isEqualTo(SC_OK), + ) + + issuer.attemptsTo( + PollingWait.until( + ListenToEvents.credentialState(issuer), + equalTo(CREDENTIAL_SENT), + ), + ) + issuer.remember("issuedCredential", ListenToEvents.with(issuer).credentialEvents.last().data) + } + + @Then("{actor} receives the issued credential") + fun holderReceivesTheIssuedCredential(holder: Actor) { + holder.attemptsTo( + PollingWait.until( + ListenToEvents.credentialState(holder), + equalTo(CREDENTIAL_RECEIVED), + ), + ) + holder.remember("issuedCredential", ListenToEvents.with(holder).credentialEvents.last().data) + } + + @Then("{actor} should see that credential issuance has failed") + fun issuerShouldSeeThatCredentialIssuanceHasFailed(issuer: Actor) { + issuer.attemptsTo( + Ensure.thatTheLastResponse().statusCode().isEqualTo(SC_UNPROCESSABLE_ENTITY), + ) + } +} diff --git a/tests/integration-tests/src/test/kotlin/steps/credentials/IssueCredentialsSteps.kt b/tests/integration-tests/src/test/kotlin/steps/credentials/IssueCredentialsSteps.kt deleted file mode 100644 index 1688e3a585..0000000000 --- a/tests/integration-tests/src/test/kotlin/steps/credentials/IssueCredentialsSteps.kt +++ /dev/null @@ -1,242 +0,0 @@ -package steps.credentials - -import abilities.ListenToEvents -import common.CredentialSchema -import interactions.Post -import interactions.body -import io.cucumber.java.en.Then -import io.cucumber.java.en.When -import io.iohk.atala.automation.extensions.get -import io.iohk.atala.automation.serenity.ensure.Ensure -import io.iohk.atala.automation.utils.Wait -import models.CredentialEvent -import net.serenitybdd.rest.SerenityRest -import net.serenitybdd.screenplay.Actor -import org.apache.http.HttpStatus.* -import org.hyperledger.identus.client.models.* -import kotlin.time.Duration.Companion.seconds - -class IssueCredentialsSteps { - - private var credentialEvent: CredentialEvent? = null - - private fun sendCredentialOffer( - issuer: Actor, - holder: Actor, - didForm: String, - schemaGuid: String?, - claims: Map, - ) { - val did: String = if (didForm == "short") { - issuer.recall("shortFormDid") - } else { - issuer.recall("longFormDid") - } - - val schemaId: String? = if (schemaGuid != null) { - val baseUrl = issuer.recall("baseUrl") - "$baseUrl/schema-registry/schemas/$schemaGuid" - } else { - null - } - - val credentialOfferRequest = CreateIssueCredentialRecordRequest( - schemaId = schemaId, - claims = claims, - issuingDID = did, - connectionId = issuer.recall("connection-with-${holder.name}").connectionId, - validityPeriod = 3600.0, - credentialFormat = "JWT", - automaticIssuance = false, - ) - - issuer.attemptsTo( - Post.to("/issue-credentials/credential-offers").body(credentialOfferRequest), - ) - } - - private fun saveCredentialOffer(issuer: Actor, holder: Actor) { - val credentialRecord = SerenityRest.lastResponse().get() - - issuer.attemptsTo( - Ensure.thatTheLastResponse().statusCode().isEqualTo(SC_CREATED), - ) - - issuer.remember("thid", credentialRecord.thid) - holder.remember("thid", credentialRecord.thid) - } - - @When("{actor} offers a credential to {actor} with {string} form DID") - fun issuerOffersACredential(issuer: Actor, holder: Actor, format: String) { - val claims = linkedMapOf( - "firstName" to "FirstName", - "lastName" to "LastName", - ) - sendCredentialOffer(issuer, holder, format, null, claims) - saveCredentialOffer(issuer, holder) - } - - @When("{actor} offers a credential to {actor} with {} form using {} schema") - fun issuerOffersCredentialToHolderUsingSchema( - issuer: Actor, - holder: Actor, - format: String, - schema: CredentialSchema, - ) { - val schemaGuid = issuer.recall(schema.name) - val claims = schema.claims - sendCredentialOffer(issuer, holder, format, schemaGuid, claims) - saveCredentialOffer(issuer, holder) - } - - @When("{actor} offers a credential to {actor} with {} form DID with wrong claims structure using {} schema") - fun issuerOffersCredentialToHolderWithWrongClaimStructure( - issuer: Actor, - holder: Actor, - format: String, - schema: CredentialSchema, - ) { - val schemaGuid = issuer.recall(schema.name)!! - val claims = linkedMapOf( - "name" to "Name", - "surname" to "Surname", - ) - sendCredentialOffer(issuer, holder, format, schemaGuid, claims) - } - - @When("{actor} offers anoncred to {actor}") - fun acmeOffersAnoncredToBob(issuer: Actor, holder: Actor) { - val credentialOfferRequest = CreateIssueCredentialRecordRequest( - credentialDefinitionId = issuer.recall("anoncredsCredentialDefinition").guid, - claims = linkedMapOf( - "name" to "Bob", - "age" to "21", - "sex" to "M", - ), - issuingDID = issuer.recall("shortFormDid"), - connectionId = issuer.recall("connection-with-${holder.name}").connectionId, - validityPeriod = 3600.0, - credentialFormat = "AnonCreds", - automaticIssuance = false, - ) - - issuer.attemptsTo( - Post.to("/issue-credentials/credential-offers") - .with { - it.body(credentialOfferRequest) - }, - ) - - val credentialRecord = SerenityRest.lastResponse().get() - - issuer.attemptsTo( - Ensure.thatTheLastResponse().statusCode().isEqualTo(SC_CREATED), - ) - - issuer.remember("thid", credentialRecord.thid) - holder.remember("thid", credentialRecord.thid) - } - - @When("{actor} receives the credential offer") - fun holderReceivesCredentialOffer(holder: Actor) { - Wait.until( - errorMessage = "Holder was unable to receive the credential offer from Issuer! " + - "Protocol state did not achieve ${IssueCredentialRecord.ProtocolState.OFFER_RECEIVED} state.", - ) { - credentialEvent = ListenToEvents.with(holder).credentialEvents.lastOrNull { - it.data.thid == holder.recall("thid") - } - credentialEvent != null && - credentialEvent!!.data.protocolState == IssueCredentialRecord.ProtocolState.OFFER_RECEIVED - } - - val recordId = ListenToEvents.with(holder).credentialEvents.last().data.recordId - holder.remember("recordId", recordId) - } - - @When("{actor} accepts credential offer for JWT") - fun holderAcceptsCredentialOfferForJwt(holder: Actor) { - holder.attemptsTo( - Post.to("/issue-credentials/records/${holder.recall("recordId")}/accept-offer") - .with { - it.body( - AcceptCredentialOfferRequest(holder.recall("longFormDid")), - ) - }, - ) - holder.attemptsTo( - Ensure.thatTheLastResponse().statusCode().isEqualTo(SC_OK), - ) - } - - @When("{actor} accepts credential offer for anoncred") - fun holderAcceptsCredentialOfferForAnoncred(holder: Actor) { - holder.attemptsTo( - Post.to("/issue-credentials/records/${holder.recall("recordId")}/accept-offer") - .with { - it.body( - "{}", - ) - }, - ) - holder.attemptsTo( - Ensure.thatTheLastResponse().statusCode().isEqualTo(SC_OK), - ) - } - - @When("{actor} issues the credential") - fun acmeIssuesTheCredential(issuer: Actor) { - Wait.until( - errorMessage = "Issuer was unable to receive the credential request from Holder! Protocol state did not achieve RequestReceived state.", - ) { - credentialEvent = ListenToEvents.with(issuer).credentialEvents.lastOrNull { - it.data.thid == issuer.recall("thid") - } - credentialEvent != null && - credentialEvent!!.data.protocolState == IssueCredentialRecord.ProtocolState.REQUEST_RECEIVED - } - val recordId = credentialEvent!!.data.recordId - issuer.attemptsTo( - Post.to("/issue-credentials/records/$recordId/issue-credential"), - ) - issuer.attemptsTo( - Ensure.thatTheLastResponse().statusCode().isEqualTo(SC_OK), - ) - - Wait.until( - 10.seconds, - errorMessage = "Issuer was unable to issue the credential! " + - "Protocol state did not achieve ${IssueCredentialRecord.ProtocolState.CREDENTIAL_SENT} state.", - ) { - credentialEvent = ListenToEvents.with(issuer).credentialEvents.lastOrNull { - it.data.thid == issuer.recall("thid") - } - issuer.remember("issuedCredential", credentialEvent!!.data) - - credentialEvent != null && - credentialEvent!!.data.protocolState == IssueCredentialRecord.ProtocolState.CREDENTIAL_SENT - } - } - - @Then("{actor} receives the issued credential") - fun bobHasTheCredentialIssued(holder: Actor) { - Wait.until( - errorMessage = "Holder was unable to receive the credential from Issuer! " + - "Protocol state did not achieve ${IssueCredentialRecord.ProtocolState.CREDENTIAL_RECEIVED} state.", - ) { - credentialEvent = ListenToEvents.with(holder).credentialEvents.lastOrNull { - it.data.thid == holder.recall("thid") - } - credentialEvent != null && - credentialEvent!!.data.protocolState == IssueCredentialRecord.ProtocolState.CREDENTIAL_RECEIVED - } - holder.remember("issuedCredential", ListenToEvents.with(holder).credentialEvents.last().data) - } - - @Then("{actor} should see that credential issuance has failed") - fun issuerShouldSeeThatCredentialIssuanceHasFailed(issuer: Actor) { - issuer.attemptsTo( - Ensure.thatTheLastResponse().statusCode().isEqualTo(SC_UNPROCESSABLE_ENTITY), - ) - } -} diff --git a/tests/integration-tests/src/test/kotlin/steps/credentials/JwtCredentialSteps.kt b/tests/integration-tests/src/test/kotlin/steps/credentials/JwtCredentialSteps.kt new file mode 100644 index 0000000000..b5026a7163 --- /dev/null +++ b/tests/integration-tests/src/test/kotlin/steps/credentials/JwtCredentialSteps.kt @@ -0,0 +1,110 @@ +package steps.credentials + +import common.CredentialSchema +import interactions.Post +import interactions.body +import io.cucumber.java.en.When +import io.iohk.atala.automation.extensions.get +import io.iohk.atala.automation.serenity.ensure.Ensure +import net.serenitybdd.rest.SerenityRest +import net.serenitybdd.screenplay.Actor +import org.apache.http.HttpStatus.SC_CREATED +import org.apache.http.HttpStatus.SC_OK +import org.hyperledger.identus.client.models.* + +class JwtCredentialSteps { + + private fun sendCredentialOffer( + issuer: Actor, + holder: Actor, + didForm: String, + schemaGuid: String?, + claims: Map, + ) { + val did: String = if (didForm == "short") { + issuer.recall("shortFormDid") + } else { + issuer.recall("longFormDid") + } + + val schemaId: String? = if (schemaGuid != null) { + val baseUrl = issuer.recall("baseUrl") + "$baseUrl/schema-registry/schemas/$schemaGuid" + } else { + null + } + + val credentialOfferRequest = CreateIssueCredentialRecordRequest( + schemaId = schemaId, + claims = claims, + issuingDID = did, + connectionId = issuer.recall("connection-with-${holder.name}").connectionId, + validityPeriod = 3600.0, + credentialFormat = "JWT", + automaticIssuance = false, + ) + + issuer.attemptsTo( + Post.to("/issue-credentials/credential-offers").body(credentialOfferRequest), + ) + } + + private fun saveCredentialOffer(issuer: Actor, holder: Actor) { + val credentialRecord = SerenityRest.lastResponse().get() + + issuer.attemptsTo( + Ensure.thatTheLastResponse().statusCode().isEqualTo(SC_CREATED), + ) + + issuer.remember("thid", credentialRecord.thid) + holder.remember("thid", credentialRecord.thid) + } + + @When("{actor} offers a jwt credential to {actor} with {string} form DID") + fun issuerOffersAJwtCredential(issuer: Actor, holder: Actor, format: String) { + val claims = linkedMapOf( + "firstName" to "FirstName", + "lastName" to "LastName", + ) + sendCredentialOffer(issuer, holder, format, null, claims) + saveCredentialOffer(issuer, holder) + } + + @When("{actor} offers a jwt credential to {actor} with {} form using {} schema") + fun issuerOffersJwtCredentialToHolderUsingSchema( + issuer: Actor, + holder: Actor, + format: String, + schema: CredentialSchema, + ) { + val schemaGuid = issuer.recall(schema.name) + val claims = schema.claims + sendCredentialOffer(issuer, holder, format, schemaGuid, claims) + saveCredentialOffer(issuer, holder) + } + + @When("{actor} offers a jwt credential to {actor} with {} form DID with wrong claims structure using {} schema") + fun issuerOffersJwtCredentialToHolderWithWrongClaimStructure( + issuer: Actor, + holder: Actor, + format: String, + schema: CredentialSchema, + ) { + val schemaGuid = issuer.recall(schema.name)!! + val claims = linkedMapOf( + "name" to "Name", + "surname" to "Surname", + ) + sendCredentialOffer(issuer, holder, format, schemaGuid, claims) + } + + @When("{actor} accepts jwt credential offer") + fun holderAcceptsJwtCredentialOfferForJwt(holder: Actor) { + val recordId = holder.recall("recordId") + holder.attemptsTo( + Post.to("/issue-credentials/records/$recordId/accept-offer") + .body(AcceptCredentialOfferRequest(holder.recall("longFormDid"))), + Ensure.thatTheLastResponse().statusCode().isEqualTo(SC_OK), + ) + } +} diff --git a/tests/integration-tests/src/test/kotlin/steps/credentials/RevokeCredentialSteps.kt b/tests/integration-tests/src/test/kotlin/steps/credentials/RevokeCredentialSteps.kt index ab8d16d289..3e3d539346 100644 --- a/tests/integration-tests/src/test/kotlin/steps/credentials/RevokeCredentialSteps.kt +++ b/tests/integration-tests/src/test/kotlin/steps/credentials/RevokeCredentialSteps.kt @@ -6,15 +6,19 @@ import io.cucumber.java.en.When import io.iohk.atala.automation.extensions.get import io.iohk.atala.automation.extensions.toJsonPath import io.iohk.atala.automation.serenity.ensure.Ensure -import io.iohk.atala.automation.utils.Wait +import io.iohk.atala.automation.serenity.interactions.PollingWait import models.JwtCredential import net.serenitybdd.rest.SerenityRest import net.serenitybdd.screenplay.Actor +import net.serenitybdd.screenplay.Question import org.apache.http.HttpStatus +import org.hamcrest.CoreMatchers.equalTo import org.hyperledger.identus.client.models.IssueCredentialRecord -import kotlin.time.Duration.Companion.seconds +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.minutes class RevokeCredentialSteps { + @When("{actor} revokes the credential issued to {actor}") fun issuerRevokesCredentialsIssuedToHolder(issuer: Actor, holder: Actor) { val issuedCredential = issuer.recall("issuedCredential") @@ -50,18 +54,20 @@ class RevokeCredentialSteps { @Then("{actor} should see the credential was revoked") fun credentialShouldBeRevoked(issuer: Actor) { - Wait.until( - timeout = 60.seconds, - errorMessage = "Encoded Status List didn't change after revoking.", - ) { - val statusListId: String = issuer.recall("statusListId") - val encodedStatusList: String = issuer.recall("encodedStatusList") - issuer.attemptsTo( - Get.resource("/credential-status/$statusListId"), - ) - val actualEncodedList: String = SerenityRest.lastResponse().jsonPath().get("credentialSubject.encodedList") - actualEncodedList != encodedStatusList - } + issuer.attemptsTo( + PollingWait.with(1.minutes, 500.milliseconds).until( + Question.about("revocation status list").answeredBy { + val statusListId: String = issuer.recall("statusListId") + val encodedStatusList: String = issuer.recall("encodedStatusList") + issuer.attemptsTo( + Get.resource("/credential-status/$statusListId"), + ) + val actualEncodedList: String = SerenityRest.lastResponse().jsonPath().get("credentialSubject.encodedList") + actualEncodedList != encodedStatusList + }, + equalTo(true), + ), + ) } @Then("{actor} should see the credential is not revoked") diff --git a/tests/integration-tests/src/test/kotlin/steps/credentials/SdJwtCredentialSteps.kt b/tests/integration-tests/src/test/kotlin/steps/credentials/SdJwtCredentialSteps.kt new file mode 100644 index 0000000000..5bdf8bcf48 --- /dev/null +++ b/tests/integration-tests/src/test/kotlin/steps/credentials/SdJwtCredentialSteps.kt @@ -0,0 +1,137 @@ +package steps.credentials + +import com.google.gson.Gson +import com.nimbusds.jose.util.Base64URL +import interactions.Post +import interactions.body +import io.cucumber.java.en.Then +import io.cucumber.java.en.When +import io.iohk.atala.automation.extensions.get +import io.iohk.atala.automation.serenity.ensure.Ensure +import models.JwtCredential +import models.SdJwtClaim +import net.serenitybdd.rest.SerenityRest +import net.serenitybdd.screenplay.Actor +import org.apache.http.HttpStatus.* +import org.hyperledger.identus.client.models.* + +class SdJwtCredentialSteps { + + private val claims = linkedMapOf( + "firstName" to "Automation", + "lastName" to "Execution", + ) + + @When("{actor} offers a sd-jwt credential to {actor}") + fun issuerOffersSdJwtCredentialToHolder(issuer: Actor, holder: Actor) { + val connectionId = issuer.recall("connection-with-${holder.name}").connectionId + val did = issuer.recall("shortFormDid") + + val credentialOfferRequest = CreateIssueCredentialRecordRequest( + claims = claims, + issuingDID = did, + connectionId = connectionId, + validityPeriod = 3600.0, + credentialFormat = "SDJWT", + automaticIssuance = false, + ) + + issuer.attemptsTo( + Post.to("/issue-credentials/credential-offers").body(credentialOfferRequest), + Ensure.thatTheLastResponse().statusCode().isEqualTo(SC_CREATED), + ) + + val credentialRecord = SerenityRest.lastResponse().get() + issuer.remember("thid", credentialRecord.thid) + holder.remember("thid", credentialRecord.thid) + } + + @When("{actor} accepts credential offer for sd-jwt") + fun holderAcceptsSdJwtCredentialOffer(holder: Actor) { + holderAcceptsSdJwtCredentialOfferWithKeyBinding(holder, null) + } + + @When("{actor} accepts credential offer for sd-jwt with '{}' key binding") + fun holderAcceptsSdJwtCredentialOfferWithKeyBinding(holder: Actor, key: String?) { + val recordId = holder.recall("recordId") + val did = holder.recall("longFormDid") + val request = AcceptCredentialOfferRequest(subjectId = did, keyId = key) + holder.attemptsTo( + Post.to("/issue-credentials/records/$recordId/accept-offer").body(request), + Ensure.thatTheLastResponse().statusCode().isEqualTo(SC_OK), + ) + } + + @When("{actor} tries to offer a sd-jwt credential to {actor}") + fun issuerTriesToOfferSdJwtCredentialToHolder(issuer: Actor, holder: Actor) { + val connectionId = issuer.recall("connection-with-${holder.name}").connectionId + val did = issuer.recall("shortFormDid") + + val credentialOfferRequest = CreateIssueCredentialRecordRequest( + claims = claims, + issuingDID = did, + connectionId = connectionId, + validityPeriod = 3600.0, + credentialFormat = "SDJWT", + automaticIssuance = false, + ) + + issuer.attemptsTo( + Post.to("/issue-credentials/credential-offers").body(credentialOfferRequest), + ) + } + + @Then("{actor} checks the sd-jwt credential contents") + fun holderChecksTheSdJwtCredentialContents(holder: Actor) { + commonValidation(holder) { payload, _ -> + holder.attemptsTo( + Ensure.that(payload.containsKey("cnf")).isFalse(), + ) + } + } + + @Then("{actor} checks the sd-jwt credential contents with holder binding") + fun holderChecksTheSdJwtCredentialContentsWithHolderBinding(holder: Actor) { + commonValidation(holder) { payload, _ -> + holder.attemptsTo( + Ensure.that(payload.containsKey("cnf")).isTrue(), + ) + } + } + + @Then("{actor} should see the issuance has failed") + fun issuerShouldSeeTheIssuanceHasFailed(issuer: Actor) { + issuer.attemptsTo( + Ensure.thatTheLastResponse().statusCode().isEqualTo(SC_EXPECTATION_FAILED), + ) + } + + private fun commonValidation( + holder: Actor, + additionalChecks: (payload: Map, disclosedClaims: Map) -> Unit, + ) { + val issuedCredential = holder.recall("issuedCredential") + val jwtCredential = JwtCredential.parseBase64(issuedCredential.credential!!) + + val payload = jwtCredential.payload!!.toJSONObject() + + val disclosedClaims = jwtCredential.signature!!.toString().split("~") + .drop(1) + .dropLastWhile { it.isBlank() } + .map { Base64URL.from(it).decodeToString() } + .map { Gson().fromJson(it, Array::class.java) } + .associate { it[1] to SdJwtClaim(salt = it[0], key = it[1], value = it[2]) } + + holder.attemptsTo( + Ensure.that(payload.containsKey("_sd")).isTrue(), + Ensure.that(payload.containsKey("_sd_alg")).isTrue(), + Ensure.that(payload.containsKey("iss")).isTrue(), + Ensure.that(payload.containsKey("iat")).isTrue(), + Ensure.that(payload.containsKey("exp")).isTrue(), + Ensure.that(disclosedClaims["firstName"]!!.value).isEqualTo(claims["firstName"]!!), + Ensure.that(disclosedClaims["lastName"]!!.value).isEqualTo(claims["lastName"]!!), + ) + + additionalChecks(payload, disclosedClaims) + } +} diff --git a/tests/integration-tests/src/test/kotlin/steps/did/DeactivateDidSteps.kt b/tests/integration-tests/src/test/kotlin/steps/did/DeactivateDidSteps.kt index 52a46869f5..5a4ab0c385 100644 --- a/tests/integration-tests/src/test/kotlin/steps/did/DeactivateDidSteps.kt +++ b/tests/integration-tests/src/test/kotlin/steps/did/DeactivateDidSteps.kt @@ -1,17 +1,17 @@ package steps.did -import interactions.Get import interactions.Post import io.cucumber.java.en.Then import io.cucumber.java.en.When import io.iohk.atala.automation.extensions.get +import io.iohk.atala.automation.matchers.RestAssuredJsonProperty import io.iohk.atala.automation.serenity.ensure.Ensure -import io.iohk.atala.automation.utils.Wait +import io.iohk.atala.automation.serenity.interactions.PollingWait +import io.iohk.atala.automation.serenity.questions.HttpRequest import net.serenitybdd.rest.SerenityRest import net.serenitybdd.screenplay.Actor import org.apache.http.HttpStatus import org.hyperledger.identus.client.models.DIDOperationResponse -import org.hyperledger.identus.client.models.DIDResolutionResult class DeactivateDidSteps { @@ -37,9 +37,11 @@ class DeactivateDidSteps { @Then("{actor} sees that PRISM DID is successfully deactivated") fun actorSeesThatPrismDidIsSuccessfullyDeactivated(actor: Actor) { val deactivatedDid = actor.recall("deactivatedDid") - Wait.until(errorMessage = "ERROR: DID deactivate operation did not succeed on the ledger!") { - actor.attemptsTo(Get.resource("/dids/$deactivatedDid")) - SerenityRest.lastResponse().get().didDocumentMetadata.deactivated!! - } + actor.attemptsTo( + PollingWait.until( + HttpRequest.get("/dids/$deactivatedDid"), + RestAssuredJsonProperty.toBe("didDocumentMetadata.deactivated", "true"), + ), + ) } } diff --git a/tests/integration-tests/src/test/kotlin/steps/did/PublishDidSteps.kt b/tests/integration-tests/src/test/kotlin/steps/did/PublishDidSteps.kt index cd4302d194..d7c3addafb 100644 --- a/tests/integration-tests/src/test/kotlin/steps/did/PublishDidSteps.kt +++ b/tests/integration-tests/src/test/kotlin/steps/did/PublishDidSteps.kt @@ -8,14 +8,14 @@ import interactions.body import io.cucumber.java.en.* import io.iohk.atala.automation.extensions.get import io.iohk.atala.automation.serenity.ensure.Ensure -import io.iohk.atala.automation.utils.Wait +import io.iohk.atala.automation.serenity.interactions.PollingWait import net.serenitybdd.rest.SerenityRest import net.serenitybdd.screenplay.Actor import org.apache.http.HttpStatus import org.apache.http.HttpStatus.SC_CREATED import org.apache.http.HttpStatus.SC_OK +import org.hamcrest.CoreMatchers.equalTo import org.hyperledger.identus.client.models.* -import kotlin.time.Duration.Companion.seconds class PublishDidSteps { @@ -46,7 +46,7 @@ class PublishDidSteps { @Given("{actor} creates unpublished DID") fun agentCreatesEmptyUnpublishedDid(actor: Actor) { - agentCreatesUnpublishedDid(actor, DidPurpose.EMPTY) + agentCreatesUnpublishedDid(actor, DidPurpose.CUSTOM) } @Given("{actor} creates unpublished DID for {}") @@ -75,6 +75,24 @@ class PublishDidSteps { actor.forget("hasPublishedDid") } + @When("{actor} prepares a custom PRISM DID") + fun actorPreparesCustomDid(actor: Actor) { + val customDid = DidPurpose.CUSTOM + actor.remember("customDid", customDid) + } + + @When("{actor} adds a '{curve}' key for '{purpose}' purpose with '{}' name to the custom PRISM DID") + fun actorAddsKeyToCustomDid(actor: Actor, curve: Curve, purpose: Purpose, name: String) { + val customDid = actor.recall("customDid") + customDid.publicKeys.add(ManagedDIDKeyTemplate(name, purpose, curve)) + } + + @When("{actor} creates the custom PRISM DID") + fun actorCreatesTheCustomPrismDid(actor: Actor) { + val customDid = actor.recall("customDid") + agentCreatesUnpublishedDid(actor, customDid) + } + @When("{actor} publishes DID to ledger") fun hePublishesDidToLedger(actor: Actor) { val shortFormDid = actor.recall("shortFormDid") @@ -87,18 +105,7 @@ class PublishDidSteps { Ensure.thatTheLastResponse().statusCode().isEqualTo(HttpStatus.SC_ACCEPTED), Ensure.that(didOperationResponse.scheduledOperation.didRef).isNotEmpty(), Ensure.that(didOperationResponse.scheduledOperation.id).isNotEmpty(), - ) - - Wait.until( - timeout = 30.seconds, - errorMessage = "ERROR: DID was not published to ledger!", - ) { - val didEvent = ListenToEvents.with(actor).didEvents.lastOrNull { - it.data.did == actor.recall("shortFormDid") - } - didEvent != null && didEvent.data.status == "PUBLISHED" - } - actor.attemptsTo( + PollingWait.until(ListenToEvents.didStatus(actor), equalTo("PUBLISHED")), Get.resource("/dids/${actor.recall("shortFormDid")}"), ) diff --git a/tests/integration-tests/src/test/kotlin/steps/did/UpdateDidSteps.kt b/tests/integration-tests/src/test/kotlin/steps/did/UpdateDidSteps.kt index b2a73bf072..e9e590d319 100644 --- a/tests/integration-tests/src/test/kotlin/steps/did/UpdateDidSteps.kt +++ b/tests/integration-tests/src/test/kotlin/steps/did/UpdateDidSteps.kt @@ -7,10 +7,12 @@ import io.cucumber.java.en.Then import io.cucumber.java.en.When import io.iohk.atala.automation.extensions.get import io.iohk.atala.automation.serenity.ensure.Ensure -import io.iohk.atala.automation.utils.Wait +import io.iohk.atala.automation.serenity.interactions.PollingWait import net.serenitybdd.rest.SerenityRest import net.serenitybdd.screenplay.Actor +import net.serenitybdd.screenplay.Question import org.apache.http.HttpStatus +import org.hamcrest.CoreMatchers.equalTo import org.hyperledger.identus.client.models.* import java.util.UUID @@ -87,91 +89,111 @@ class UpdateDidSteps { fun actorSeesDidSuccessfullyUpdatedWithNewKeys(actor: Actor, purpose: Purpose) { val newDidKeyId = actor.recall("newDidKeyId") - Wait.until(errorMessage = "ERROR: DID UPDATE operation did not succeed on the ledger!") { - actor.attemptsTo( - Get.resource("/dids/${actor.recall("shortFormDid")}"), - ) - val didKey = "${actor.recall("shortFormDid")}#$newDidKeyId" - val didDocument = SerenityRest.lastResponse().get().didDocument!! - val foundVerificationMethod = didDocument.verificationMethod!!.map { it.id }.any { it == didKey } - - foundVerificationMethod && when (purpose) { - Purpose.ASSERTION_METHOD -> didDocument.assertionMethod!!.any { it == didKey } - Purpose.AUTHENTICATION -> didDocument.authentication!!.any { it == didKey } - Purpose.CAPABILITY_DELEGATION -> didDocument.capabilityDelegation!!.any { it == didKey } - Purpose.CAPABILITY_INVOCATION -> didDocument.capabilityInvocation!!.any { it == didKey } - Purpose.KEY_AGREEMENT -> didDocument.keyAgreement!!.any { it == didKey } - } - } + actor.attemptsTo( + PollingWait.until( + Question.about("did update").answeredBy { + actor.attemptsTo( + Get.resource("/dids/${actor.recall("shortFormDid")}"), + ) + val didKey = "${actor.recall("shortFormDid")}#$newDidKeyId" + val didDocument = SerenityRest.lastResponse().get().didDocument!! + val foundVerificationMethod = didDocument.verificationMethod!!.map { it.id }.any { it == didKey } + + foundVerificationMethod && when (purpose) { + Purpose.ASSERTION_METHOD -> didDocument.assertionMethod!!.any { it == didKey } + Purpose.AUTHENTICATION -> didDocument.authentication!!.any { it == didKey } + Purpose.CAPABILITY_DELEGATION -> didDocument.capabilityDelegation!!.any { it == didKey } + Purpose.CAPABILITY_INVOCATION -> didDocument.capabilityInvocation!!.any { it == didKey } + Purpose.KEY_AGREEMENT -> didDocument.keyAgreement!!.any { it == didKey } + } + }, + equalTo(true), + ), + ) } @Then("{actor} sees PRISM DID was successfully updated and keys removed with {purpose} purpose") fun actorSeesDidSuccessfullyUpdatedAndKeysRemoved(actor: Actor, purpose: Purpose) { val newDidKeyId = actor.recall("newDidKeyId") - Wait.until(errorMessage = "ERROR: DID UPDATE operation did not succeed on the ledger!") { - actor.attemptsTo( - Get.resource("/dids/${actor.recall("shortFormDid")}"), - ) - val didKey = "${actor.recall("shortFormDid")}#$newDidKeyId" - val didDocument = SerenityRest.lastResponse().get().didDocument!! - val verificationMethodNotPresent = didDocument.verificationMethod!!.map { it.id }.none { it == didKey } - - verificationMethodNotPresent && when (purpose) { - Purpose.ASSERTION_METHOD -> didDocument.assertionMethod!!.none { it == didKey } - Purpose.AUTHENTICATION -> didDocument.authentication!!.none { it == didKey } - Purpose.CAPABILITY_DELEGATION -> didDocument.capabilityDelegation!!.none { it == didKey } - Purpose.CAPABILITY_INVOCATION -> didDocument.capabilityInvocation!!.none { it == didKey } - Purpose.KEY_AGREEMENT -> didDocument.keyAgreement!!.none { it == didKey } - } - } + + actor.attemptsTo( + PollingWait.until( + Question.about("did update").answeredBy { + actor.attemptsTo( + Get.resource("/dids/${actor.recall("shortFormDid")}"), + ) + val didKey = "${actor.recall("shortFormDid")}#$newDidKeyId" + val didDocument = SerenityRest.lastResponse().get().didDocument!! + val verificationMethodNotPresent = didDocument.verificationMethod!!.map { it.id }.none { it == didKey } + + verificationMethodNotPresent && when (purpose) { + Purpose.ASSERTION_METHOD -> didDocument.assertionMethod!!.none { it == didKey } + Purpose.AUTHENTICATION -> didDocument.authentication!!.none { it == didKey } + Purpose.CAPABILITY_DELEGATION -> didDocument.capabilityDelegation!!.none { it == didKey } + Purpose.CAPABILITY_INVOCATION -> didDocument.capabilityInvocation!!.none { it == didKey } + Purpose.KEY_AGREEMENT -> didDocument.keyAgreement!!.none { it == didKey } + } + }, + equalTo(true), + ), + ) } @Then("{actor} sees that PRISM DID should have the new service") fun actorSeesDidSuccessfullyUpdatedWithNewServices(actor: Actor) { val serviceId = actor.recall("newServiceId") - Wait.until( - errorMessage = "ERROR: DID UPDATE operation did not succeed on the ledger!", - ) { - actor.attemptsTo( - Get.resource("/dids/${actor.recall("shortFormDid")}"), - ) - val serviceIds = - SerenityRest.lastResponse().get().didDocument!!.service!!.map { it.id } - serviceIds.any { - it == "${actor.recall("shortFormDid")}#$serviceId" - } - } + actor.attemptsTo( + PollingWait.until( + Question.about("did update").answeredBy { + actor.attemptsTo( + Get.resource("/dids/${actor.recall("shortFormDid")}"), + ) + val serviceIds = + SerenityRest.lastResponse().get().didDocument!!.service!!.map { it.id } + serviceIds.any { + it == "${actor.recall("shortFormDid")}#$serviceId" + } + }, + equalTo(true), + ), + ) } @Then("{actor} sees the PRISM DID should have the service removed") fun actorSeesDidSuccessfullyUpdatedByRemovingServices(actor: Actor) { val serviceId = actor.recall("newServiceId") - Wait.until( - errorMessage = "ERROR: DID UPDATE operation did not succeed on the ledger!", - ) { - actor.attemptsTo( - Get.resource("/dids/${actor.recall("shortFormDid")}"), - ) - val serviceIds = - SerenityRest.lastResponse().get().didDocument!!.service!!.map { it.id } - serviceIds.none { - it == "${actor.recall("shortFormDid")}#$serviceId" - } - } + actor.attemptsTo( + PollingWait.until( + Question.about("did update").answeredBy { + actor.attemptsTo( + Get.resource("/dids/${actor.recall("shortFormDid")}"), + ) + val serviceIds = + SerenityRest.lastResponse().get().didDocument!!.service!!.map { it.id } + serviceIds.none { + it == "${actor.recall("shortFormDid")}#$serviceId" + } + }, + equalTo(true), + ), + ) } @Then("{actor} sees the PRISM DID should have the service updated") fun actorSeesDidSuccessfullyUpdatedByUpdatingServices(actor: Actor) { val serviceUrl = actor.recall("newServiceUrl") - Wait.until( - errorMessage = "ERROR: DID UPDATE operation did not succeed on the ledger!", - ) { - actor.attemptsTo( - Get.resource("/dids/${actor.recall("shortFormDid")}"), - ) - val service = SerenityRest.lastResponse().get().didDocument!!.service!! - service.any { it.serviceEndpoint.value.contains(serviceUrl) } - } + actor.attemptsTo( + PollingWait.until( + Question.about("did update").answeredBy { + actor.attemptsTo( + Get.resource("/dids/${actor.recall("shortFormDid")}"), + ) + val service = SerenityRest.lastResponse().get().didDocument!!.service!! + service.any { it.serviceEndpoint.value.contains(serviceUrl) } + }, + equalTo(true), + ), + ) } private fun actorSubmitsPrismDidUpdateOperation(actor: Actor, updatePrismDidAction: UpdateManagedDIDRequestAction) { diff --git a/tests/integration-tests/src/test/kotlin/steps/proofs/AnoncredsPresentProofSteps.kt b/tests/integration-tests/src/test/kotlin/steps/proofs/AnoncredProofSteps.kt similarity index 61% rename from tests/integration-tests/src/test/kotlin/steps/proofs/AnoncredsPresentProofSteps.kt rename to tests/integration-tests/src/test/kotlin/steps/proofs/AnoncredProofSteps.kt index ace6fc890e..e41618037c 100644 --- a/tests/integration-tests/src/test/kotlin/steps/proofs/AnoncredsPresentProofSteps.kt +++ b/tests/integration-tests/src/test/kotlin/steps/proofs/AnoncredProofSteps.kt @@ -1,26 +1,19 @@ package steps.proofs -import abilities.ListenToEvents -import interactions.Patch -import interactions.Post +import interactions.* import io.cucumber.java.en.When import io.iohk.atala.automation.extensions.get import io.iohk.atala.automation.serenity.ensure.Ensure -import io.iohk.atala.automation.utils.Wait -import models.PresentationEvent -import models.PresentationStatusAdapter import net.serenitybdd.rest.SerenityRest import net.serenitybdd.screenplay.Actor import net.serenitybdd.screenplay.rest.abilities.CallAnApi import org.apache.http.HttpStatus.SC_CREATED import org.hyperledger.identus.client.models.* -class AnoncredsPresentProofSteps { - - private var proofEvent: PresentationEvent? = null +class AnoncredProofSteps { @When("{actor} sends a anoncreds request for proof presentation to {actor} using credential definition issued by {actor}") - fun faberSendsAnAnoncredsRequestForProofPresentationToBob(faber: Actor, bob: Actor, issuer: Actor) { + fun verifierSendsAnAnoncredRequestForProofPresentationToHolder(verifier: Actor, holder: Actor, issuer: Actor) { val credentialDefinitionRegistryUrl = issuer.usingAbilityTo(CallAnApi::class.java) .resolve("/credential-definition-registry/definitions") @@ -54,49 +47,28 @@ class AnoncredsPresentProofSteps { version = "0.1", ) val presentationRequest = RequestPresentationInput( - connectionId = faber.recall("connection-with-${bob.name}").connectionId, + connectionId = verifier.recall("connection-with-${holder.name}").connectionId, credentialFormat = "AnonCreds", anoncredPresentationRequest = anoncredsPresentationRequestV1, proofs = emptyList(), ) - faber.attemptsTo( - Post.to("/present-proof/presentations") - .with { - it.body( - presentationRequest, - ) - }, - ) - faber.attemptsTo( + verifier.attemptsTo( + Post.to("/present-proof/presentations").body(presentationRequest), Ensure.thatTheLastResponse().statusCode().isEqualTo(SC_CREATED), ) val presentationStatus = SerenityRest.lastResponse().get() - faber.remember("thid", presentationStatus.thid) - bob.remember("thid", presentationStatus.thid) - } - - @When("{actor} receives the anoncreds request") - fun bobReceivesTheAnoncredsRequest(bob: Actor) { - Wait.until( - errorMessage = "ERROR: Bob did not achieve any presentation request!", - ) { - proofEvent = ListenToEvents.with(bob).presentationEvents.lastOrNull { - it.data.thid == bob.recall("thid") - } - proofEvent != null && - proofEvent!!.data.status == PresentationStatusAdapter.Status.REQUEST_RECEIVED - } - bob.remember("presentationId", proofEvent!!.data.presentationId) + verifier.remember("thid", presentationStatus.thid) + holder.remember("thid", presentationStatus.thid) } @When("{actor} accepts the anoncreds presentation request") - fun bobAcceptsTheAnoncredsPresentationWithProof(bob: Actor) { + fun holderAcceptsTheAnoncredsPresentationWithProof(holder: Actor) { val requestPresentationAction = RequestPresentationAction( anoncredPresentationRequest = AnoncredCredentialProofsV1( listOf( AnoncredCredentialProofV1( - bob.recall("issuedCredential").recordId, + holder.recall("issuedCredential").recordId, listOf("sex"), listOf("age"), ), @@ -105,13 +77,9 @@ class AnoncredsPresentProofSteps { action = RequestPresentationAction.Action.REQUEST_MINUS_ACCEPT, ) - val presentationId = bob.recall("presentationId") - bob.attemptsTo( - Patch.to("/present-proof/presentations/$presentationId").with { - it.body( - requestPresentationAction, - ) - }, + val presentationId = holder.recall("presentationId") + holder.attemptsTo( + Patch.to("/present-proof/presentations/$presentationId").body(requestPresentationAction), ) } } diff --git a/tests/integration-tests/src/test/kotlin/steps/proofs/HolderProofSteps.kt b/tests/integration-tests/src/test/kotlin/steps/proofs/HolderProofSteps.kt new file mode 100644 index 0000000000..bdf3d078f7 --- /dev/null +++ b/tests/integration-tests/src/test/kotlin/steps/proofs/HolderProofSteps.kt @@ -0,0 +1,35 @@ +package steps.proofs + +import abilities.ListenToEvents +import interactions.* +import io.cucumber.java.en.When +import io.iohk.atala.automation.serenity.interactions.PollingWait +import models.PresentationStatusAdapter.Status.REQUEST_RECEIVED +import net.serenitybdd.screenplay.Actor +import org.hamcrest.CoreMatchers.equalTo +import org.hyperledger.identus.client.models.RequestPresentationAction + +class HolderProofSteps { + + @When("{actor} rejects the proof") + fun holderRejectsProof(holder: Actor) { + val presentationId: String = holder.recall("presentationId") + holder.attemptsTo( + Patch.to("/present-proof/presentations/$presentationId").body( + RequestPresentationAction(action = RequestPresentationAction.Action.REQUEST_MINUS_REJECT), + ), + ) + } + + @When("{actor} receives the presentation proof request") + fun holderReceivesTheRequest(holder: Actor) { + holder.attemptsTo( + PollingWait.until( + ListenToEvents.presentationProofStatus(holder), + equalTo(REQUEST_RECEIVED), + ), + ) + val presentationId = ListenToEvents.with(holder).presentationEvents.last().data.presentationId + holder.remember("presentationId", presentationId) + } +} diff --git a/tests/integration-tests/src/test/kotlin/steps/proofs/JwtProofSteps.kt b/tests/integration-tests/src/test/kotlin/steps/proofs/JwtProofSteps.kt new file mode 100644 index 0000000000..78985e62b0 --- /dev/null +++ b/tests/integration-tests/src/test/kotlin/steps/proofs/JwtProofSteps.kt @@ -0,0 +1,52 @@ +package steps.proofs + +import interactions.* +import io.cucumber.java.en.When +import io.iohk.atala.automation.extensions.get +import io.iohk.atala.automation.serenity.ensure.Ensure +import net.serenitybdd.rest.SerenityRest +import net.serenitybdd.screenplay.Actor +import org.apache.http.HttpStatus.SC_CREATED +import org.apache.http.HttpStatus.SC_OK +import org.hyperledger.identus.client.models.* + +class JwtProofSteps { + + @When("{actor} sends a request for jwt proof presentation to {actor}") + fun verifierSendsARequestForJwtProofPresentationToHolder(verifier: Actor, holder: Actor) { + val verifierConnectionToHolder = verifier.recall("connection-with-${holder.name}").connectionId + val presentationRequest = RequestPresentationInput( + connectionId = verifierConnectionToHolder, + options = Options( + challenge = "11c91493-01b3-4c4d-ac36-b336bab5bddf", + domain = "https://example-verifier.com", + ), + proofs = listOf( + ProofRequestAux( + schemaId = "https://schema.org/Person", + trustIssuers = listOf("did:web:atalaprism.io/users/testUser"), + ), + ), + ) + verifier.attemptsTo( + Post.to("/present-proof/presentations").body(presentationRequest), + Ensure.thatTheLastResponse().statusCode().isEqualTo(SC_CREATED), + ) + val presentationStatus = SerenityRest.lastResponse().get() + verifier.remember("thid", presentationStatus.thid) + holder.remember("thid", presentationStatus.thid) + } + + @When("{actor} makes the jwt presentation of the proof") + fun holderMakesThePresentationOfTheProofToVerifier(holder: Actor) { + val requestPresentationAction = RequestPresentationAction( + proofId = listOf(holder.recall("issuedCredential").recordId), + action = RequestPresentationAction.Action.REQUEST_MINUS_ACCEPT, + ) + val presentationId: String = holder.recall("presentationId") + holder.attemptsTo( + Patch.to("/present-proof/presentations/$presentationId").body(requestPresentationAction), + Ensure.thatTheLastResponse().statusCode().isEqualTo(SC_OK), + ) + } +} diff --git a/tests/integration-tests/src/test/kotlin/steps/proofs/PresentProofSteps.kt b/tests/integration-tests/src/test/kotlin/steps/proofs/PresentProofSteps.kt deleted file mode 100644 index b0599b7627..0000000000 --- a/tests/integration-tests/src/test/kotlin/steps/proofs/PresentProofSteps.kt +++ /dev/null @@ -1,123 +0,0 @@ -package steps.proofs - -import abilities.ListenToEvents -import interactions.Patch -import interactions.Post -import interactions.body -import io.cucumber.java.en.Then -import io.cucumber.java.en.When -import io.iohk.atala.automation.extensions.get -import io.iohk.atala.automation.serenity.ensure.Ensure -import io.iohk.atala.automation.utils.Wait -import models.PresentationStatusAdapter -import net.serenitybdd.rest.SerenityRest -import net.serenitybdd.screenplay.Actor -import org.apache.http.HttpStatus.* -import org.hyperledger.identus.client.models.* -import kotlin.time.Duration.Companion.seconds - -class PresentProofSteps { - - @When("{actor} sends a request for proof presentation to {actor}") - fun verifierSendsARequestForProofPresentationToHolder(verifier: Actor, holder: Actor) { - val verifierConnectionToHolder = verifier.recall("connection-with-${holder.name}").connectionId - val presentationRequest = RequestPresentationInput( - connectionId = verifierConnectionToHolder, - options = Options( - challenge = "11c91493-01b3-4c4d-ac36-b336bab5bddf", - domain = "https://example-verifier.com", - ), - proofs = listOf( - ProofRequestAux( - schemaId = "https://schema.org/Person", - trustIssuers = listOf("did:web:atalaprism.io/users/testUser"), - ), - ), - ) - verifier.attemptsTo( - Post.to("/present-proof/presentations").body(presentationRequest), - Ensure.thatTheLastResponse().statusCode().isEqualTo(SC_CREATED), - ) - val presentationStatus = SerenityRest.lastResponse().get() - verifier.remember("thid", presentationStatus.thid) - holder.remember("thid", presentationStatus.thid) - } - - @When("{actor} receives the presentation proof request") - fun holderReceivesTheRequest(holder: Actor) { - Wait.until( - timeout = 30.seconds, - errorMessage = "ERROR: Bob did not achieve any presentation request!", - ) { - val proofEvent = ListenToEvents.with(holder).presentationEvents.lastOrNull { - it.data.thid == holder.recall("thid") - } - holder.remember("presentationId", proofEvent?.data?.presentationId) - proofEvent?.data?.status == PresentationStatusAdapter.Status.REQUEST_RECEIVED - } - } - - @When("{actor} makes the presentation of the proof") - fun holderMakesThePresentationOfTheProofToVerifier(holder: Actor) { - val requestPresentationAction = RequestPresentationAction( - proofId = listOf(holder.recall("issuedCredential").recordId), - action = RequestPresentationAction.Action.REQUEST_MINUS_ACCEPT, - ) - val presentationId: String = holder.recall("presentationId") - holder.attemptsTo( - Patch.to("/present-proof/presentations/$presentationId").body(requestPresentationAction), - Ensure.thatTheLastResponse().statusCode().isEqualTo(SC_OK), - ) - } - - @When("{actor} rejects the proof") - fun holderRejectsProof(holder: Actor) { - val presentationId: String = holder.recall("presentationId") - holder.attemptsTo( - Patch.to("/present-proof/presentations/$presentationId").with { - it.body( - RequestPresentationAction(action = RequestPresentationAction.Action.REQUEST_MINUS_REJECT), - ) - }, - ) - } - - @Then("{actor} sees the proof is rejected") - fun bobSeesProofIsRejected(bob: Actor) { - Wait.until( - timeout = 30.seconds, - errorMessage = "ERROR: Faber did not receive presentation from Bob!", - ) { - val proofEvent = ListenToEvents.with(bob).presentationEvents.lastOrNull { - it.data.thid == bob.recall("thid") - } - proofEvent?.data?.status == PresentationStatusAdapter.Status.REQUEST_REJECTED - } - } - - @Then("{actor} has the proof verified") - fun faberHasTheProofVerified(faber: Actor) { - Wait.until( - timeout = 60.seconds, - errorMessage = "Presentation did not achieve PresentationVerified state!", - ) { - val proofEvent = ListenToEvents.with(faber).presentationEvents.lastOrNull { - it.data.thid == faber.recall("thid") - } - proofEvent?.data?.status == PresentationStatusAdapter.Status.PRESENTATION_VERIFIED - } - } - - @Then("{actor} sees the proof returned verification failed") - fun verifierSeesTheProofReturnedVerificationFailed(verifier: Actor) { - Wait.until( - timeout = 120.seconds, - errorMessage = "Presentation did not achieve PresentationVerificationFailed state!", - ) { - val proofEvent = ListenToEvents.with(verifier).presentationEvents.lastOrNull { - it.data.thid == verifier.recall("thid") - } - proofEvent?.data?.status == PresentationStatusAdapter.Status.PRESENTATION_VERIFICATION_FAILED - } - } -} diff --git a/tests/integration-tests/src/test/kotlin/steps/proofs/SdJwtProofSteps.kt b/tests/integration-tests/src/test/kotlin/steps/proofs/SdJwtProofSteps.kt new file mode 100644 index 0000000000..41eee396b0 --- /dev/null +++ b/tests/integration-tests/src/test/kotlin/steps/proofs/SdJwtProofSteps.kt @@ -0,0 +1,99 @@ +package steps.proofs + +import abilities.ListenToEvents +import com.google.gson.Gson +import com.google.gson.JsonObject +import com.nimbusds.jose.util.Base64URL +import interactions.* +import io.cucumber.java.en.Then +import io.cucumber.java.en.When +import io.iohk.atala.automation.extensions.get +import io.iohk.atala.automation.serenity.ensure.Ensure +import models.JwtCredential +import models.SdJwtClaim +import net.serenitybdd.rest.SerenityRest +import net.serenitybdd.screenplay.Actor +import org.apache.http.HttpStatus.SC_CREATED +import org.apache.http.HttpStatus.SC_OK +import org.hyperledger.identus.client.models.* + +class SdJwtProofSteps { + + @When("{actor} sends a request for sd-jwt proof presentation to {actor} requesting [{}] claims") + fun verifierSendsARequestForSdJwtProofPresentationToHolder(verifier: Actor, holder: Actor, keys: String) { + val claims = JsonObject() + for (key in keys.split(",")) { + claims.addProperty(key, "{}") + } + val verifierConnectionToHolder = verifier.recall("connection-with-${holder.name}").connectionId + val presentationRequest = RequestPresentationInput( + connectionId = verifierConnectionToHolder, + options = Options( + challenge = "11c91493-01b3-4c4d-ac36-b336bab5bddf", + domain = "https://example-verifier.com", + ), + proofs = listOf(), + credentialFormat = "SDJWT", + claims = claims, + ) + verifier.attemptsTo( + Post.to("/present-proof/presentations").body(presentationRequest), + Ensure.thatTheLastResponse().statusCode().isEqualTo(SC_CREATED), + ) + val presentationStatus = SerenityRest.lastResponse().get() + verifier.remember("thid", presentationStatus.thid) + holder.remember("thid", presentationStatus.thid) + } + + @When("{actor} makes the sd-jwt presentation of the proof disclosing [{}] claims") + fun holderMakesThePresentationOfTheProofToVerifier(holder: Actor, keys: String) { + val claims = JsonObject() + for (key in keys.split(",")) { + claims.addProperty(key, "{}") + } + + val requestPresentationAction = RequestPresentationAction( + proofId = listOf(holder.recall("issuedCredential").recordId), + action = RequestPresentationAction.Action.REQUEST_MINUS_ACCEPT, + claims = claims, + credentialFormat = "SDJWT", + ) + + val presentationId: String = holder.recall("presentationId") + holder.attemptsTo( + Patch.to("/present-proof/presentations/$presentationId").body(requestPresentationAction), + Ensure.thatTheLastResponse().statusCode().isEqualTo(SC_OK), + ) + } + + @Then("{actor} has the sd-jwt proof verified") + fun verifierHasTheSdJwtProofVerified(verifier: Actor) { + VerifierProofSteps().verifierHasTheProofVerified(verifier) + val proofEvent = ListenToEvents.with(verifier).presentationEvents.last().data + val sdjwt = proofEvent.data!!.first() + val isBound = !sdjwt.endsWith("~") // if it ends with a ~ there's no binding + + val jwt = JwtCredential.parseJwt(sdjwt.split("~").first()) + val claims = sdjwt.split("~") + .drop(1) + .dropLast(if (isBound) 1 else 0) + .dropLastWhile { it.isBlank() } + .map { Base64URL.from(it).decodeToString() } + .map { Gson().fromJson(it, Array::class.java) } + .associate { it[1] to SdJwtClaim(salt = it[0], key = it[1], value = it[2]) } + + verifier.attemptsTo( + Ensure.that(claims.containsKey("firstName")).isTrue(), + Ensure.that(claims.containsKey("lastName")).isFalse(), + ) + + if (isBound) { + val bindingJwt = JwtCredential.parseJwt(sdjwt.split("~").last()) + val payload = Gson().toJsonTree(bindingJwt.payload!!.toJSONObject()).asJsonObject + + verifier.attemptsTo( + Ensure.that(payload.get("aud").asString).isEqualTo("https://example-verifier.com"), + ) + } + } +} diff --git a/tests/integration-tests/src/test/kotlin/steps/proofs/VerifierProofSteps.kt b/tests/integration-tests/src/test/kotlin/steps/proofs/VerifierProofSteps.kt new file mode 100644 index 0000000000..e99c103c83 --- /dev/null +++ b/tests/integration-tests/src/test/kotlin/steps/proofs/VerifierProofSteps.kt @@ -0,0 +1,32 @@ +package steps.proofs + +import abilities.ListenToEvents.Companion.presentationProofStatus +import io.cucumber.java.en.Then +import io.iohk.atala.automation.serenity.interactions.PollingWait +import models.PresentationStatusAdapter.Status.* +import net.serenitybdd.screenplay.Actor +import org.hamcrest.CoreMatchers.equalTo + +class VerifierProofSteps { + + @Then("{actor} sees the proof returned verification failed") + fun verifierSeesTheProofReturnedVerificationFailed(verifier: Actor) { + verifier.attemptsTo( + PollingWait.until(presentationProofStatus(verifier), equalTo(PRESENTATION_VERIFICATION_FAILED)), + ) + } + + @Then("{actor} sees the proof is rejected") + fun verifierSeesProofIsRejected(verifier: Actor) { + verifier.attemptsTo( + PollingWait.until(presentationProofStatus(verifier), equalTo(REQUEST_REJECTED)), + ) + } + + @Then("{actor} has the proof verified") + fun verifierHasTheProofVerified(verifier: Actor) { + verifier.attemptsTo( + PollingWait.until(presentationProofStatus(verifier), equalTo(PRESENTATION_VERIFIED)), + ) + } +} diff --git a/tests/integration-tests/src/test/resources/features/credentials/issue_anoncred_with_published_did.feature b/tests/integration-tests/src/test/resources/features/credential/anoncred/issuance.feature similarity index 78% rename from tests/integration-tests/src/test/resources/features/credentials/issue_anoncred_with_published_did.feature rename to tests/integration-tests/src/test/resources/features/credential/anoncred/issuance.feature index 051e8df412..712d6d7db6 100644 --- a/tests/integration-tests/src/test/resources/features/credentials/issue_anoncred_with_published_did.feature +++ b/tests/integration-tests/src/test/resources/features/credential/anoncred/issuance.feature @@ -1,5 +1,5 @@ -@RFC0453 @AIP20 @credentials -Feature: Issue Anoncred with published DID +@anoncred @issuance +Feature: Issue Anoncred credential Background: Given Issuer and Holder have an existing connection @@ -10,6 +10,6 @@ Feature: Issue Anoncred with published DID Given Issuer has an anoncred schema definition When Issuer offers anoncred to Holder And Holder receives the credential offer - And Holder accepts credential offer for anoncred + And Holder accepts anoncred credential offer And Issuer issues the credential Then Holder receives the issued credential diff --git a/tests/integration-tests/src/test/resources/features/proofs/present_proof_anoncred.feature b/tests/integration-tests/src/test/resources/features/credential/anoncred/present_proof.feature similarity index 86% rename from tests/integration-tests/src/test/resources/features/proofs/present_proof_anoncred.feature rename to tests/integration-tests/src/test/resources/features/credential/anoncred/present_proof.feature index 77c52dc572..8bc05395c0 100644 --- a/tests/integration-tests/src/test/resources/features/proofs/present_proof_anoncred.feature +++ b/tests/integration-tests/src/test/resources/features/credential/anoncred/present_proof.feature @@ -1,4 +1,4 @@ -@proof @anoncreds +@anoncred @proof Feature: Present Proof Protocol Scenario: Holder presents anoncreds credential proof to verifier @@ -9,10 +9,10 @@ Scenario: Holder presents anoncreds credential proof to verifier And Issuer has an anoncred schema definition And Issuer offers anoncred to Holder And Holder receives the credential offer - And Holder accepts credential offer for anoncred + And Holder accepts anoncred credential offer And Issuer issues the credential And Holder receives the issued credential When Verifier sends a anoncreds request for proof presentation to Holder using credential definition issued by Issuer - And Holder receives the anoncreds request + And Holder receives the presentation proof request And Holder accepts the anoncreds presentation request # Then Verifier has the proof verified FIXME diff --git a/tests/integration-tests/src/test/resources/features/credential/jwt/issuance.feature b/tests/integration-tests/src/test/resources/features/credential/jwt/issuance.feature new file mode 100644 index 0000000000..2e9689f772 --- /dev/null +++ b/tests/integration-tests/src/test/resources/features/credential/jwt/issuance.feature @@ -0,0 +1,41 @@ +@jwt @issuance +Feature: Issue JWT credential + + Scenario: Issuing jwt credential with published PRISM DID + Given Issuer and Holder have an existing connection + And Issuer has a published DID for JWT + And Holder has an unpublished DID for JWT + When Issuer offers a jwt credential to Holder with "short" form DID + And Holder receives the credential offer + And Holder accepts jwt credential offer + And Issuer issues the credential + Then Holder receives the issued credential + + Scenario: Issuing jwt credential with a schema + Given Issuer and Holder have an existing connection + And Issuer has a published DID for JWT + And Issuer has published STUDENT_SCHEMA schema + And Holder has an unpublished DID for JWT + When Issuer offers a jwt credential to Holder with "short" form using STUDENT_SCHEMA schema + And Holder receives the credential offer + And Holder accepts jwt credential offer + And Issuer issues the credential + Then Holder receives the issued credential + + Scenario: Issuing jwt credential with wrong claim structure for schema + Given Issuer and Holder have an existing connection + And Issuer has a published DID for JWT + And Issuer has published STUDENT_SCHEMA schema + And Holder has an unpublished DID for JWT + When Issuer offers a jwt credential to Holder with "short" form DID with wrong claims structure using STUDENT_SCHEMA schema + Then Issuer should see that credential issuance has failed + + Scenario: Issuing jwt credential with unpublished PRISM DID + Given Issuer and Holder have an existing connection + And Issuer has an unpublished DID for JWT + And Holder has an unpublished DID for JWT + And Issuer offers a jwt credential to Holder with "long" form DID + And Holder receives the credential offer + And Holder accepts jwt credential offer + And Issuer issues the credential + Then Holder receives the issued credential diff --git a/tests/integration-tests/src/test/resources/features/proofs/present_proof.feature b/tests/integration-tests/src/test/resources/features/credential/jwt/present_proof.feature similarity index 60% rename from tests/integration-tests/src/test/resources/features/proofs/present_proof.feature rename to tests/integration-tests/src/test/resources/features/credential/jwt/present_proof.feature index c2fa51b80d..5b0f46564c 100644 --- a/tests/integration-tests/src/test/resources/features/proofs/present_proof.feature +++ b/tests/integration-tests/src/test/resources/features/credential/jwt/present_proof.feature @@ -1,26 +1,26 @@ -@proof @jwt +@jwt @proof Feature: Present Proof Protocol - Scenario: Holder presents credential proof to verifier + Scenario: Holder presents jwt credential proof to verifier Given Verifier and Holder have an existing connection And Holder has a jwt issued credential from Issuer - When Verifier sends a request for proof presentation to Holder + When Verifier sends a request for jwt proof presentation to Holder And Holder receives the presentation proof request - And Holder makes the presentation of the proof + And Holder makes the jwt presentation of the proof Then Verifier has the proof verified - Scenario: Holder presents proof to verifier which is the issuer itself + Scenario: Holder presents jwt proof to verifier which is the issuer itself Given Issuer and Holder have an existing connection And Holder has a jwt issued credential from Issuer - When Issuer sends a request for proof presentation to Holder + When Issuer sends a request for jwt proof presentation to Holder And Holder receives the presentation proof request - And Holder makes the presentation of the proof + And Holder makes the jwt presentation of the proof Then Issuer has the proof verified Scenario: Verifier rejects holder proof Given Verifier and Holder have an existing connection And Holder has a jwt issued credential from Issuer - When Verifier sends a request for proof presentation to Holder + When Verifier sends a request for jwt proof presentation to Holder And Holder receives the presentation proof request And Holder rejects the proof Then Holder sees the proof is rejected diff --git a/tests/integration-tests/src/test/resources/features/revocation/revoke_jwt_credential.feature b/tests/integration-tests/src/test/resources/features/credential/jwt/revocation.feature similarity index 55% rename from tests/integration-tests/src/test/resources/features/revocation/revoke_jwt_credential.feature rename to tests/integration-tests/src/test/resources/features/credential/jwt/revocation.feature index b60e7d7bf9..508380402d 100644 --- a/tests/integration-tests/src/test/resources/features/revocation/revoke_jwt_credential.feature +++ b/tests/integration-tests/src/test/resources/features/credential/jwt/revocation.feature @@ -1,21 +1,21 @@ -@revocation @jwt -Feature: Credential revocation - JWT +@jwt @revocation +Feature: JWT Credential revocation Background: Given Holder has a jwt issued credential from Issuer - Scenario: Revoke issued credential + Scenario: Revoke jwt issued credential When Issuer revokes the credential issued to Holder Then Issuer should see the credential was revoked - When Issuer sends a request for proof presentation to Holder + When Issuer sends a request for jwt proof presentation to Holder And Holder receives the presentation proof request - And Holder makes the presentation of the proof + And Holder makes the jwt presentation of the proof Then Issuer sees the proof returned verification failed - Scenario: Holder tries to revoke credential from issuer + Scenario: Holder tries to revoke jwt credential from issuer When Holder tries to revoke credential from Issuer - And Issuer sends a request for proof presentation to Holder + And Issuer sends a request for jwt proof presentation to Holder And Holder receives the presentation proof request - And Holder makes the presentation of the proof + And Holder makes the jwt presentation of the proof Then Issuer has the proof verified And Issuer should see the credential is not revoked diff --git a/tests/integration-tests/src/test/resources/features/credential/sdjwt/issuance.feature b/tests/integration-tests/src/test/resources/features/credential/sdjwt/issuance.feature new file mode 100644 index 0000000000..29af552150 --- /dev/null +++ b/tests/integration-tests/src/test/resources/features/credential/sdjwt/issuance.feature @@ -0,0 +1,37 @@ +@sdjwt @issuance +Feature: Issue SD-JWT credential + + Scenario: Issuing sd-jwt credential + Given Issuer and Holder have an existing connection + And Issuer has a published DID for SD_JWT + And Holder has an unpublished DID for SD_JWT + When Issuer offers a sd-jwt credential to Holder + And Holder receives the credential offer + And Holder accepts credential offer for sd-jwt + And Issuer issues the credential + Then Holder receives the issued credential + And Holder checks the sd-jwt credential contents + + Scenario: Issuing sd-jwt credential with holder binding + Given Issuer and Holder have an existing connection + And Issuer has a published DID for SD_JWT + And Holder has an unpublished DID for SD_JWT + When Issuer offers a sd-jwt credential to Holder + And Holder receives the credential offer + And Holder accepts credential offer for sd-jwt with 'auth-1' key binding + And Issuer issues the credential + Then Holder receives the issued credential + Then Holder checks the sd-jwt credential contents with holder binding + +# Scenario: Issuing sd-jwt with wrong algorithm +# Given Issuer and Holder have an existing connection +# When Issuer prepares a custom PRISM DID +# And Issuer adds a 'secp256k1' key for 'assertionMethod' purpose with 'assert-1' name to the custom PRISM DID +# And Issuer adds a 'secp256k1' key for 'authentication' purpose with 'auth-1' name to the custom PRISM DID +# And Issuer creates the custom PRISM DID +# And Holder has an unpublished DID for SD_JWT +# And Issuer offers a sd-jwt credential to Holder +# And Holder receives the credential offer +# And Holder accepts credential offer for sd-jwt +# And Issuer tries to issue the credential +# Then Issuer should see that credential issuance has failed diff --git a/tests/integration-tests/src/test/resources/features/credential/sdjwt/present_proof.feature b/tests/integration-tests/src/test/resources/features/credential/sdjwt/present_proof.feature new file mode 100644 index 0000000000..e5d273bf37 --- /dev/null +++ b/tests/integration-tests/src/test/resources/features/credential/sdjwt/present_proof.feature @@ -0,0 +1,35 @@ +@sdjwt @proof +Feature: Present SD-JWT Proof Protocol + + Scenario Outline: Holder presents sd-jwt proof to + Given and Holder have an existing connection + And Holder has a sd-jwt issued credential from Issuer + When sends a request for sd-jwt proof presentation to Holder requesting [firstName] claims + And Holder receives the presentation proof request + And Holder makes the sd-jwt presentation of the proof disclosing [firstName] claims + Then has the proof verified + Examples: + | verifier | + | Verifier | + | Issuer | + + Scenario Outline: Holder presents a bound sd-jwt proof to + Given and Holder have an existing connection + And Holder has a bound sd-jwt issued credential from Issuer + When sends a request for sd-jwt proof presentation to Holder requesting [firstName] claims + And Holder receives the presentation proof request + And Holder makes the sd-jwt presentation of the proof disclosing [firstName] claims + Then has the sd-jwt proof verified + Examples: + | verifier | + | Verifier | + | Issuer | + +# Scenario: Holder presents sd-jwt proof with different claims from requested +# Given Verifier and Holder have an existing connection +# And Holder has a bound sd-jwt issued credential from Issuer +# When Verifier sends a request for sd-jwt proof presentation to Holder requesting [firstName] claims +# And Holder receives the presentation proof request +# And Holder makes the sd-jwt presentation of the proof disclosing [lastName] claims +# Then Verifier sees the proof returned verification failed + diff --git a/tests/integration-tests/src/test/resources/features/credentials/issue_jwt_with_published_did.feature b/tests/integration-tests/src/test/resources/features/credentials/issue_jwt_with_published_did.feature deleted file mode 100644 index e54fd17c2d..0000000000 --- a/tests/integration-tests/src/test/resources/features/credentials/issue_jwt_with_published_did.feature +++ /dev/null @@ -1,34 +0,0 @@ -@RFC0453 @AIP20 @credentials -Feature: Issue JWT Credentials with published DID - - Background: - Given Issuer and Holder have an existing connection - And Issuer has a published DID for JWT - And Issuer has published STUDENT_SCHEMA schema - And Holder has an unpublished DID for JWT - - Scenario: Issuing credential with published PRISM DID - When Issuer offers a credential to Holder with "short" form DID - And Holder receives the credential offer - And Holder accepts credential offer for JWT - And Issuer issues the credential - Then Holder receives the issued credential - - Scenario: Issuing anoncred with published PRISM DID - Given Issuer has an anoncred schema definition - When Issuer offers anoncred to Holder - And Holder receives the credential offer - And Holder accepts credential offer for anoncred - And Issuer issues the credential - Then Holder receives the issued credential - - Scenario: Issuing credential with an existing schema - When Issuer offers a credential to Holder with "short" form using STUDENT_SCHEMA schema - And Holder receives the credential offer - And Holder accepts credential offer for JWT - And Issuer issues the credential - Then Holder receives the issued credential - - Scenario: Issuing credential with wrong claim structure for schema - When Issuer offers a credential to Holder with "short" form DID with wrong claims structure using STUDENT_SCHEMA schema - Then Issuer should see that credential issuance has failed diff --git a/tests/integration-tests/src/test/resources/features/credentials/issue_jwt_with_unpublished_did.feature b/tests/integration-tests/src/test/resources/features/credentials/issue_jwt_with_unpublished_did.feature deleted file mode 100644 index a658a0c453..0000000000 --- a/tests/integration-tests/src/test/resources/features/credentials/issue_jwt_with_unpublished_did.feature +++ /dev/null @@ -1,14 +0,0 @@ -@RFC0453 @AIP20 @credentials -Feature: Issue JWT Credentials with unpublished DID - - Background: - Given Issuer and Holder have an existing connection - And Issuer has an unpublished DID for JWT - And Holder has an unpublished DID for JWT - - Scenario: Issuing credential with unpublished PRISM DID - And Issuer offers a credential to Holder with "long" form DID - And Holder receives the credential offer - And Holder accepts credential offer for JWT - And Issuer issues the credential - Then Holder receives the issued credential From d1e698d3c2547943f8bdeb8211a364ecf4253cff Mon Sep 17 00:00:00 2001 From: Hyperledger Bot Date: Mon, 15 Jul 2024 12:30:11 +0000 Subject: [PATCH 11/13] chore(release): cut Identus Cloud agent 1.38.0 release # [1.38.0](https://github.com/hyperledger/identus-cloud-agent/compare/cloud-agent-v1.37.0...cloud-agent-v1.38.0) (2024-07-15) ### Bug Fixes * Move InMemory classes to the test moduels ([#1240](https://github.com/hyperledger/identus-cloud-agent/issues/1240)) ([823057a](https://github.com/hyperledger/identus-cloud-agent/commit/823057adaa6127eca80dba4df123f07098d34f65)) * move mocks into the test modules ([#1236](https://github.com/hyperledger/identus-cloud-agent/issues/1236)) ([df83026](https://github.com/hyperledger/identus-cloud-agent/commit/df83026704980e071f7aa158634da20fbc2527c3)) * use Put and Get for DID in doobie statement ([#1250](https://github.com/hyperledger/identus-cloud-agent/issues/1250)) ([fc1cf51](https://github.com/hyperledger/identus-cloud-agent/commit/fc1cf5157f5503143c23da54c8ea6fe78a776640)) * Wallet Management Error Handling ([#1248](https://github.com/hyperledger/identus-cloud-agent/issues/1248)) ([cfd5101](https://github.com/hyperledger/identus-cloud-agent/commit/cfd5101f18276b9f59830c47c0d7fa64b30662db)) ### Features * upgrade docusaurus and semantic-release packages ([de53f1d](https://github.com/hyperledger/identus-cloud-agent/commit/de53f1db15a25e4d66cba1b191fc6e591b42284b)) Signed-off-by: Allain Magyar --- CHANGELOG.md | 15 ++++ DEPENDENCIES.md | 60 ++++++------- .../api/http/cloud-agent-openapi-spec.yaml | 6 +- infrastructure/charts/agent/Chart.yaml | 4 +- infrastructure/charts/cloud-agent-1.38.0.tgz | Bin 0 -> 161333 bytes infrastructure/charts/index.yaml | 84 +++++++++++------- infrastructure/local/.env | 2 +- package-lock.json | 4 +- package.json | 2 +- version.sbt | 2 +- 10 files changed, 106 insertions(+), 73 deletions(-) create mode 100644 infrastructure/charts/cloud-agent-1.38.0.tgz diff --git a/CHANGELOG.md b/CHANGELOG.md index 0907a99844..9a3be86fbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,18 @@ +# [1.38.0](https://github.com/hyperledger/identus-cloud-agent/compare/cloud-agent-v1.37.0...cloud-agent-v1.38.0) (2024-07-15) + + +### Bug Fixes + +* Move InMemory classes to the test moduels ([#1240](https://github.com/hyperledger/identus-cloud-agent/issues/1240)) ([823057a](https://github.com/hyperledger/identus-cloud-agent/commit/823057adaa6127eca80dba4df123f07098d34f65)) +* move mocks into the test modules ([#1236](https://github.com/hyperledger/identus-cloud-agent/issues/1236)) ([df83026](https://github.com/hyperledger/identus-cloud-agent/commit/df83026704980e071f7aa158634da20fbc2527c3)) +* use Put and Get for DID in doobie statement ([#1250](https://github.com/hyperledger/identus-cloud-agent/issues/1250)) ([fc1cf51](https://github.com/hyperledger/identus-cloud-agent/commit/fc1cf5157f5503143c23da54c8ea6fe78a776640)) +* Wallet Management Error Handling ([#1248](https://github.com/hyperledger/identus-cloud-agent/issues/1248)) ([cfd5101](https://github.com/hyperledger/identus-cloud-agent/commit/cfd5101f18276b9f59830c47c0d7fa64b30662db)) + + +### Features + +* upgrade docusaurus and semantic-release packages ([de53f1d](https://github.com/hyperledger/identus-cloud-agent/commit/de53f1db15a25e4d66cba1b191fc6e591b42284b)) + # [1.37.0](https://github.com/hyperledger/identus-cloud-agent/compare/cloud-agent-v1.36.1...cloud-agent-v1.37.0) (2024-07-01) diff --git a/DEPENDENCIES.md b/DEPENDENCIES.md index f25d5d9208..be74257280 100644 --- a/DEPENDENCIES.md +++ b/DEPENDENCIES.md @@ -422,34 +422,34 @@ MIT | [The MIT License (MIT)](https://opensource.org/licenses/MIT) | [com.dimafe Public Domain | [Public Domain, per Creative Commons CC0](http://creativecommons.org/publicdomain/zero/1.0/) | [org.hdrhistogram # HdrHistogram # 2.1.12](http://hdrhistogram.github.io/HdrHistogram/) | Public Domain | [Public Domain, per Creative Commons CC0](http://creativecommons.org/publicdomain/zero/1.0/) | [org.latencyutils # LatencyUtils # 2.0.3](http://latencyutils.github.io/LatencyUtils/) | none specified | []() | [net.jcip # jcip-annotations # 1.0](http://jcip.net/) | -none specified | []() | [org.hyperledger # castor-core_3 # 1.36.1-SNAPSHOT](https://github.com/hyperledger/identus-cloud-agent) | -none specified | []() | [org.hyperledger # cloud-agent-wallet-api_3 # 1.36.1-SNAPSHOT](https://github.com/hyperledger/identus-cloud-agent) | -none specified | []() | [org.hyperledger # connect-core_3 # 1.36.1-SNAPSHOT](https://github.com/hyperledger/identus-cloud-agent) | -none specified | []() | [org.hyperledger # connect-sql-doobie_3 # 1.36.1-SNAPSHOT](https://github.com/hyperledger/identus-cloud-agent) | -none specified | []() | [org.hyperledger # event-notification_3 # 1.36.1-SNAPSHOT](https://github.com/hyperledger/identus-cloud-agent) | -none specified | []() | [org.hyperledger # mercury-agent-core_3 # 1.36.1-SNAPSHOT](https://github.com/hyperledger/identus-cloud-agent) | -none specified | []() | [org.hyperledger # mercury-agent-didcommx_3 # 1.36.1-SNAPSHOT](https://github.com/hyperledger/identus-cloud-agent) | -none specified | []() | [org.hyperledger # mercury-data-models_3 # 1.36.1-SNAPSHOT](https://github.com/hyperledger/identus-cloud-agent) | -none specified | []() | [org.hyperledger # mercury-protocol-connection_3 # 1.36.1-SNAPSHOT](https://github.com/hyperledger/identus-cloud-agent) | -none specified | []() | [org.hyperledger # mercury-protocol-coordinate-mediation_3 # 1.36.1-SNAPSHOT](https://github.com/hyperledger/identus-cloud-agent) | -none specified | []() | [org.hyperledger # mercury-protocol-invitation_3 # 1.36.1-SNAPSHOT](https://github.com/hyperledger/identus-cloud-agent) | -none specified | []() | [org.hyperledger # mercury-protocol-issue-credential_3 # 1.36.1-SNAPSHOT](https://github.com/hyperledger/identus-cloud-agent) | -none specified | []() | [org.hyperledger # mercury-protocol-mailbox_3 # 1.36.1-SNAPSHOT](https://github.com/hyperledger/identus-cloud-agent) | -none specified | []() | [org.hyperledger # mercury-protocol-outofband-login_3 # 1.36.1-SNAPSHOT](https://github.com/hyperledger/identus-cloud-agent) | -none specified | []() | [org.hyperledger # mercury-protocol-present-proof_3 # 1.36.1-SNAPSHOT](https://github.com/hyperledger/identus-cloud-agent) | -none specified | []() | [org.hyperledger # mercury-protocol-report-problem_3 # 1.36.1-SNAPSHOT](https://github.com/hyperledger/identus-cloud-agent) | -none specified | []() | [org.hyperledger # mercury-protocol-revocation-notification_3 # 1.36.1-SNAPSHOT](https://github.com/hyperledger/identus-cloud-agent) | -none specified | []() | [org.hyperledger # mercury-protocol-routing-2-0_3 # 1.36.1-SNAPSHOT](https://github.com/hyperledger/identus-cloud-agent) | -none specified | []() | [org.hyperledger # mercury-protocol-trust-ping_3 # 1.36.1-SNAPSHOT](https://github.com/hyperledger/identus-cloud-agent) | -none specified | []() | [org.hyperledger # mercury-resolver_3 # 1.36.1-SNAPSHOT](https://github.com/hyperledger/identus-cloud-agent) | -none specified | []() | [org.hyperledger # mercury-verifiable-credentials_3 # 1.36.1-SNAPSHOT](https://github.com/hyperledger/identus-cloud-agent) | -none specified | []() | [org.hyperledger # pollux-anoncreds_3 # 1.36.1-SNAPSHOT](https://github.com/hyperledger/identus-cloud-agent) | -none specified | []() | [org.hyperledger # pollux-core_3 # 1.36.1-SNAPSHOT](https://github.com/hyperledger/identus-cloud-agent) | -none specified | []() | [org.hyperledger # pollux-sd-jwt_3 # 1.36.1-SNAPSHOT](https://github.com/hyperledger/identus-cloud-agent) | -none specified | []() | [org.hyperledger # pollux-sql-doobie_3 # 1.36.1-SNAPSHOT](https://github.com/hyperledger/identus-cloud-agent) | -none specified | []() | [org.hyperledger # pollux-vc-jwt_3 # 1.36.1-SNAPSHOT](https://github.com/hyperledger/identus-cloud-agent) | -none specified | []() | [org.hyperledger # prism-node-client_3 # 1.36.1-SNAPSHOT](https://github.com/hyperledger/identus-cloud-agent) | -none specified | []() | [org.hyperledger # shared # 1.36.1-SNAPSHOT](https://github.com/hyperledger/identus-cloud-agent) | -none specified | []() | [org.hyperledger # shared-crypto # 1.36.1-SNAPSHOT](https://github.com/hyperledger/identus-cloud-agent) | -none specified | []() | [org.hyperledger # shared-test # 1.36.1-SNAPSHOT](https://github.com/hyperledger/identus-cloud-agent) | +none specified | []() | [org.hyperledger # castor-core_3 # 1.37.0-SNAPSHOT](https://github.com/hyperledger/identus-cloud-agent) | +none specified | []() | [org.hyperledger # cloud-agent-wallet-api_3 # 1.37.0-SNAPSHOT](https://github.com/hyperledger/identus-cloud-agent) | +none specified | []() | [org.hyperledger # connect-core_3 # 1.37.0-SNAPSHOT](https://github.com/hyperledger/identus-cloud-agent) | +none specified | []() | [org.hyperledger # connect-sql-doobie_3 # 1.37.0-SNAPSHOT](https://github.com/hyperledger/identus-cloud-agent) | +none specified | []() | [org.hyperledger # event-notification_3 # 1.37.0-SNAPSHOT](https://github.com/hyperledger/identus-cloud-agent) | +none specified | []() | [org.hyperledger # mercury-agent-core_3 # 1.37.0-SNAPSHOT](https://github.com/hyperledger/identus-cloud-agent) | +none specified | []() | [org.hyperledger # mercury-agent-didcommx_3 # 1.37.0-SNAPSHOT](https://github.com/hyperledger/identus-cloud-agent) | +none specified | []() | [org.hyperledger # mercury-data-models_3 # 1.37.0-SNAPSHOT](https://github.com/hyperledger/identus-cloud-agent) | +none specified | []() | [org.hyperledger # mercury-protocol-connection_3 # 1.37.0-SNAPSHOT](https://github.com/hyperledger/identus-cloud-agent) | +none specified | []() | [org.hyperledger # mercury-protocol-coordinate-mediation_3 # 1.37.0-SNAPSHOT](https://github.com/hyperledger/identus-cloud-agent) | +none specified | []() | [org.hyperledger # mercury-protocol-invitation_3 # 1.37.0-SNAPSHOT](https://github.com/hyperledger/identus-cloud-agent) | +none specified | []() | [org.hyperledger # mercury-protocol-issue-credential_3 # 1.37.0-SNAPSHOT](https://github.com/hyperledger/identus-cloud-agent) | +none specified | []() | [org.hyperledger # mercury-protocol-mailbox_3 # 1.37.0-SNAPSHOT](https://github.com/hyperledger/identus-cloud-agent) | +none specified | []() | [org.hyperledger # mercury-protocol-outofband-login_3 # 1.37.0-SNAPSHOT](https://github.com/hyperledger/identus-cloud-agent) | +none specified | []() | [org.hyperledger # mercury-protocol-present-proof_3 # 1.37.0-SNAPSHOT](https://github.com/hyperledger/identus-cloud-agent) | +none specified | []() | [org.hyperledger # mercury-protocol-report-problem_3 # 1.37.0-SNAPSHOT](https://github.com/hyperledger/identus-cloud-agent) | +none specified | []() | [org.hyperledger # mercury-protocol-revocation-notification_3 # 1.37.0-SNAPSHOT](https://github.com/hyperledger/identus-cloud-agent) | +none specified | []() | [org.hyperledger # mercury-protocol-routing-2-0_3 # 1.37.0-SNAPSHOT](https://github.com/hyperledger/identus-cloud-agent) | +none specified | []() | [org.hyperledger # mercury-protocol-trust-ping_3 # 1.37.0-SNAPSHOT](https://github.com/hyperledger/identus-cloud-agent) | +none specified | []() | [org.hyperledger # mercury-resolver_3 # 1.37.0-SNAPSHOT](https://github.com/hyperledger/identus-cloud-agent) | +none specified | []() | [org.hyperledger # mercury-verifiable-credentials_3 # 1.37.0-SNAPSHOT](https://github.com/hyperledger/identus-cloud-agent) | +none specified | []() | [org.hyperledger # pollux-anoncreds_3 # 1.37.0-SNAPSHOT](https://github.com/hyperledger/identus-cloud-agent) | +none specified | []() | [org.hyperledger # pollux-core_3 # 1.37.0-SNAPSHOT](https://github.com/hyperledger/identus-cloud-agent) | +none specified | []() | [org.hyperledger # pollux-sd-jwt_3 # 1.37.0-SNAPSHOT](https://github.com/hyperledger/identus-cloud-agent) | +none specified | []() | [org.hyperledger # pollux-sql-doobie_3 # 1.37.0-SNAPSHOT](https://github.com/hyperledger/identus-cloud-agent) | +none specified | []() | [org.hyperledger # pollux-vc-jwt_3 # 1.37.0-SNAPSHOT](https://github.com/hyperledger/identus-cloud-agent) | +none specified | []() | [org.hyperledger # prism-node-client_3 # 1.37.0-SNAPSHOT](https://github.com/hyperledger/identus-cloud-agent) | +none specified | []() | [org.hyperledger # shared # 1.37.0-SNAPSHOT](https://github.com/hyperledger/identus-cloud-agent) | +none specified | []() | [org.hyperledger # shared-crypto # 1.37.0-SNAPSHOT](https://github.com/hyperledger/identus-cloud-agent) | +none specified | []() | [org.hyperledger # shared-test # 1.37.0-SNAPSHOT](https://github.com/hyperledger/identus-cloud-agent) | diff --git a/cloud-agent/service/api/http/cloud-agent-openapi-spec.yaml b/cloud-agent/service/api/http/cloud-agent-openapi-spec.yaml index 588b1873af..fd0c0735af 100644 --- a/cloud-agent/service/api/http/cloud-agent-openapi-spec.yaml +++ b/cloud-agent/service/api/http/cloud-agent-openapi-spec.yaml @@ -1,7 +1,7 @@ openapi: 3.0.3 info: title: Identus Cloud Agent API Reference - version: 1.37.0 + version: 1.38.0 description: |2 The Identus Cloud Agent API facilitates the integration and management of self-sovereign identity capabilities within applications. @@ -5441,7 +5441,7 @@ components: type: string description: The date and time when the issue credential record was created. format: date-time - example: '2024-07-01T08:50:32.358710422Z' + example: '2024-07-15T12:05:35.865441004Z' updatedAt: type: string description: The date and time when the issue credential record was last @@ -6213,7 +6213,7 @@ components: type: string description: Issuance timestamp of status list credential format: date-time - example: '2024-07-01T08:50:32.396776613Z' + example: '2024-07-15T12:05:35.908034143Z' credentialSubject: $ref: '#/components/schemas/CredentialSubject' proof: diff --git a/infrastructure/charts/agent/Chart.yaml b/infrastructure/charts/agent/Chart.yaml index 15c98c9c8b..0445277f57 100644 --- a/infrastructure/charts/agent/Chart.yaml +++ b/infrastructure/charts/agent/Chart.yaml @@ -13,12 +13,12 @@ type: application # This is the chart version. This version number should be incremented each time you make changes # to the chart and its templates, including the app version. # Versions are expected to follow Semantic Versioning (https://semver.org/) -version: 1.37.0 +version: 1.38.0 # This is the version number of the application being deployed. This version number should be # incremented each time you make changes to the application. Versions are not expected to # follow Semantic Versioning. They should reflect the version the application is using. # It is recommended to use it with quotes. -appVersion: 1.37.0 +appVersion: 1.38.0 dependencies: - name: vault version: 0.24.1 diff --git a/infrastructure/charts/cloud-agent-1.38.0.tgz b/infrastructure/charts/cloud-agent-1.38.0.tgz new file mode 100644 index 0000000000000000000000000000000000000000..0135b9a6e5dcd4e16102ee583385c06421ffcb32 GIT binary patch literal 161333 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�xH2hSsNTg`_`e6Y$c=&hc{xfm^iK*`ge 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ƞ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 RUjP6A|Nlwqrzrrc5CA(?CDQ-^ literal 0 HcmV?d00001 diff --git a/infrastructure/charts/index.yaml b/infrastructure/charts/index.yaml index 71980545cb..97e5d96623 100644 --- a/infrastructure/charts/index.yaml +++ b/infrastructure/charts/index.yaml @@ -1,9 +1,27 @@ apiVersion: v1 entries: cloud-agent: + - apiVersion: v2 + appVersion: 1.38.0 + created: "2024-07-15T12:29:39.496521751Z" + dependencies: + - name: vault + repository: https://helm.releases.hashicorp.com + version: 0.24.1 + - condition: keycloak.enabled + name: keycloak + repository: https://charts.bitnami.com/bitnami + version: 17.2.0 + description: A Helm chart for deploying cloud-agent + digest: 3b1bd1dd295c3361dba8e0c4ec58994ed76a12bea5484938aa77805192fd452b + name: cloud-agent + type: application + urls: + - https://raw.githubusercontent.com/hyperledger/identus-cloud-agent/main/infrastructure/charts/cloud-agent-1.38.0.tgz + version: 1.38.0 - apiVersion: v2 appVersion: 1.37.0 - created: "2024-07-01T09:13:50.234308304Z" + created: "2024-07-15T12:29:39.487203135Z" dependencies: - name: vault repository: https://helm.releases.hashicorp.com @@ -21,7 +39,7 @@ entries: version: 1.37.0 - apiVersion: v2 appVersion: 1.36.1 - created: "2024-07-01T09:13:50.223902191Z" + created: "2024-07-15T12:29:39.476683729Z" dependencies: - name: vault repository: https://helm.releases.hashicorp.com @@ -39,7 +57,7 @@ entries: version: 1.36.1 - apiVersion: v2 appVersion: 1.36.0 - created: "2024-07-01T09:13:50.214857923Z" + created: "2024-07-15T12:29:39.4664606Z" dependencies: - name: vault repository: https://helm.releases.hashicorp.com @@ -57,7 +75,7 @@ entries: version: 1.36.0 - apiVersion: v2 appVersion: 1.35.1 - created: "2024-07-01T09:13:50.204930147Z" + created: "2024-07-15T12:29:39.456221677Z" dependencies: - name: vault repository: https://helm.releases.hashicorp.com @@ -75,7 +93,7 @@ entries: version: 1.35.1 - apiVersion: v2 appVersion: 1.35.0 - created: "2024-07-01T09:13:50.195078852Z" + created: "2024-07-15T12:29:39.445760067Z" dependencies: - name: vault repository: https://helm.releases.hashicorp.com @@ -93,7 +111,7 @@ entries: version: 1.35.0 - apiVersion: v2 appVersion: 1.34.0 - created: "2024-07-01T09:13:50.185271959Z" + created: "2024-07-15T12:29:39.435700056Z" dependencies: - name: vault repository: https://helm.releases.hashicorp.com @@ -112,7 +130,7 @@ entries: prism-agent: - apiVersion: v2 appVersion: 1.33.1 - created: "2024-07-01T09:13:50.461034123Z" + created: "2024-07-15T12:29:39.731773224Z" dependencies: - name: vault repository: https://helm.releases.hashicorp.com @@ -130,7 +148,7 @@ entries: version: 1.33.1 - apiVersion: v2 appVersion: 1.33.0 - created: "2024-07-01T09:13:50.450096134Z" + created: "2024-07-15T12:29:39.721534328Z" dependencies: - name: vault repository: https://helm.releases.hashicorp.com @@ -148,7 +166,7 @@ entries: version: 1.33.0 - apiVersion: v2 appVersion: 1.32.1 - created: "2024-07-01T09:13:50.440440387Z" + created: "2024-07-15T12:29:39.710669925Z" dependencies: - name: vault repository: https://helm.releases.hashicorp.com @@ -166,7 +184,7 @@ entries: version: 1.32.1 - apiVersion: v2 appVersion: 1.32.0 - created: "2024-07-01T09:13:50.429229489Z" + created: "2024-07-15T12:29:39.700959357Z" dependencies: - name: vault repository: https://helm.releases.hashicorp.com @@ -184,7 +202,7 @@ entries: version: 1.32.0 - apiVersion: v2 appVersion: 1.31.0 - created: "2024-07-01T09:13:50.420150878Z" + created: "2024-07-15T12:29:39.689846117Z" dependencies: - name: vault repository: https://helm.releases.hashicorp.com @@ -202,7 +220,7 @@ entries: version: 1.31.0 - apiVersion: v2 appVersion: 1.30.1 - created: "2024-07-01T09:13:50.40938063Z" + created: "2024-07-15T12:29:39.679147948Z" dependencies: - name: vault repository: https://helm.releases.hashicorp.com @@ -220,7 +238,7 @@ entries: version: 1.30.1 - apiVersion: v2 appVersion: 1.30.0 - created: "2024-07-01T09:13:50.398758055Z" + created: "2024-07-15T12:29:39.669134164Z" dependencies: - name: vault repository: https://helm.releases.hashicorp.com @@ -238,7 +256,7 @@ entries: version: 1.30.0 - apiVersion: v2 appVersion: 1.29.0 - created: "2024-07-01T09:13:50.388282635Z" + created: "2024-07-15T12:29:39.657444145Z" dependencies: - name: vault repository: https://helm.releases.hashicorp.com @@ -256,7 +274,7 @@ entries: version: 1.29.0 - apiVersion: v2 appVersion: 1.28.0 - created: "2024-07-01T09:13:50.378802886Z" + created: "2024-07-15T12:29:39.64729637Z" dependencies: - name: vault repository: https://helm.releases.hashicorp.com @@ -274,7 +292,7 @@ entries: version: 1.28.0 - apiVersion: v2 appVersion: 1.27.0 - created: "2024-07-01T09:13:50.368443349Z" + created: "2024-07-15T12:29:39.636142885Z" dependencies: - name: vault repository: https://helm.releases.hashicorp.com @@ -292,7 +310,7 @@ entries: version: 1.27.0 - apiVersion: v2 appVersion: 1.26.0 - created: "2024-07-01T09:13:50.358343839Z" + created: "2024-07-15T12:29:39.625580446Z" dependencies: - name: vault repository: https://helm.releases.hashicorp.com @@ -310,7 +328,7 @@ entries: version: 1.26.0 - apiVersion: v2 appVersion: 1.25.0 - created: "2024-07-01T09:13:50.348875871Z" + created: "2024-07-15T12:29:39.615551643Z" dependencies: - name: vault repository: https://helm.releases.hashicorp.com @@ -328,7 +346,7 @@ entries: version: 1.25.0 - apiVersion: v2 appVersion: 1.24.0 - created: "2024-07-01T09:13:50.339737128Z" + created: "2024-07-15T12:29:39.604404582Z" dependencies: - name: vault repository: https://helm.releases.hashicorp.com @@ -346,7 +364,7 @@ entries: version: 1.24.0 - apiVersion: v2 appVersion: 1.23.0 - created: "2024-07-01T09:13:50.328935996Z" + created: "2024-07-15T12:29:39.593553755Z" dependencies: - name: vault repository: https://helm.releases.hashicorp.com @@ -364,7 +382,7 @@ entries: version: 1.23.0 - apiVersion: v2 appVersion: 1.22.0 - created: "2024-07-01T09:13:50.318312167Z" + created: "2024-07-15T12:29:39.583832686Z" dependencies: - name: vault repository: https://helm.releases.hashicorp.com @@ -382,7 +400,7 @@ entries: version: 1.22.0 - apiVersion: v2 appVersion: 1.21.1 - created: "2024-07-01T09:13:50.307947402Z" + created: "2024-07-15T12:29:39.573324718Z" dependencies: - name: vault repository: https://helm.releases.hashicorp.com @@ -400,7 +418,7 @@ entries: version: 1.21.1 - apiVersion: v2 appVersion: 1.21.0 - created: "2024-07-01T09:13:50.298351541Z" + created: "2024-07-15T12:29:39.563170515Z" dependencies: - name: vault repository: https://helm.releases.hashicorp.com @@ -418,7 +436,7 @@ entries: version: 1.21.0 - apiVersion: v2 appVersion: 1.20.1 - created: "2024-07-01T09:13:50.289340676Z" + created: "2024-07-15T12:29:39.553469417Z" dependencies: - name: vault repository: https://helm.releases.hashicorp.com @@ -436,7 +454,7 @@ entries: version: 1.20.1 - apiVersion: v2 appVersion: 1.20.0 - created: "2024-07-01T09:13:50.279663615Z" + created: "2024-07-15T12:29:39.543477488Z" dependencies: - name: vault repository: https://helm.releases.hashicorp.com @@ -454,7 +472,7 @@ entries: version: 1.20.0 - apiVersion: v2 appVersion: 1.19.1 - created: "2024-07-01T09:13:50.269885862Z" + created: "2024-07-15T12:29:39.53342354Z" dependencies: - name: vault repository: https://helm.releases.hashicorp.com @@ -472,7 +490,7 @@ entries: version: 1.19.1 - apiVersion: v2 appVersion: 1.19.0 - created: "2024-07-01T09:13:50.259598085Z" + created: "2024-07-15T12:29:39.522721417Z" dependencies: - name: vault repository: https://helm.releases.hashicorp.com @@ -490,7 +508,7 @@ entries: version: 1.19.0 - apiVersion: v2 appVersion: 1.18.0 - created: "2024-07-01T09:13:50.249658531Z" + created: "2024-07-15T12:29:39.512436124Z" dependencies: - name: vault repository: https://helm.releases.hashicorp.com @@ -504,7 +522,7 @@ entries: version: 1.18.0 - apiVersion: v2 appVersion: 1.17.0 - created: "2024-07-01T09:13:50.246850237Z" + created: "2024-07-15T12:29:39.509538689Z" dependencies: - name: vault repository: https://helm.releases.hashicorp.com @@ -518,7 +536,7 @@ entries: version: 1.17.0 - apiVersion: v2 appVersion: 1.16.4 - created: "2024-07-01T09:13:50.242960911Z" + created: "2024-07-15T12:29:39.505838077Z" dependencies: - name: vault repository: https://helm.releases.hashicorp.com @@ -532,7 +550,7 @@ entries: version: 1.16.4 - apiVersion: v2 appVersion: 1.16.3 - created: "2024-07-01T09:13:50.239921005Z" + created: "2024-07-15T12:29:39.502999021Z" dependencies: - name: vault repository: https://helm.releases.hashicorp.com @@ -546,7 +564,7 @@ entries: version: 1.16.3 - apiVersion: v2 appVersion: 1.16.2 - created: "2024-07-01T09:13:50.237164297Z" + created: "2024-07-15T12:29:39.50018937Z" dependencies: - name: vault repository: https://helm.releases.hashicorp.com @@ -558,4 +576,4 @@ entries: urls: - https://raw.githubusercontent.com/hyperledger/identus-cloud-agent/main/infrastructure/charts/prism-agent-1.16.2.tgz version: 1.16.2 -generated: "2024-07-01T09:13:50.175364732Z" +generated: "2024-07-15T12:29:39.425487877Z" diff --git a/infrastructure/local/.env b/infrastructure/local/.env index d151c7359e..c2fe0a6b2a 100644 --- a/infrastructure/local/.env +++ b/infrastructure/local/.env @@ -1,3 +1,3 @@ -AGENT_VERSION=1.37.0 +AGENT_VERSION=1.38.0 PRISM_NODE_VERSION=2.3.0 VAULT_DEV_ROOT_TOKEN_ID=root diff --git a/package-lock.json b/package-lock.json index b2b54b9e17..03c7c280fc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "identus-cloud-agent", - "version": "1.37.0", + "version": "1.38.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "identus-cloud-agent", - "version": "1.37.0", + "version": "1.38.0", "devDependencies": { "@commitlint/cli": "^19.3.0", "@commitlint/config-conventional": "^19.2.2", diff --git a/package.json b/package.json index 84f56ed851..3b9750b32f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "identus-cloud-agent", - "version": "1.37.0", + "version": "1.38.0", "engines": { "node": ">=16.13.0" }, diff --git a/version.sbt b/version.sbt index 2e05788ede..163d9c0802 100644 --- a/version.sbt +++ b/version.sbt @@ -1 +1 @@ -ThisBuild / version := "1.37.0-SNAPSHOT" +ThisBuild / version := "1.38.0-SNAPSHOT" From d22745fc9589c71d038af60d8fc2c99d8cbd104a Mon Sep 17 00:00:00 2001 From: Fabio Pinheiro Date: Tue, 16 Jul 2024 13:38:52 +0100 Subject: [PATCH 12/13] fix: add reportProcessingFailure back in PresentationRepository (#1232) Signed-off-by: FabioPinheiro --- .../server/jobs/PresentBackgroundJobs.scala | 26 ++++---- .../repository/PresentationRepository.scala | 2 +- .../core/service/PresentationService.scala | 2 +- .../service/PresentationServiceImpl.scala | 9 +-- .../service/PresentationServiceNotifier.scala | 2 +- .../PresentationRepositoryInMemory.scala | 64 ++++++++++++------- .../repository/JdbcCredentialRepository.scala | 2 +- .../JdbcPresentationRepository.scala | 6 +- 8 files changed, 64 insertions(+), 49 deletions(-) diff --git a/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/agent/server/jobs/PresentBackgroundJobs.scala b/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/agent/server/jobs/PresentBackgroundJobs.scala index d82db1532a..c00bf0087d 100644 --- a/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/agent/server/jobs/PresentBackgroundJobs.scala +++ b/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/agent/server/jobs/PresentBackgroundJobs.scala @@ -55,7 +55,7 @@ object PresentBackgroundJobs extends BackgroundJobsHelper { private type MESSAGING_RESOURCES = DidOps & DIDResolver & HttpClient val presentProofExchanges: ZIO[RESOURCES, Throwable, Unit] = { - val presentProofDidComExchange = for { + for { presentationService <- ZIO.service[PresentationService] config <- ZIO.service[AppConfig] records <- presentationService @@ -72,7 +72,6 @@ object PresentBackgroundJobs extends BackgroundJobsHelper { .foreachPar(records)(performPresentProofExchange) .withParallelism(config.pollux.presentationBgJobProcessingParallelism) } yield () - presentProofDidComExchange } private def counterMetric(key: String) = Metric @@ -81,16 +80,19 @@ object PresentBackgroundJobs extends BackgroundJobsHelper { private def performPresentProofExchange(record: PresentationRecord): URIO[RESOURCES, Unit] = aux(record) - .tapError({ - (error: PresentationError | DIDSecretStorageError | BackgroundJobError | CredentialServiceError | - CastorDIDResolutionError | GetManagedDIDError | Failure) => - ZIO.logErrorCause( - s"Present Proof - Error processing record: ${record.id}", - Cause.fail(error) - ) - }) - .catchAll(e => ZIO.logErrorCause(s"Present Proof - Error processing record: ${record.id} ", Cause.fail(e))) - .catchAllDefect(d => ZIO.logErrorCause(s"Present Proof - Defect processing record: ${record.id}", Cause.fail(d))) + .catchAll { + case ex: Failure => + ZIO + .service[PresentationService] + .flatMap(_.reportProcessingFailure(record.id, Some(ex))) + .catchAll(ex => + ZIO.logErrorCause(s"PresentBackgroundJobs - Fail to recover from ${record.id}", Cause.fail(ex)) + ) + case ex => ZIO.logErrorCause(s"PresentBackgroundJobs - Error processing record: ${record.id}", Cause.fail(ex)) + } + .catchAllDefect(d => + ZIO.logErrorCause(s"PresentBackgroundJobs - Defect processing record: ${record.id}", Cause.fail(d)) + ) private def aux(record: PresentationRecord): ZIO[RESOURCES, ERROR, Unit] = { import org.hyperledger.identus.pollux.core.model.PresentationRecord.ProtocolState.* diff --git a/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/repository/PresentationRepository.scala b/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/repository/PresentationRepository.scala index 789a25473d..fffd057a59 100644 --- a/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/repository/PresentationRepository.scala +++ b/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/repository/PresentationRepository.scala @@ -74,5 +74,5 @@ trait PresentationRepository { def updateAfterFail( recordId: DidCommID, failReason: Option[Failure] - ): URIO[WalletAccessContext, Unit] + ): UIO[Unit] } diff --git a/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/service/PresentationService.scala b/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/service/PresentationService.scala index 6038278a1c..7bdc865887 100644 --- a/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/service/PresentationService.scala +++ b/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/service/PresentationService.scala @@ -169,6 +169,6 @@ trait PresentationService { def reportProcessingFailure( recordId: DidCommID, failReason: Option[Failure] - ): ZIO[WalletAccessContext, PresentationError, Unit] + ): IO[PresentationError, Unit] } diff --git a/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/service/PresentationServiceImpl.scala b/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/service/PresentationServiceImpl.scala index acdc2adc06..5f6ed6be39 100644 --- a/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/service/PresentationServiceImpl.scala +++ b/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/service/PresentationServiceImpl.scala @@ -1098,14 +1098,11 @@ private class PresentationServiceImpl( } yield result } - def reportProcessingFailure( + override def reportProcessingFailure( recordId: DidCommID, failReason: Option[Failure] - ): ZIO[WalletAccessContext, PresentationError, Unit] = - for { - _ <- getRecord(recordId) - result <- presentationRepository.updateAfterFail(recordId, failReason) - } yield result + ): IO[PresentationError, Unit] = + presentationRepository.updateAfterFail(recordId, failReason) private def getRecordFromThreadId( thid: DidCommID diff --git a/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/service/PresentationServiceNotifier.scala b/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/service/PresentationServiceNotifier.scala index c8558054c0..696b4dd5c3 100644 --- a/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/service/PresentationServiceNotifier.scala +++ b/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/service/PresentationServiceNotifier.scala @@ -281,7 +281,7 @@ class PresentationServiceNotifier( override def reportProcessingFailure( recordId: DidCommID, failReason: Option[Failure] - ): ZIO[WalletAccessContext, PresentationError, Unit] = svc.reportProcessingFailure(recordId, failReason) + ): IO[PresentationError, Unit] = svc.reportProcessingFailure(recordId, failReason) } object PresentationServiceNotifier { diff --git a/pollux/core/src/test/scala/org/hyperledger/identus/pollux/core/repository/PresentationRepositoryInMemory.scala b/pollux/core/src/test/scala/org/hyperledger/identus/pollux/core/repository/PresentationRepositoryInMemory.scala index 4f646f3bee..01d00ed4ea 100644 --- a/pollux/core/src/test/scala/org/hyperledger/identus/pollux/core/repository/PresentationRepositoryInMemory.scala +++ b/pollux/core/src/test/scala/org/hyperledger/identus/pollux/core/repository/PresentationRepositoryInMemory.scala @@ -30,6 +30,17 @@ class PresentationRepositoryInMemory( }(ZIO.succeed) } yield walletRef + private def anyWalletStoreRefBy( + recordId: DidCommID + ): ZIO[Any, Nothing, Option[Ref[Map[DidCommID, PresentationRecord]]]] = { + for { + refs <- walletRefs.get + // walletsNoRef <- ZIO.foreach(refs)({ case (wID, ref) => ref.get.map(r => (wID, r)) }) + tmp <- ZIO.foreach(refs)({ case (wID, ref) => ref.get.map(r => (ref, r)) }) + walletRef = tmp.find(e => e._2.keySet.contains(recordId)).map(_._1) + } yield walletRef + } + override def createPresentationRecord(record: PresentationRecord): URIO[WalletAccessContext, Unit] = { val result = for { @@ -324,35 +335,40 @@ class PresentationRepositoryInMemory( result.ensureOneAffectedRowOrDie } + // def updateAfterFailX( + // recordId: DidCommID, + // failReason: Option[Failure] + // ): URIO[WalletAccessContext, Unit] = { override def updateAfterFail( recordId: DidCommID, failReason: Option[Failure] - ): URIO[WalletAccessContext, Unit] = { - val result = - for { - storeRef <- walletStoreRef - maybeRecord <- findPresentationRecord(recordId) - count <- maybeRecord - .map(record => - for { - _ <- storeRef.update(r => - r.updated( - recordId, - record.copy( - metaRetries = math.max(0, record.metaRetries - 1), - metaNextRetry = - if (record.metaRetries - 1 <= 0) None - else Some(Instant.now().plusSeconds(60)), // TODO exponention time - metaLastFailure = failReason + ): UIO[Unit] = + anyWalletStoreRefBy(recordId).flatMap { mStoreRef => + mStoreRef match + case None => ZIO.succeed(0) + case Some(storeRef) => + for { + maybeRecord <- storeRef.get.map(store => store.get(recordId)) + count <- maybeRecord + .map(record => + for { + _ <- storeRef.update(r => + r.updated( + recordId, + record.copy( + metaRetries = math.max(0, record.metaRetries - 1), + metaNextRetry = + if (record.metaRetries - 1 <= 0) None + else Some(Instant.now().plusSeconds(60)), // TODO exponention time + metaLastFailure = failReason + ) + ) ) - ) + } yield 1 ) - } yield 1 - ) - .getOrElse(ZIO.succeed(0)) - } yield count - result.ensureOneAffectedRowOrDie - } + .getOrElse(ZIO.succeed(0)) + } yield count + }.ensureOneAffectedRowOrDie } object PresentationRepositoryInMemory { diff --git a/pollux/sql-doobie/src/main/scala/org/hyperledger/identus/pollux/sql/repository/JdbcCredentialRepository.scala b/pollux/sql-doobie/src/main/scala/org/hyperledger/identus/pollux/sql/repository/JdbcCredentialRepository.scala index 2cab203276..554d6c4852 100644 --- a/pollux/sql-doobie/src/main/scala/org/hyperledger/identus/pollux/sql/repository/JdbcCredentialRepository.scala +++ b/pollux/sql-doobie/src/main/scala/org/hyperledger/identus/pollux/sql/repository/JdbcCredentialRepository.scala @@ -548,7 +548,7 @@ class JdbcCredentialRepository(xa: Transactor[ContextAwareTask], xb: Transactor[ .ensureOneAffectedRowOrDie } - def updateAfterFail( + override def updateAfterFail( recordId: DidCommID, failReason: Option[Failure] ): URIO[WalletAccessContext, Unit] = { diff --git a/pollux/sql-doobie/src/main/scala/org/hyperledger/identus/pollux/sql/repository/JdbcPresentationRepository.scala b/pollux/sql-doobie/src/main/scala/org/hyperledger/identus/pollux/sql/repository/JdbcPresentationRepository.scala index 5d98bd1f23..57a22d2ee8 100644 --- a/pollux/sql-doobie/src/main/scala/org/hyperledger/identus/pollux/sql/repository/JdbcPresentationRepository.scala +++ b/pollux/sql-doobie/src/main/scala/org/hyperledger/identus/pollux/sql/repository/JdbcPresentationRepository.scala @@ -489,10 +489,10 @@ class JdbcPresentationRepository( .ensureOneAffectedRowOrDie } - def updateAfterFail( + override def updateAfterFail( recordId: DidCommID, failReason: Option[Failure] - ): URIO[WalletAccessContext, Unit] = { + ): UIO[Unit] = { val cxnIO = sql""" | UPDATE public.presentation_records | SET @@ -503,7 +503,7 @@ class JdbcPresentationRepository( | id = $recordId """.stripMargin.update cxnIO.run - .transactWallet(xa) + .transact(xb) .orDie .ensureOneAffectedRowOrDie } From f999f303f876d97287f217e00bdbc1bbcd193495 Mon Sep 17 00:00:00 2001 From: patlo-iog Date: Tue, 16 Jul 2024 22:14:42 +0700 Subject: [PATCH 13/13] docs: oid4vci example and docs [skip ci] (#1265) Signed-off-by: Pat Losoponkul Signed-off-by: patlo-iog Signed-off-by: Hyperledger Bot Co-authored-by: Yurii Shynbuiev - IOHK Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Hyperledger Bot --- docs/docusaurus/credentials/issue.md | 2 +- docs/docusaurus/credentials/oid4vci.md | 74 +++++++++++++++++++ docs/docusaurus/sidebars.js | 5 +- docs/general/authserver-oid4vci-contract.md | 72 ++++++++++++++++++ examples/.nickel/caddy.ncl | 2 +- examples/.nickel/root.ncl | 2 +- examples/.nickel/stack.ncl | 2 +- examples/.nickel/versions.ncl | 2 +- examples/mt-keycloak-vault/README.md | 2 +- examples/mt-keycloak-vault/compose.yaml | 8 +- .../hurl/01_create_users.hurl | 6 +- .../mt-keycloak-vault/hurl/02_jwt_flow.hurl | 40 +++++----- examples/mt-keycloak/README.md | 2 +- examples/mt-keycloak/compose.yaml | 8 +- .../mt-keycloak/hurl/01_create_users.hurl | 6 +- examples/mt-keycloak/hurl/02_jwt_flow.hurl | 44 +++++------ examples/mt/README.md | 2 +- examples/mt/compose.yaml | 8 +- examples/st-multi/README.me | 10 +-- examples/st-multi/compose.yaml | 24 +++--- examples/st-multi/hurl/01_jwt_flow.hurl | 44 +++++------ examples/st-oid4vci/README.md | 26 ++----- examples/st-oid4vci/compose.yaml | 10 +-- examples/st-oid4vci/demo/Dockerfile | 6 ++ examples/st-oid4vci/{demo.py => demo/main.py} | 49 ++++++------ examples/st-oid4vci/demo/requirements.txt | 11 +++ examples/st-vault/README.md | 2 +- examples/st-vault/compose.yaml | 8 +- examples/st/README.md | 6 +- examples/st/compose.yaml | 8 +- 30 files changed, 321 insertions(+), 170 deletions(-) create mode 100644 docs/docusaurus/credentials/oid4vci.md create mode 100644 docs/general/authserver-oid4vci-contract.md create mode 100644 examples/st-oid4vci/demo/Dockerfile rename examples/st-oid4vci/{demo.py => demo/main.py} (90%) create mode 100644 examples/st-oid4vci/demo/requirements.txt diff --git a/docs/docusaurus/credentials/issue.md b/docs/docusaurus/credentials/issue.md index 30646fa8e0..b392d310c2 100644 --- a/docs/docusaurus/credentials/issue.md +++ b/docs/docusaurus/credentials/issue.md @@ -1,7 +1,7 @@ import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; -# Issue credentials +# Issue credentials (DIDComm) In the Identus Platform, the [Issue Credentials Protocol](/docs/concepts/glossary#issue-credentials-protocol) allows you to create, retrieve, and manage issued [verifiable credentials (VCs)](/docs/concepts/glossary#verifiable-credentials) between a VC issuer and a VC holder. diff --git a/docs/docusaurus/credentials/oid4vci.md b/docs/docusaurus/credentials/oid4vci.md new file mode 100644 index 0000000000..a84b7c7dfe --- /dev/null +++ b/docs/docusaurus/credentials/oid4vci.md @@ -0,0 +1,74 @@ +# Issue credentials (OID4VCI) + +[OID4VCI](/docs/concepts/glossary#oid4vci) (OpenID for Verifiable Credential Issuance) is a protocol that extends OAuth2 to issue credentials. +It involves a Credential Issuer server and an Authorization server working together, +using the authorization and token endpoints on the Authorization Server to grant holders access to credentials on the Credential Issuer server. +These servers may or may not be the same, depending on the implementation. + +The Identus Cloud Agent can act as a Credential Issuer server and integrate with any Authorization Server that follows the integration contract. The contract for the Authorization Server in the OID4VCI flow can be found [here](https://github.com/hyperledger/identus-cloud-agent/blob/main/docs/general/authserver-oid4vci-contract.md). + +## Example: OID4VCI Authorization Code Issuance + +Example is available [here](https://github.com/hyperledger/identus-cloud-agent/tree/main/examples/st-oid4vci). + +Following the instructions, the example demonstrates a single-tenant agent setup using an external Keycloak as the Issuer Authorization Server. The demo application walks through the authorization code issuance flow step-by-step. + +#### 1. Launching Local Example Stack + +```bash +docker-compose up +``` + +After running the `docker-compose up` command, all the containers should be running and initialized with the necessary configurations. The following logs should appear indicating that the stack is ready to execute the flow + +``` + _ _ _ _ _ + | |_| |_| |_ _ __| | | ___ + | ' \ _| _| '_ \_ _(_-< + |_||_\__|\__| .__/ |_|/__/ + |_| + 2024-07-16_11:51:01.301 INFO o.h.b.s.BlazeServerBuilder@L424:[ZScheduler-Worker-5] {} - http4s v0.23.23 on blaze v0.23.15 started at http://0.0.0.0:8085/ + +``` + +#### 2. Building the demo application + +```bash +docker build -t identus-oid4vci-demo:latest ./demo +``` + +#### 3. Running the demo application + +```bash +docker run --network -it identus-oid4vci-demo:latest +``` +The parameter `NETWORK_NAME` should be the same as the network name in docker-compose. +This name can be discovered by running the `docker network ls` command. + +The demo application acts as both issuer and Holder in the same script. +See the source code for detailed steps on how to implement this flow. +The demo application will interactively prompt the next step in the issuance flow. +Keep continuing until this log appears asking the user to log in using the browser. + +``` +############################## + +Open this link in the browser to login + +http://localhost:9980/realms/students/protocol/openid-connect/auth?redirect_uri=..... + +############################## + +wating for authorization redirect ... +``` + +Open this URL in the browser. Enter `alice` for the username and `1234` for the password. + +After a successful login, this log should appear indicating the demo application has received the credentials. + +``` +::::: Credential Received ::::: +{ + "credential": "eyJ0eXAiOiJKV1QiLC...SK1vJK-fx6zjXw" +} +``` diff --git a/docs/docusaurus/sidebars.js b/docs/docusaurus/sidebars.js index 7e1712fa68..b746ea93f5 100644 --- a/docs/docusaurus/sidebars.js +++ b/docs/docusaurus/sidebars.js @@ -20,8 +20,9 @@ const sidebars = { }, items: [ 'credentials/issue', + 'credentials/oid4vci', 'credentials/present-proof', - 'credentials/revocation', + 'credentials/revocation' ] }, { @@ -96,7 +97,7 @@ const sidebars = { 'multitenancy/tenant-onboarding-ext-iam', 'multitenancy/tenant-onboarding-self-service', 'multitenancy/tenant-migration', - 'multitenancy/admin-authz-ext-iam', + 'multitenancy/admin-authz-ext-iam' ] } ] diff --git a/docs/general/authserver-oid4vci-contract.md b/docs/general/authserver-oid4vci-contract.md new file mode 100644 index 0000000000..3260389333 --- /dev/null +++ b/docs/general/authserver-oid4vci-contract.md @@ -0,0 +1,72 @@ +# OID4VCI Authorization Server contract + +Identus Cloud Agent supports OID4VCI while allowing users to plug-in their authorization server. +The agent provides a credential endpoint, while the authorization server handles the authorization and token endpoints. +This flexibility enables integration with any existing authorization server potentially containing the holder user base. +The issuance flow implementation requires the authorization server to adhere to the contract, ensuring the agent can manage the issuance session and coordinate the process. + +The Identus platform also provides the [Keycloak plugin reference implementation](https://github.com/hyperledger/identus-keycloak-plugins) showcasing the agent capability. +However, the authorization server is not limited to only Keycloak. + +## Contract for Authorization Code issuance flow + +The sequence diagram is largely based on [OID4VCI spec](https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-authorization-code-flow) +with slight modification to the __TokenEndpoint__. + +```mermaid +sequenceDiagram + participant Holder + participant Issuer + participant AuthServer + participant CloudAgent + + Issuer ->>+ CloudAgent: Create CredentialOffer + CloudAgent ->>- Issuer: CredentialOffer
(issuer_state) + Issuer -->> Holder: Present offer
(issuer_state) + Holder ->>+ CloudAgent: Discover Metadata + CloudAgent ->>- Holder: IssuerMetadata + Holder ->>+ AuthServer: Discover Metadata + AuthServer ->>- Holder: AuthServerMetadata + Holder ->>+ AuthServer: AuthorizationRequest
(issuer_state) + AuthServer ->>- Holder: AuthorizationResponse
(code) + Holder ->>+ AuthServer: TokenRequest
(code) + AuthServer ->>+ CloudAgent: NonceRequest
(issuer_state) + CloudAgent ->>- AuthServer: NonceResponse
(c_nonce) + AuthServer ->>- Holder: TokenResponse
(c_nonce) + Holder ->>+ CloudAgent: CredentialRequest
(proof) + CloudAgent ->>- Holder: CredentialResponse +``` + + +### Authorization Endpoint + +1. Authorization `scope` MUST be configured in the Authorization Server to the same value as in Credential Issuer Metadata +2. The endpoint MUST accept the parameter `issuer_state` in the [__AuthorizationRequest__](https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#section-5.1.3-2.3) and recall this value in the subsequent call on the __TokenEndpoint__ + +### Token Endpoint + +1. When the holder makes a __TokenRequest__ to the __TokenEndpoint__, the __AuthorizationServer__ MUST recall the `issuer_state` parameter and make an HTTP call to the `/oid4vci/nonces` endpoint in the Cloud Agent using the following format. + +__NonceRequest__ + +``` +POST /oid4vci/nonces +Authorization: Bearer + +{ + "issuerState": "" +} +``` +Where `JWT_TOKEN` is a valid token issued by the __AuthorizationServer__. + +__NonceResponse__ + +``` +{ + "nonce": "", + "nonceExpiresIn": +} +``` + +2. The [__TokenResponse__](https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-successful-token-response) must include `c_nonce` and `c_nonce_expires_in` parameter in the __TokenResponse__ + diff --git a/examples/.nickel/caddy.ncl b/examples/.nickel/caddy.ncl index 03aa4e0bd7..153ce9c879 100644 --- a/examples/.nickel/caddy.ncl +++ b/examples/.nickel/caddy.ncl @@ -25,7 +25,7 @@ in handle_path /didcomm* { reverse_proxy %{args.agent.host}:%{std.to_string args.agent.didcommPort} } - handle_path /agent* { + handle_path /cloud-agent* { reverse_proxy %{args.agent.host}:%{std.to_string args.agent.restPort} } handle_path /keycloak* { diff --git a/examples/.nickel/root.ncl b/examples/.nickel/root.ncl index 1276ba261b..5fa174acc2 100644 --- a/examples/.nickel/root.ncl +++ b/examples/.nickel/root.ncl @@ -21,7 +21,7 @@ in name = "issuer", port = 9980, realm = "students", - extraEnvs = { IDENTUS_URL = "http://caddy-issuer:8080/prism-agent" } + extraEnvs = { IDENTUS_URL = "http://caddy-issuer:8080/cloud-agent" } } ), diff --git a/examples/.nickel/stack.ncl b/examples/.nickel/stack.ncl index e722ae0551..323ec5d6ce 100644 --- a/examples/.nickel/stack.ncl +++ b/examples/.nickel/stack.ncl @@ -159,7 +159,7 @@ in agentDb = makeSharedDbConfig "agent", node = { host = "node" }, didcommServiceUrl = "http://%{hosts.caddy}:%{std.to_string args.port}/didcomm", - restServiceUrl = "http://%{hosts.caddy}:%{std.to_string args.port}/agent", + restServiceUrl = "http://%{hosts.caddy}:%{std.to_string args.port}/cloud-agent", apikeyEnabled = args.apikeyEnabled, } & ( diff --git a/examples/.nickel/versions.ncl b/examples/.nickel/versions.ncl index 5fd5259c61..7ff18ae327 100644 --- a/examples/.nickel/versions.ncl +++ b/examples/.nickel/versions.ncl @@ -1,6 +1,6 @@ { # identus - agent = "1.37.0", + agent = "1.38.0", node = "2.4.0", identusKeycloak = "0.2.0", # 3rd party diff --git a/examples/mt-keycloak-vault/README.md b/examples/mt-keycloak-vault/README.md index fc6d4a2ac2..207927dfcd 100644 --- a/examples/mt-keycloak-vault/README.md +++ b/examples/mt-keycloak-vault/README.md @@ -2,7 +2,7 @@ | Exposed Service | Description | |---------------------------------|--------------------------| -| `localhost:8080/prism-agent` | Multi-tenant Cloud Agent | +| `localhost:8080/cloudagent` | Multi-tenant Cloud Agent | | `localhost:8080/keycloak/admin` | Keycloak | | `localhost:8200` | Vault | diff --git a/examples/mt-keycloak-vault/compose.yaml b/examples/mt-keycloak-vault/compose.yaml index f86bdffcd3..6a313e1e19 100644 --- a/examples/mt-keycloak-vault/compose.yaml +++ b/examples/mt-keycloak-vault/compose.yaml @@ -5,7 +5,7 @@ configs: handle_path /didcomm* { reverse_proxy agent-default:8090 } - handle_path /agent* { + handle_path /cloud-agent* { reverse_proxy agent-default:8085 } handle_path /keycloak* { @@ -46,14 +46,14 @@ services: POLLUX_DB_PASSWORD: postgres POLLUX_DB_PORT: '5432' POLLUX_DB_USER: postgres - POLLUX_STATUS_LIST_REGISTRY_PUBLIC_URL: http://caddy-default:8080/agent + POLLUX_STATUS_LIST_REGISTRY_PUBLIC_URL: http://caddy-default:8080/cloud-agent PRISM_NODE_HOST: node PRISM_NODE_PORT: '50053' - REST_SERVICE_URL: http://caddy-default:8080/agent + REST_SERVICE_URL: http://caddy-default:8080/cloud-agent SECRET_STORAGE_BACKEND: vault VAULT_ADDR: http://vault-default:8200 VAULT_TOKEN: admin - image: ghcr.io/hyperledger/identus-cloud-agent:1.37.0 + image: ghcr.io/hyperledger/identus-cloud-agent:1.38.0 restart: always caddy-default: configs: diff --git a/examples/mt-keycloak-vault/hurl/01_create_users.hurl b/examples/mt-keycloak-vault/hurl/01_create_users.hurl index 2576c26564..0e5d13854a 100644 --- a/examples/mt-keycloak-vault/hurl/01_create_users.hurl +++ b/examples/mt-keycloak-vault/hurl/01_create_users.hurl @@ -66,7 +66,7 @@ HTTP 200 issuer_access_token: jsonpath "$.access_token" # Create Issuer wallet -POST {{ agent_url }}/agent/wallets +POST {{ agent_url }}/cloud-agent/wallets Authorization: Bearer {{ issuer_access_token }} { "name": "issuer-wallet" @@ -85,7 +85,7 @@ HTTP 200 holder_access_token: jsonpath "$.access_token" # Create Holder wallet -POST {{ agent_url }}/agent/wallets +POST {{ agent_url }}/cloud-agent/wallets Authorization: Bearer {{ holder_access_token }} { "name": "holder-wallet" @@ -104,7 +104,7 @@ HTTP 200 verifier_access_token: jsonpath "$.access_token" # Create Verifier wallet -POST {{ agent_url }}/agent/wallets +POST {{ agent_url }}/cloud-agent/wallets Authorization: Bearer {{ verifier_access_token }} { "name": "verifier-wallet" diff --git a/examples/mt-keycloak-vault/hurl/02_jwt_flow.hurl b/examples/mt-keycloak-vault/hurl/02_jwt_flow.hurl index 4f7ca2e697..6e0c8edcab 100644 --- a/examples/mt-keycloak-vault/hurl/02_jwt_flow.hurl +++ b/examples/mt-keycloak-vault/hurl/02_jwt_flow.hurl @@ -39,7 +39,7 @@ verifier_access_token: jsonpath "$.access_token" # Prerequisites ############################## # Issuer create DID -POST {{ agent_url }}/agent/did-registrar/dids +POST {{ agent_url }}/cloud-agent/did-registrar/dids Authorization: Bearer {{ issuer_access_token }} { "documentTemplate": { @@ -57,7 +57,7 @@ HTTP 201 issuer_did: jsonpath "$.longFormDid" # regex "(did:prism:[a-z0-9]+):.+$" # Holder create DID -POST {{ agent_url }}/agent/did-registrar/dids +POST {{ agent_url }}/cloud-agent/did-registrar/dids Authorization: Bearer {{ holder_access_token }} { "documentTemplate": { @@ -78,7 +78,7 @@ holder_did: jsonpath "$.longFormDid" regex "(did:prism:[a-z0-9]+):.+$" # Issuance Connection ############################## # Inviter create connection -POST {{ agent_url }}/agent/connections +POST {{ agent_url }}/cloud-agent/connections Authorization: Bearer {{ issuer_access_token }} { "label": "My Connection" @@ -89,7 +89,7 @@ raw_invitation: jsonpath "$.invitation.invitationUrl" regex ".*_oob=(.*)$" issuer_connection_id: jsonpath "$.connectionId" # Invitee accept connection -POST {{ agent_url }}/agent/connection-invitations +POST {{ agent_url }}/cloud-agent/connection-invitations Authorization: Bearer {{ holder_access_token }} { "invitation": "{{ raw_invitation }}" @@ -99,7 +99,7 @@ HTTP 200 holder_connection_id: jsonpath "$.connectionId" # Wait for inviter connection status -GET {{ agent_url }}/agent/connections/{{ issuer_connection_id }} +GET {{ agent_url }}/cloud-agent/connections/{{ issuer_connection_id }} Authorization: Bearer {{ issuer_access_token }} [Options] retry: -1 @@ -108,7 +108,7 @@ HTTP 200 jsonpath "$.state" == "ConnectionResponseSent" # Wait for invitee connection status -GET {{ agent_url }}/agent/connections/{{ holder_connection_id }} +GET {{ agent_url }}/cloud-agent/connections/{{ holder_connection_id }} Authorization: Bearer {{ holder_access_token }} [Options] retry: -1 @@ -120,7 +120,7 @@ jsonpath "$.state" == "ConnectionResponseReceived" # Issuance ############################## # Issuer create credential offer -POST {{ agent_url }}/agent/issue-credentials/credential-offers +POST {{ agent_url }}/cloud-agent/issue-credentials/credential-offers Authorization: Bearer {{ issuer_access_token }} { "claims": { @@ -138,7 +138,7 @@ issuer_cred_record_id: jsonpath "$.recordId" didcomm_issuing_thid: jsonpath "$.thid" # Holder wait for OfferReceived state -GET {{ agent_url }}/agent/issue-credentials/records +GET {{ agent_url }}/cloud-agent/issue-credentials/records Authorization: Bearer {{ holder_access_token }} [QueryStringParams] thid: {{ didcomm_issuing_thid }} @@ -151,7 +151,7 @@ jsonpath "$.contents[0].protocolState" == "OfferReceived" holder_cred_record_id: jsonpath "$.contents[0].recordId" # Holder accept a credential-offer -POST {{ agent_url }}/agent/issue-credentials/records/{{ holder_cred_record_id }}/accept-offer +POST {{ agent_url }}/cloud-agent/issue-credentials/records/{{ holder_cred_record_id }}/accept-offer Authorization: Bearer {{ holder_access_token }} { "subjectId": "{{ holder_did }}" @@ -159,7 +159,7 @@ Authorization: Bearer {{ holder_access_token }} HTTP 200 # Holder wait for CredentialReceived state -GET {{ agent_url }}/agent/issue-credentials/records/{{ holder_cred_record_id }} +GET {{ agent_url }}/cloud-agent/issue-credentials/records/{{ holder_cred_record_id }} Authorization: Bearer {{ holder_access_token }} [Options] retry: -1 @@ -168,7 +168,7 @@ HTTP 200 jsonpath "$.protocolState" == "CredentialReceived" # Issuer wait for CredentialSent state -GET {{ agent_url }}/agent/issue-credentials/records/{{ issuer_cred_record_id }} +GET {{ agent_url }}/cloud-agent/issue-credentials/records/{{ issuer_cred_record_id }} Authorization: Bearer {{ issuer_access_token }} [Options] retry: -1 @@ -180,7 +180,7 @@ jsonpath "$.protocolState" == "CredentialSent" # Presentation Connection ############################## # Inviter create connection -POST {{ agent_url }}/agent/connections +POST {{ agent_url }}/cloud-agent/connections Authorization: Bearer {{ verifier_access_token }} { "label": "My Connection" @@ -191,7 +191,7 @@ raw_invitation: jsonpath "$.invitation.invitationUrl" regex ".*_oob=(.*)$" verifier_connection_id: jsonpath "$.connectionId" # Invitee accept connection -POST {{ agent_url }}/agent/connection-invitations +POST {{ agent_url }}/cloud-agent/connection-invitations Authorization: Bearer {{ holder_access_token }} { "invitation": "{{ raw_invitation }}" @@ -201,7 +201,7 @@ HTTP 200 holder_connection_id: jsonpath "$.connectionId" # Wait for inviter connection status -GET {{ agent_url }}/agent/connections/{{ verifier_connection_id }} +GET {{ agent_url }}/cloud-agent/connections/{{ verifier_connection_id }} Authorization: Bearer {{ verifier_access_token }} [Options] retry: -1 @@ -210,7 +210,7 @@ HTTP 200 jsonpath "$.state" == "ConnectionResponseSent" # Wait for invitee connection status -GET {{ agent_url }}/agent/connections/{{ holder_connection_id }} +GET {{ agent_url }}/cloud-agent/connections/{{ holder_connection_id }} Authorization: Bearer {{ holder_access_token }} [Options] retry: -1 @@ -222,7 +222,7 @@ jsonpath "$.state" == "ConnectionResponseReceived" # Presentation ############################## # Verifier create presentation request -POST {{ agent_url }}/agent/present-proof/presentations +POST {{ agent_url }}/cloud-agent/present-proof/presentations Authorization: Bearer {{ verifier_access_token }} { "connectionId": "{{ verifier_connection_id }}", @@ -238,7 +238,7 @@ verifier_presentation_id: jsonpath "$.presentationId" didcomm_presentation_thid: jsonpath "$.thid" # Holder wait for RequestReceived state -GET {{ agent_url }}/agent/present-proof/presentations +GET {{ agent_url }}/cloud-agent/present-proof/presentations Authorization: Bearer {{ holder_access_token }} [QueryStringParams] thid: {{ didcomm_presentation_thid }} @@ -251,7 +251,7 @@ jsonpath "$.contents[0].status" == "RequestReceived" holder_presentation_id: jsonpath "$.contents[0].presentationId" # Holder accept presentation request -PATCH {{ agent_url }}/agent/present-proof/presentations/{{ holder_presentation_id }} +PATCH {{ agent_url }}/cloud-agent/present-proof/presentations/{{ holder_presentation_id }} Authorization: Bearer {{ holder_access_token }} { "action": "request-accept", @@ -260,7 +260,7 @@ Authorization: Bearer {{ holder_access_token }} HTTP 200 # Holder wait for PresentationSent state -GET {{ agent_url }}/agent/present-proof/presentations +GET {{ agent_url }}/cloud-agent/present-proof/presentations Authorization: Bearer {{ holder_access_token }} [QueryStringParams] thid: {{ didcomm_presentation_thid }} @@ -271,7 +271,7 @@ HTTP 200 jsonpath "$.contents[0].status" == "PresentationSent" # Verfiier wait for PresentationVerified state -GET {{ agent_url }}/agent/present-proof/presentations +GET {{ agent_url }}/cloud-agent/present-proof/presentations Authorization: Bearer {{ verifier_access_token }} [QueryStringParams] thid: {{ didcomm_presentation_thid }} diff --git a/examples/mt-keycloak/README.md b/examples/mt-keycloak/README.md index 2e1d454908..373b8a4bfa 100644 --- a/examples/mt-keycloak/README.md +++ b/examples/mt-keycloak/README.md @@ -2,7 +2,7 @@ | Exposed Service | Description | |---------------------------------|--------------------------| -| `localhost:8080/prism-agent` | Multi-tenant Cloud Agent | +| `localhost:8080/cloud-agent` | Multi-tenant Cloud Agent | | `localhost:8080/keycloak/admin` | Keycloak | __Keycloak__ diff --git a/examples/mt-keycloak/compose.yaml b/examples/mt-keycloak/compose.yaml index 8672828e72..ea2b55a150 100644 --- a/examples/mt-keycloak/compose.yaml +++ b/examples/mt-keycloak/compose.yaml @@ -5,7 +5,7 @@ configs: handle_path /didcomm* { reverse_proxy agent-default:8090 } - handle_path /agent* { + handle_path /cloud-agent* { reverse_proxy agent-default:8085 } handle_path /keycloak* { @@ -46,12 +46,12 @@ services: POLLUX_DB_PASSWORD: postgres POLLUX_DB_PORT: '5432' POLLUX_DB_USER: postgres - POLLUX_STATUS_LIST_REGISTRY_PUBLIC_URL: http://caddy-default:8080/agent + POLLUX_STATUS_LIST_REGISTRY_PUBLIC_URL: http://caddy-default:8080/cloud-agent PRISM_NODE_HOST: node PRISM_NODE_PORT: '50053' - REST_SERVICE_URL: http://caddy-default:8080/agent + REST_SERVICE_URL: http://caddy-default:8080/cloud-agent SECRET_STORAGE_BACKEND: postgres - image: ghcr.io/hyperledger/identus-cloud-agent:1.37.0 + image: ghcr.io/hyperledger/identus-cloud-agent:1.38.0 restart: always caddy-default: configs: diff --git a/examples/mt-keycloak/hurl/01_create_users.hurl b/examples/mt-keycloak/hurl/01_create_users.hurl index 2576c26564..0e5d13854a 100644 --- a/examples/mt-keycloak/hurl/01_create_users.hurl +++ b/examples/mt-keycloak/hurl/01_create_users.hurl @@ -66,7 +66,7 @@ HTTP 200 issuer_access_token: jsonpath "$.access_token" # Create Issuer wallet -POST {{ agent_url }}/agent/wallets +POST {{ agent_url }}/cloud-agent/wallets Authorization: Bearer {{ issuer_access_token }} { "name": "issuer-wallet" @@ -85,7 +85,7 @@ HTTP 200 holder_access_token: jsonpath "$.access_token" # Create Holder wallet -POST {{ agent_url }}/agent/wallets +POST {{ agent_url }}/cloud-agent/wallets Authorization: Bearer {{ holder_access_token }} { "name": "holder-wallet" @@ -104,7 +104,7 @@ HTTP 200 verifier_access_token: jsonpath "$.access_token" # Create Verifier wallet -POST {{ agent_url }}/agent/wallets +POST {{ agent_url }}/cloud-agent/wallets Authorization: Bearer {{ verifier_access_token }} { "name": "verifier-wallet" diff --git a/examples/mt-keycloak/hurl/02_jwt_flow.hurl b/examples/mt-keycloak/hurl/02_jwt_flow.hurl index 4565f4e877..41999eeb87 100644 --- a/examples/mt-keycloak/hurl/02_jwt_flow.hurl +++ b/examples/mt-keycloak/hurl/02_jwt_flow.hurl @@ -39,7 +39,7 @@ verifier_access_token: jsonpath "$.access_token" # Prerequisites ############################## # Issuer create DID -POST {{ agent_url }}/agent/did-registrar/dids +POST {{ agent_url }}/cloud-agent/did-registrar/dids Authorization: Bearer {{ issuer_access_token }} { "documentTemplate": { @@ -57,12 +57,12 @@ HTTP 201 issuer_did: jsonpath "$.longFormDid" regex "(did:prism:[a-z0-9]+):.+$" # Issuer publish DID -POST {{ agent_url }}/agent/did-registrar/dids/{{ issuer_did }}/publications +POST {{ agent_url }}/cloud-agent/did-registrar/dids/{{ issuer_did }}/publications Authorization: Bearer {{ issuer_access_token }} HTTP 202 # Issuer wait for DID to be published -GET {{ agent_url }}/agent/did-registrar/dids/{{ issuer_did }} +GET {{ agent_url }}/cloud-agent/did-registrar/dids/{{ issuer_did }} Authorization: Bearer {{ issuer_access_token }} [Options] retry: -1 @@ -71,7 +71,7 @@ HTTP 200 jsonpath "$.status" == "PUBLISHED" # Holder create DID -POST {{ agent_url }}/agent/did-registrar/dids +POST {{ agent_url }}/cloud-agent/did-registrar/dids Authorization: Bearer {{ holder_access_token }} { "documentTemplate": { @@ -92,7 +92,7 @@ holder_did: jsonpath "$.longFormDid" # Issuance Connection ############################## # Inviter create connection -POST {{ agent_url }}/agent/connections +POST {{ agent_url }}/cloud-agent/connections Authorization: Bearer {{ issuer_access_token }} { "label": "My Connection" @@ -103,7 +103,7 @@ raw_invitation: jsonpath "$.invitation.invitationUrl" regex ".*_oob=(.*)$" issuer_connection_id: jsonpath "$.connectionId" # Invitee accept connection -POST {{ agent_url }}/agent/connection-invitations +POST {{ agent_url }}/cloud-agent/connection-invitations Authorization: Bearer {{ holder_access_token }} { "invitation": "{{ raw_invitation }}" @@ -113,7 +113,7 @@ HTTP 200 holder_connection_id: jsonpath "$.connectionId" # Wait for inviter connection status -GET {{ agent_url }}/agent/connections/{{ issuer_connection_id }} +GET {{ agent_url }}/cloud-agent/connections/{{ issuer_connection_id }} Authorization: Bearer {{ issuer_access_token }} [Options] retry: -1 @@ -122,7 +122,7 @@ HTTP 200 jsonpath "$.state" == "ConnectionResponseSent" # Wait for invitee connection status -GET {{ agent_url }}/agent/connections/{{ holder_connection_id }} +GET {{ agent_url }}/cloud-agent/connections/{{ holder_connection_id }} Authorization: Bearer {{ holder_access_token }} [Options] retry: -1 @@ -134,7 +134,7 @@ jsonpath "$.state" == "ConnectionResponseReceived" # Issuance ############################## # Issuer create credential offer -POST {{ agent_url }}/agent/issue-credentials/credential-offers +POST {{ agent_url }}/cloud-agent/issue-credentials/credential-offers Authorization: Bearer {{ issuer_access_token }} { "claims": { @@ -152,7 +152,7 @@ issuer_cred_record_id: jsonpath "$.recordId" didcomm_issuing_thid: jsonpath "$.thid" # Holder wait for OfferReceived state -GET {{ agent_url }}/agent/issue-credentials/records +GET {{ agent_url }}/cloud-agent/issue-credentials/records Authorization: Bearer {{ holder_access_token }} [QueryStringParams] thid: {{ didcomm_issuing_thid }} @@ -165,7 +165,7 @@ jsonpath "$.contents[0].protocolState" == "OfferReceived" holder_cred_record_id: jsonpath "$.contents[0].recordId" # Holder accept a credential-offer -POST {{ agent_url }}/agent/issue-credentials/records/{{ holder_cred_record_id }}/accept-offer +POST {{ agent_url }}/cloud-agent/issue-credentials/records/{{ holder_cred_record_id }}/accept-offer Authorization: Bearer {{ holder_access_token }} { "subjectId": "{{ holder_did }}" @@ -173,7 +173,7 @@ Authorization: Bearer {{ holder_access_token }} HTTP 200 # Holder wait for CredentialReceived state -GET {{ agent_url }}/agent/issue-credentials/records/{{ holder_cred_record_id }} +GET {{ agent_url }}/cloud-agent/issue-credentials/records/{{ holder_cred_record_id }} Authorization: Bearer {{ holder_access_token }} [Options] retry: -1 @@ -182,7 +182,7 @@ HTTP 200 jsonpath "$.protocolState" == "CredentialReceived" # Issuer wait for CredentialSent state -GET {{ agent_url }}/agent/issue-credentials/records/{{ issuer_cred_record_id }} +GET {{ agent_url }}/cloud-agent/issue-credentials/records/{{ issuer_cred_record_id }} Authorization: Bearer {{ issuer_access_token }} [Options] retry: -1 @@ -194,7 +194,7 @@ jsonpath "$.protocolState" == "CredentialSent" # Presentation Connection ############################## # Inviter create connection -POST {{ agent_url }}/agent/connections +POST {{ agent_url }}/cloud-agent/connections Authorization: Bearer {{ verifier_access_token }} { "label": "My Connection" @@ -205,7 +205,7 @@ raw_invitation: jsonpath "$.invitation.invitationUrl" regex ".*_oob=(.*)$" verifier_connection_id: jsonpath "$.connectionId" # Invitee accept connection -POST {{ agent_url }}/agent/connection-invitations +POST {{ agent_url }}/cloud-agent/connection-invitations Authorization: Bearer {{ holder_access_token }} { "invitation": "{{ raw_invitation }}" @@ -215,7 +215,7 @@ HTTP 200 holder_connection_id: jsonpath "$.connectionId" # Wait for inviter connection status -GET {{ agent_url }}/agent/connections/{{ verifier_connection_id }} +GET {{ agent_url }}/cloud-agent/connections/{{ verifier_connection_id }} Authorization: Bearer {{ verifier_access_token }} [Options] retry: -1 @@ -224,7 +224,7 @@ HTTP 200 jsonpath "$.state" == "ConnectionResponseSent" # Wait for invitee connection status -GET {{ agent_url }}/agent/connections/{{ holder_connection_id }} +GET {{ agent_url }}/cloud-agent/connections/{{ holder_connection_id }} Authorization: Bearer {{ holder_access_token }} [Options] retry: -1 @@ -236,7 +236,7 @@ jsonpath "$.state" == "ConnectionResponseReceived" # Presentation ############################## # Verifier create presentation request -POST {{ agent_url }}/agent/present-proof/presentations +POST {{ agent_url }}/cloud-agent/present-proof/presentations Authorization: Bearer {{ verifier_access_token }} { "connectionId": "{{ verifier_connection_id }}", @@ -252,7 +252,7 @@ verifier_presentation_id: jsonpath "$.presentationId" didcomm_presentation_thid: jsonpath "$.thid" # Holder wait for RequestReceived state -GET {{ agent_url }}/agent/present-proof/presentations +GET {{ agent_url }}/cloud-agent/present-proof/presentations Authorization: Bearer {{ holder_access_token }} [QueryStringParams] thid: {{ didcomm_presentation_thid }} @@ -265,7 +265,7 @@ jsonpath "$.contents[0].status" == "RequestReceived" holder_presentation_id: jsonpath "$.contents[0].presentationId" # Holder accept presentation request -PATCH {{ agent_url }}/agent/present-proof/presentations/{{ holder_presentation_id }} +PATCH {{ agent_url }}/cloud-agent/present-proof/presentations/{{ holder_presentation_id }} Authorization: Bearer {{ holder_access_token }} { "action": "request-accept", @@ -274,7 +274,7 @@ Authorization: Bearer {{ holder_access_token }} HTTP 200 # Holder wait for PresentationSent state -GET {{ agent_url }}/agent/present-proof/presentations +GET {{ agent_url }}/cloud-agent/present-proof/presentations Authorization: Bearer {{ holder_access_token }} [QueryStringParams] thid: {{ didcomm_presentation_thid }} @@ -285,7 +285,7 @@ HTTP 200 jsonpath "$.contents[0].status" == "PresentationSent" # Verfiier wait for PresentationVerified state -GET {{ agent_url }}/agent/present-proof/presentations +GET {{ agent_url }}/cloud-agent/present-proof/presentations Authorization: Bearer {{ verifier_access_token }} [QueryStringParams] thid: {{ didcomm_presentation_thid }} diff --git a/examples/mt/README.md b/examples/mt/README.md index 7cb3752063..030af3b4bd 100644 --- a/examples/mt/README.md +++ b/examples/mt/README.md @@ -2,4 +2,4 @@ | Exposed Service | Description | |------------------------------|--------------------------| -| `localhost:8080/prism-agent` | Multi-tenant Cloud Agent | +| `localhost:8080/cloud-agent` | Multi-tenant Cloud Agent | diff --git a/examples/mt/compose.yaml b/examples/mt/compose.yaml index bc1858a68f..0b355b8049 100644 --- a/examples/mt/compose.yaml +++ b/examples/mt/compose.yaml @@ -5,7 +5,7 @@ configs: handle_path /didcomm* { reverse_proxy agent-default:8090 } - handle_path /agent* { + handle_path /cloud-agent* { reverse_proxy agent-default:8085 } handle_path /keycloak* { @@ -39,12 +39,12 @@ services: POLLUX_DB_PASSWORD: postgres POLLUX_DB_PORT: '5432' POLLUX_DB_USER: postgres - POLLUX_STATUS_LIST_REGISTRY_PUBLIC_URL: http://caddy-default:8080/agent + POLLUX_STATUS_LIST_REGISTRY_PUBLIC_URL: http://caddy-default:8080/cloud-agent PRISM_NODE_HOST: node PRISM_NODE_PORT: '50053' - REST_SERVICE_URL: http://caddy-default:8080/agent + REST_SERVICE_URL: http://caddy-default:8080/cloud-agent SECRET_STORAGE_BACKEND: postgres - image: ghcr.io/hyperledger/identus-cloud-agent:1.37.0 + image: ghcr.io/hyperledger/identus-cloud-agent:1.38.0 restart: always caddy-default: configs: diff --git a/examples/st-multi/README.me b/examples/st-multi/README.me index 9483ed9d44..da8c69f181 100644 --- a/examples/st-multi/README.me +++ b/examples/st-multi/README.me @@ -1,7 +1,7 @@ ## Configuration -|Exposed Service|Description| -|-|-| -|`localhost:8080/prism-agent`|Single-tenant Cloud Agent#1 (issuer)| -|`localhost:8081/prism-agent`|Single-tenant Cloud Agent#2 (holder)| -|`localhost:8082/prism-agent`|Single-tenant Cloud Agent#3 (verifier)| +|Exposed Service |Description | +|----------------------------|--------------------------------------| +|`localhost:8080/cloud-agent`|Single-tenant Cloud Agent#1 (issuer) | +|`localhost:8081/cloud-agent`|Single-tenant Cloud Agent#2 (holder) | +|`localhost:8082/cloud-agent`|Single-tenant Cloud Agent#3 (verifier)| diff --git a/examples/st-multi/compose.yaml b/examples/st-multi/compose.yaml index 54f501fce6..88771d576a 100644 --- a/examples/st-multi/compose.yaml +++ b/examples/st-multi/compose.yaml @@ -5,7 +5,7 @@ configs: handle_path /didcomm* { reverse_proxy agent-holder:8090 } - handle_path /agent* { + handle_path /cloud-agent* { reverse_proxy agent-holder:8085 } handle_path /keycloak* { @@ -21,7 +21,7 @@ configs: handle_path /didcomm* { reverse_proxy agent-issuer:8090 } - handle_path /agent* { + handle_path /cloud-agent* { reverse_proxy agent-issuer:8085 } handle_path /keycloak* { @@ -37,7 +37,7 @@ configs: handle_path /didcomm* { reverse_proxy agent-verifier:8090 } - handle_path /agent* { + handle_path /cloud-agent* { reverse_proxy agent-verifier:8085 } handle_path /keycloak* { @@ -71,12 +71,12 @@ services: POLLUX_DB_PASSWORD: postgres POLLUX_DB_PORT: '5432' POLLUX_DB_USER: postgres - POLLUX_STATUS_LIST_REGISTRY_PUBLIC_URL: http://caddy-holder:8081/agent + POLLUX_STATUS_LIST_REGISTRY_PUBLIC_URL: http://caddy-holder:8081/cloud-agent PRISM_NODE_HOST: node PRISM_NODE_PORT: '50053' - REST_SERVICE_URL: http://caddy-holder:8081/agent + REST_SERVICE_URL: http://caddy-holder:8081/cloud-agent SECRET_STORAGE_BACKEND: postgres - image: ghcr.io/hyperledger/identus-cloud-agent:1.37.0 + image: ghcr.io/hyperledger/identus-cloud-agent:1.38.0 restart: always agent-issuer: depends_on: @@ -101,12 +101,12 @@ services: POLLUX_DB_PASSWORD: postgres POLLUX_DB_PORT: '5432' POLLUX_DB_USER: postgres - POLLUX_STATUS_LIST_REGISTRY_PUBLIC_URL: http://caddy-issuer:8080/agent + POLLUX_STATUS_LIST_REGISTRY_PUBLIC_URL: http://caddy-issuer:8080/cloud-agent PRISM_NODE_HOST: node PRISM_NODE_PORT: '50053' - REST_SERVICE_URL: http://caddy-issuer:8080/agent + REST_SERVICE_URL: http://caddy-issuer:8080/cloud-agent SECRET_STORAGE_BACKEND: postgres - image: ghcr.io/hyperledger/identus-cloud-agent:1.37.0 + image: ghcr.io/hyperledger/identus-cloud-agent:1.38.0 restart: always agent-verifier: depends_on: @@ -131,12 +131,12 @@ services: POLLUX_DB_PASSWORD: postgres POLLUX_DB_PORT: '5432' POLLUX_DB_USER: postgres - POLLUX_STATUS_LIST_REGISTRY_PUBLIC_URL: http://caddy-verifier:8082/agent + POLLUX_STATUS_LIST_REGISTRY_PUBLIC_URL: http://caddy-verifier:8082/cloud-agent PRISM_NODE_HOST: node PRISM_NODE_PORT: '50053' - REST_SERVICE_URL: http://caddy-verifier:8082/agent + REST_SERVICE_URL: http://caddy-verifier:8082/cloud-agent SECRET_STORAGE_BACKEND: postgres - image: ghcr.io/hyperledger/identus-cloud-agent:1.37.0 + image: ghcr.io/hyperledger/identus-cloud-agent:1.38.0 restart: always caddy-holder: configs: diff --git a/examples/st-multi/hurl/01_jwt_flow.hurl b/examples/st-multi/hurl/01_jwt_flow.hurl index 7e5e1b9823..4752a1adb1 100644 --- a/examples/st-multi/hurl/01_jwt_flow.hurl +++ b/examples/st-multi/hurl/01_jwt_flow.hurl @@ -2,7 +2,7 @@ # Prerequisites ############################## # Issuer create DID -POST {{ issuer_url }}/agent/did-registrar/dids +POST {{ issuer_url }}/cloud-agent/did-registrar/dids { "documentTemplate": { "publicKeys": [ @@ -19,11 +19,11 @@ HTTP 201 issuer_did: jsonpath "$.longFormDid" regex "(did:prism:[a-z0-9]+):.+$" # Issuer publish DID -POST {{ issuer_url }}/agent/did-registrar/dids/{{ issuer_did }}/publications +POST {{ issuer_url }}/cloud-agent/did-registrar/dids/{{ issuer_did }}/publications HTTP 202 # Issuer wait for DID to be published -GET {{ issuer_url }}/agent/did-registrar/dids/{{ issuer_did }} +GET {{ issuer_url }}/cloud-agent/did-registrar/dids/{{ issuer_did }} [Options] retry: -1 HTTP 200 @@ -31,7 +31,7 @@ HTTP 200 jsonpath "$.status" == "PUBLISHED" # Holder create DID -POST {{ holder_url }}/agent/did-registrar/dids +POST {{ holder_url }}/cloud-agent/did-registrar/dids { "documentTemplate": { "publicKeys": [ @@ -51,7 +51,7 @@ holder_did: jsonpath "$.longFormDid" # Issuance Connection ############################## # Inviter create connection -POST {{ issuer_url }}/agent/connections +POST {{ issuer_url }}/cloud-agent/connections { "label": "My Connection" } @@ -61,7 +61,7 @@ raw_invitation: jsonpath "$.invitation.invitationUrl" regex ".*_oob=(.*)$" issuer_connection_id: jsonpath "$.connectionId" # Invitee accept connection -POST {{ holder_url }}/agent/connection-invitations +POST {{ holder_url }}/cloud-agent/connection-invitations { "invitation": "{{ raw_invitation }}" } @@ -70,7 +70,7 @@ HTTP 200 holder_connection_id: jsonpath "$.connectionId" # Wait for inviter connection status -GET {{ issuer_url }}/agent/connections/{{ issuer_connection_id }} +GET {{ issuer_url }}/cloud-agent/connections/{{ issuer_connection_id }} [Options] retry: -1 HTTP 200 @@ -78,7 +78,7 @@ HTTP 200 jsonpath "$.state" == "ConnectionResponseSent" # Wait for invitee connection status -GET {{ holder_url }}/agent/connections/{{ holder_connection_id }} +GET {{ holder_url }}/cloud-agent/connections/{{ holder_connection_id }} [Options] retry: -1 HTTP 200 @@ -89,7 +89,7 @@ jsonpath "$.state" == "ConnectionResponseReceived" # Issuance ############################## # Issuer create credential offer -POST {{ issuer_url }}/agent/issue-credentials/credential-offers +POST {{ issuer_url }}/cloud-agent/issue-credentials/credential-offers { "claims": { "emailAddress": "alice@wonderland.com", @@ -106,7 +106,7 @@ issuer_cred_record_id: jsonpath "$.recordId" didcomm_issuing_thid: jsonpath "$.thid" # Holder wait for OfferReceived state -GET {{ holder_url }}/agent/issue-credentials/records +GET {{ holder_url }}/cloud-agent/issue-credentials/records [QueryStringParams] thid: {{ didcomm_issuing_thid }} [Options] @@ -118,14 +118,14 @@ jsonpath "$.contents[0].protocolState" == "OfferReceived" holder_cred_record_id: jsonpath "$.contents[0].recordId" # Holder accept a credential-offer -POST {{ holder_url }}/agent/issue-credentials/records/{{ holder_cred_record_id }}/accept-offer +POST {{ holder_url }}/cloud-agent/issue-credentials/records/{{ holder_cred_record_id }}/accept-offer { "subjectId": "{{ holder_did }}" } HTTP 200 # Holder wait for CredentialReceived state -GET {{ holder_url }}/agent/issue-credentials/records/{{ holder_cred_record_id }} +GET {{ holder_url }}/cloud-agent/issue-credentials/records/{{ holder_cred_record_id }} [Options] retry: -1 HTTP 200 @@ -133,7 +133,7 @@ HTTP 200 jsonpath "$.protocolState" == "CredentialReceived" # Issuer wait for CredentialSent state -GET {{ issuer_url }}/agent/issue-credentials/records/{{ issuer_cred_record_id }} +GET {{ issuer_url }}/cloud-agent/issue-credentials/records/{{ issuer_cred_record_id }} [Options] retry: -1 HTTP 200 @@ -144,7 +144,7 @@ jsonpath "$.protocolState" == "CredentialSent" # Presentation Connection ############################## # Inviter create connection -POST {{ verifier_url }}/agent/connections +POST {{ verifier_url }}/cloud-agent/connections { "label": "My Connection" } @@ -154,7 +154,7 @@ raw_invitation: jsonpath "$.invitation.invitationUrl" regex ".*_oob=(.*)$" verifier_connection_id: jsonpath "$.connectionId" # Invitee accept connection -POST {{ holder_url }}/agent/connection-invitations +POST {{ holder_url }}/cloud-agent/connection-invitations { "invitation": "{{ raw_invitation }}" } @@ -163,7 +163,7 @@ HTTP 200 holder_connection_id: jsonpath "$.connectionId" # Wait for inviter connection status -GET {{ verifier_url }}/agent/connections/{{ verifier_connection_id }} +GET {{ verifier_url }}/cloud-agent/connections/{{ verifier_connection_id }} [Options] retry: -1 HTTP 200 @@ -171,7 +171,7 @@ HTTP 200 jsonpath "$.state" == "ConnectionResponseSent" # Wait for invitee connection status -GET {{ holder_url }}/agent/connections/{{ holder_connection_id }} +GET {{ holder_url }}/cloud-agent/connections/{{ holder_connection_id }} [Options] retry: -1 HTTP 200 @@ -182,7 +182,7 @@ jsonpath "$.state" == "ConnectionResponseReceived" # Presentation ############################## # Verifier create presentation request -POST {{ verifier_url }}/agent/present-proof/presentations +POST {{ verifier_url }}/cloud-agent/present-proof/presentations { "connectionId": "{{ verifier_connection_id }}", "proofs":[], @@ -197,7 +197,7 @@ verifier_presentation_id: jsonpath "$.presentationId" didcomm_presentation_thid: jsonpath "$.thid" # Holder wait for RequestReceived state -GET {{ holder_url }}/agent/present-proof/presentations +GET {{ holder_url }}/cloud-agent/present-proof/presentations [QueryStringParams] thid: {{ didcomm_presentation_thid }} [Options] @@ -209,7 +209,7 @@ jsonpath "$.contents[0].status" == "RequestReceived" holder_presentation_id: jsonpath "$.contents[0].presentationId" # Holder accept presentation request -PATCH {{ holder_url }}/agent/present-proof/presentations/{{ holder_presentation_id }} +PATCH {{ holder_url }}/cloud-agent/present-proof/presentations/{{ holder_presentation_id }} { "action": "request-accept", "proofId": ["{{ holder_cred_record_id }}"] @@ -217,7 +217,7 @@ PATCH {{ holder_url }}/agent/present-proof/presentations/{{ holder_presentation_ HTTP 200 # Holder wait for PresentationSent state -GET {{ holder_url }}/agent/present-proof/presentations +GET {{ holder_url }}/cloud-agent/present-proof/presentations [QueryStringParams] thid: {{ didcomm_presentation_thid }} [Options] @@ -227,7 +227,7 @@ HTTP 200 jsonpath "$.contents[0].status" == "PresentationSent" # Verfiier wait for PresentationVerified state -GET {{ verifier_url }}/agent/present-proof/presentations +GET {{ verifier_url }}/cloud-agent/present-proof/presentations [QueryStringParams] thid: {{ didcomm_presentation_thid }} [Options] diff --git a/examples/st-oid4vci/README.md b/examples/st-oid4vci/README.md index 85b357d0a3..956da6a0db 100644 --- a/examples/st-oid4vci/README.md +++ b/examples/st-oid4vci/README.md @@ -3,24 +3,6 @@ ## Prerequisites - Docker installed v2.24.0 or later -- Python 3 with the following packages installed - - [requests](https://pypi.org/project/requests/) - - [pyjwt](https://pyjwt.readthedocs.io/en/stable/) - - [cryptography](https://cryptography.io/en/latest/) -- Virtual environment (optional) - -Example of the script to install the required packages in a virtual environment: -```shell -python -m venv {path-to-the-project-dir}/identus-cloud-agent/examples/st-oid4vci/python-env -source {path-to-the-project-dir}/identus-cloud-agent/examples/st-oid4vci/python-env/bin/activate -pip install requests pyjwt cryptography -``` - -- the latest Cloud Agent image is built and available in the local Docker registry - -```shell -sbt docker:publishLocal -``` ### 1. Spin up the agent stack with pre-configured Keycloak @@ -32,10 +14,16 @@ The Keycloak UI is available at `http://localhost:9980` and the admin username i ### 2. Run the issuance demo script +Build the demo application and run it using + ```bash -python demo.py +docker build -t identus-oid4vci-demo:latest ./demo +docker run --network -it identus-oid4vci-demo:latest ``` +where `NETWORK_NAME` is the docker-compose network name from agent stack. +By default, this value should be `st-oid4vci_default`. + - 2.1 Follow the instructions in the terminal. The holder will then be asked to log in via a browser - 2.2 Enter the username `alice` and password `1234` to log in - 2.3 Grant access for the scopes displayed on the consent UI diff --git a/examples/st-oid4vci/compose.yaml b/examples/st-oid4vci/compose.yaml index 5de842daa2..adc542a455 100644 --- a/examples/st-oid4vci/compose.yaml +++ b/examples/st-oid4vci/compose.yaml @@ -5,7 +5,7 @@ configs: handle_path /didcomm* { reverse_proxy agent-issuer:8090 } - handle_path /agent* { + handle_path /cloud-agent* { reverse_proxy agent-issuer:8085 } handle_path /keycloak* { @@ -39,12 +39,12 @@ services: POLLUX_DB_PASSWORD: postgres POLLUX_DB_PORT: '5432' POLLUX_DB_USER: postgres - POLLUX_STATUS_LIST_REGISTRY_PUBLIC_URL: http://caddy-issuer:8080/agent + POLLUX_STATUS_LIST_REGISTRY_PUBLIC_URL: http://caddy-issuer:8080/cloud-agent PRISM_NODE_HOST: node PRISM_NODE_PORT: '50053' - REST_SERVICE_URL: http://caddy-issuer:8080/agent + REST_SERVICE_URL: http://caddy-issuer:8080/cloud-agent SECRET_STORAGE_BACKEND: postgres - image: ghcr.io/hyperledger/identus-cloud-agent:1.37.0 + image: ghcr.io/hyperledger/identus-cloud-agent:1.38.0 restart: always caddy-issuer: configs: @@ -102,7 +102,7 @@ services: - --hostname-url=http://localhost:9980 - --hostname-admin-url=http://localhost:9980 environment: - IDENTUS_URL: http://caddy-issuer:8080/prism-agent + IDENTUS_URL: http://caddy-issuer:8080/cloud-agent KEYCLOAK_ADMIN: admin KEYCLOAK_ADMIN_PASSWORD: admin image: ghcr.io/hyperledger/identus-keycloak-plugins:0.2.0 diff --git a/examples/st-oid4vci/demo/Dockerfile b/examples/st-oid4vci/demo/Dockerfile new file mode 100644 index 0000000000..b68166517b --- /dev/null +++ b/examples/st-oid4vci/demo/Dockerfile @@ -0,0 +1,6 @@ +FROM python:3.12 +WORKDIR /workspace +COPY requirements.txt /workspace/requirements.txt +RUN pip install -r requirements.txt +COPY . /workspace +CMD ["python", "main.py"] diff --git a/examples/st-oid4vci/demo.py b/examples/st-oid4vci/demo/main.py similarity index 90% rename from examples/st-oid4vci/demo.py rename to examples/st-oid4vci/demo/main.py index 0dd12acf4d..b48239a161 100755 --- a/examples/st-oid4vci/demo.py +++ b/examples/st-oid4vci/demo/main.py @@ -6,14 +6,14 @@ import requests from cryptography.hazmat.primitives.asymmetric import ec -MOCKSERVER_URL = "http://localhost:7777" +MOCKSERVER_URL = "http://mockserver:1080" LOGIN_REDIRECT_URL = "http://localhost:7777/cb" -AGENT_URL = "http://localhost:8080/prism-agent" +AGENT_URL = "http://caddy-issuer:8080/cloud-agent" CREDENTIAL_ISSUER = None CREDENTIAL_ISSUER_DID = None CREDENTIAL_CONFIGURATION_ID = "UniversityDegreeCredential" -AUTHORIZATION_SERVER = "http://localhost:9980/realms/students" +AUTHORIZATION_SERVER = "http://external-keycloak-issuer:8080/realms/students" ALICE_CLIENT_ID = "alice-wallet" @@ -24,10 +24,11 @@ def prepare_mock_server(): - # reset mock server - requests.put(f"{MOCKSERVER_URL}/mockserver/reset") + """ + Prepare mock server for authorization redirect from front-end channel + """ - # mock wallet authorization callback endpoint + requests.put(f"{MOCKSERVER_URL}/mockserver/reset") requests.put( f"{MOCKSERVER_URL}/mockserver/expectation", json={ @@ -41,7 +42,16 @@ def prepare_mock_server(): def prepare_issuer(): - # prepare issuging DID + """ + Prepare an oid4vci issuer + 1. Create issuing DID + 2. Publish issuing DID + 3. Create credential schema + 4. Create oid4vci issuer + 5. Create oid4vci credential configuration from schema + """ + + # 1. Create issuing DID dids = requests.get(f"{AGENT_URL}/did-registrar/dids").json()["contents"] if len(dids) == 0: requests.post( @@ -55,6 +65,7 @@ def prepare_issuer(): ) dids = requests.get(f"{AGENT_URL}/did-registrar/dids").json()["contents"] + # 2. Publish issuing DID issuer_did = dids[0] while issuer_did["status"] != "PUBLISHED": time.sleep(2) @@ -72,7 +83,7 @@ def prepare_issuer(): global CREDENTIAL_ISSUER_DID CREDENTIAL_ISSUER_DID = canonical_did - # prepare schema + # 3. Create credential schema schema = requests.post( f"{AGENT_URL}/schema-registry/schemas", json={ @@ -97,7 +108,7 @@ def prepare_issuer(): ).json() schema_guid = schema["guid"] - # prepare issuer + # 4. Create oid4vci issuer credential_issuer = requests.post( f"{AGENT_URL}/oid4vci/issuers", json={ @@ -112,14 +123,13 @@ def prepare_issuer(): global CREDENTIAL_ISSUER CREDENTIAL_ISSUER = f"{AGENT_URL}/oid4vci/issuers/{issuer_id}" - # prepare credential configuration + # 5. Create oid4vci credential configuration from schema cred_config = requests.post( f"{CREDENTIAL_ISSUER}/credential-configurations", json={ "configurationId": CREDENTIAL_CONFIGURATION_ID, "format": "jwt_vc_json", - # TODO: align docker host URL - "schemaId": f"http://localhost:8085/schema-registry/schemas/{schema_guid}/schema", + "schemaId": f"{AGENT_URL}/schema-registry/schemas/{schema_guid}/schema", }, ).json() @@ -137,19 +147,8 @@ def issuer_create_credential_offer(claims): def holder_get_issuer_metadata(credential_issuer: str): - # FIXME: override this just to make url reachable from host - def override_host(url): - return url.replace("http://caddy-issuer:8080/prism-agent", AGENT_URL) - - metadata_url = override_host( - f"{credential_issuer}/.well-known/openid-credential-issuer" - ) + metadata_url = f"{credential_issuer}/.well-known/openid-credential-issuer" response = requests.get(metadata_url).json() - response["credential_endpoint"] = override_host(response["credential_endpoint"]) - response["credential_issuer"] = override_host(response["credential_issuer"]) - response["authorization_servers"] = [ - AUTHORIZATION_SERVER for s in response["authorization_servers"] - ] return response @@ -157,7 +156,6 @@ def holder_get_issuer_as_metadata(authorization_server: str): metadata_url = f"{authorization_server}/.well-known/openid-configuration" response = requests.get(metadata_url) metadata = response.json() - print(f"Metadata: {metadata}") return metadata @@ -293,6 +291,7 @@ def holder_get_credential(credential_endpoint: str, token_response): issuer_as_token_endpoint = issuer_as_metadata["token_endpoint"] issuer_as_authorization_endpoint = issuer_as_metadata["authorization_endpoint"] print("\n::::: Issuer Authorization Server Metadata :::::") + print(json.dumps(issuer_as_metadata, indent=2)) print(f"issuer_as_auth_endpoint: {issuer_as_authorization_endpoint}") print(f"issuer_as_token_endpoint: {issuer_as_token_endpoint}") input("\nEnter to continue ...") diff --git a/examples/st-oid4vci/demo/requirements.txt b/examples/st-oid4vci/demo/requirements.txt new file mode 100644 index 0000000000..791e544886 --- /dev/null +++ b/examples/st-oid4vci/demo/requirements.txt @@ -0,0 +1,11 @@ +certifi==2024.7.4 +cffi==1.16.0 +charset-normalizer==3.3.2 +cryptography==42.0.8 +idna==3.7 +pycparser==2.22 +PyJWT==2.8.0 +requests==2.32.3 +setuptools==70.3.0 +urllib3==2.2.2 +wheel==0.43.0 diff --git a/examples/st-vault/README.md b/examples/st-vault/README.md index 338d74d18b..782bea7136 100644 --- a/examples/st-vault/README.md +++ b/examples/st-vault/README.md @@ -2,7 +2,7 @@ | Exposed Service | Description | |------------------------------|---------------------------| -| `localhost:8080/prism-agent` | Single-tenant Cloud Agent | +| `localhost:8080/cloud-agent` | Single-tenant Cloud Agent | | `localhost:8200` | Vault | __Vault__ diff --git a/examples/st-vault/compose.yaml b/examples/st-vault/compose.yaml index a903091b13..fdbb1909e7 100644 --- a/examples/st-vault/compose.yaml +++ b/examples/st-vault/compose.yaml @@ -5,7 +5,7 @@ configs: handle_path /didcomm* { reverse_proxy agent-issuer:8090 } - handle_path /agent* { + handle_path /cloud-agent* { reverse_proxy agent-issuer:8085 } handle_path /keycloak* { @@ -39,14 +39,14 @@ services: POLLUX_DB_PASSWORD: postgres POLLUX_DB_PORT: '5432' POLLUX_DB_USER: postgres - POLLUX_STATUS_LIST_REGISTRY_PUBLIC_URL: http://caddy-issuer:8080/agent + POLLUX_STATUS_LIST_REGISTRY_PUBLIC_URL: http://caddy-issuer:8080/cloud-agent PRISM_NODE_HOST: node PRISM_NODE_PORT: '50053' - REST_SERVICE_URL: http://caddy-issuer:8080/agent + REST_SERVICE_URL: http://caddy-issuer:8080/cloud-agent SECRET_STORAGE_BACKEND: vault VAULT_ADDR: http://vault-issuer:8200 VAULT_TOKEN: admin - image: ghcr.io/hyperledger/identus-cloud-agent:1.37.0 + image: ghcr.io/hyperledger/identus-cloud-agent:1.38.0 restart: always caddy-issuer: configs: diff --git a/examples/st/README.md b/examples/st/README.md index 703a0bc6ad..1575a89961 100644 --- a/examples/st/README.md +++ b/examples/st/README.md @@ -1,5 +1,5 @@ ## Configuration -| Exposed Service | Description | -|------------------------|---------------------------| -| `localhost:8080/agent` | Single-tenant Cloud Agent | +| Exposed Service | Description | +|------------------------------|---------------------------| +| `localhost:8080/cloud-agent` | Single-tenant Cloud Agent | diff --git a/examples/st/compose.yaml b/examples/st/compose.yaml index 9efa65e481..0b2b1dc541 100644 --- a/examples/st/compose.yaml +++ b/examples/st/compose.yaml @@ -5,7 +5,7 @@ configs: handle_path /didcomm* { reverse_proxy agent-issuer:8090 } - handle_path /agent* { + handle_path /cloud-agent* { reverse_proxy agent-issuer:8085 } handle_path /keycloak* { @@ -39,12 +39,12 @@ services: POLLUX_DB_PASSWORD: postgres POLLUX_DB_PORT: '5432' POLLUX_DB_USER: postgres - POLLUX_STATUS_LIST_REGISTRY_PUBLIC_URL: http://caddy-issuer:8080/agent + POLLUX_STATUS_LIST_REGISTRY_PUBLIC_URL: http://caddy-issuer:8080/cloud-agent PRISM_NODE_HOST: node PRISM_NODE_PORT: '50053' - REST_SERVICE_URL: http://caddy-issuer:8080/agent + REST_SERVICE_URL: http://caddy-issuer:8080/cloud-agent SECRET_STORAGE_BACKEND: postgres - image: ghcr.io/hyperledger/identus-cloud-agent:1.37.0 + image: ghcr.io/hyperledger/identus-cloud-agent:1.38.0 restart: always caddy-issuer: configs: