From fcbdd91230e589f5f17ec5eee7c84c3f90ffa59e Mon Sep 17 00:00:00 2001 From: mikeplotean <101570226+mikeplotean@users.noreply.github.com> Date: Mon, 6 Nov 2023 15:05:35 +0200 Subject: [PATCH] feat: ebsi compliance - holder wallet (#3) * feat: introduce method to fetch deferred credential refactor: resolving of credential offer from credential offer request url test: test fetching deferred credential from ebsi conformance issuer * feat: introduce high level function for executing pre-authorized code issuance flow refactor: avoid code duplication by common internal method for executing token and credential request after authorization (or pre-authorization) test: test ebsi conformance pre-authorized code issuance * refactor: remove annotation * test: test same device credential issuance flow feat: implement vp_token auth in the course of an issuance flow test: start test of issuance flow with implicit vp token exchange for ebsi conformance * fix: descriptor-map id requirement * chore: updated presentation for vp to include multiple constrains * test: updated ebsi-conformance-tests * chore: cleanup * chore: ebsi-tests - clear credentials folder * chore: added missing presentation fields * chore: cleanup + minor refactoring --------- Co-authored-by: Severin Stampler --- .../id/walt/oid4vc/errors/CredentialError.kt | 2 +- .../oid4vc/errors/CredentialOfferError.kt | 3 +- .../providers/OpenIDCredentialWallet.kt | 67 ++++++- .../oid4vc/responses/CredentialResponse.kt | 4 +- .../kotlin/id/walt/oid4vc/CI_JVM_Test.kt | 2 +- .../kotlin/id/walt/oid4vc/EBSITestWallet.kt | 119 ++++++++----- .../id/walt/oid4vc/EBSI_Conformance_Test.kt | 166 ++++++++++++++++-- 7 files changed, 287 insertions(+), 76 deletions(-) diff --git a/src/commonMain/kotlin/id/walt/oid4vc/errors/CredentialError.kt b/src/commonMain/kotlin/id/walt/oid4vc/errors/CredentialError.kt index 05264f5..a43fb95 100644 --- a/src/commonMain/kotlin/id/walt/oid4vc/errors/CredentialError.kt +++ b/src/commonMain/kotlin/id/walt/oid4vc/errors/CredentialError.kt @@ -8,7 +8,7 @@ import id.walt.oid4vc.responses.CredentialResponse import kotlin.time.Duration class CredentialError( - credentialRequest: CredentialRequest, val errorCode: CredentialErrorCode, + credentialRequest: CredentialRequest?, val errorCode: CredentialErrorCode, val errorUri: String? = null, val cNonce: String? = null, val cNonceExpiresIn: Duration? = null, override val message: String? = null ) : Exception() { diff --git a/src/commonMain/kotlin/id/walt/oid4vc/errors/CredentialOfferError.kt b/src/commonMain/kotlin/id/walt/oid4vc/errors/CredentialOfferError.kt index 965d34e..509230d 100644 --- a/src/commonMain/kotlin/id/walt/oid4vc/errors/CredentialOfferError.kt +++ b/src/commonMain/kotlin/id/walt/oid4vc/errors/CredentialOfferError.kt @@ -1,11 +1,12 @@ package id.walt.oid4vc.errors +import id.walt.oid4vc.data.CredentialOffer import id.walt.oid4vc.requests.CredentialOfferRequest import id.walt.oid4vc.requests.TokenRequest import id.walt.oid4vc.responses.TokenErrorCode class CredentialOfferError( - val credentialOfferRequest: CredentialOfferRequest, val errorCode: CredentialOfferErrorCode, override val message: String? = null + val credentialOfferRequest: CredentialOfferRequest?, val credentialOffer: CredentialOffer?, val errorCode: CredentialOfferErrorCode, override val message: String? = null ): Exception() { } diff --git a/src/commonMain/kotlin/id/walt/oid4vc/providers/OpenIDCredentialWallet.kt b/src/commonMain/kotlin/id/walt/oid4vc/providers/OpenIDCredentialWallet.kt index f25c767..ebba322 100644 --- a/src/commonMain/kotlin/id/walt/oid4vc/providers/OpenIDCredentialWallet.kt +++ b/src/commonMain/kotlin/id/walt/oid4vc/providers/OpenIDCredentialWallet.kt @@ -3,7 +3,6 @@ package id.walt.oid4vc.providers import id.walt.oid4vc.data.* import id.walt.oid4vc.data.dif.PresentationDefinition import id.walt.oid4vc.definitions.JWTClaims -import id.walt.oid4vc.definitions.OPENID_CREDENTIAL_AUTHORIZATION_TYPE import id.walt.oid4vc.errors.* import id.walt.oid4vc.interfaces.IHttpClient import id.walt.oid4vc.interfaces.ITokenProvider @@ -160,18 +159,31 @@ abstract class OpenIDCredentialWallet( // ========================================================== // =============== issuance flow =========================== - open fun getCredentialOffer(credentialOfferRequest: CredentialOfferRequest): CredentialOffer { + open fun resolveCredentialOffer(credentialOfferRequest: CredentialOfferRequest): CredentialOffer { return credentialOfferRequest.credentialOffer ?: credentialOfferRequest.credentialOfferUri?.let { uri -> httpGetAsJson(Url(uri))?.jsonObject?.let { CredentialOffer.fromJSON(it) } - } ?: throw CredentialOfferError(credentialOfferRequest, CredentialOfferErrorCode.invalid_request, "No credential offer value found on request, and credential offer could not be fetched by reference from given credential_offer_uri") + } ?: throw CredentialOfferError(credentialOfferRequest, null, CredentialOfferErrorCode.invalid_request, "No credential offer value found on request, and credential offer could not be fetched by reference from given credential_offer_uri") + } + + open fun executePreAuthorizedCodeFlow(credentialOffer: CredentialOffer, holderDid: String, client: OpenIDClientConfig, userPIN: String?): List { + if(!credentialOffer.grants.containsKey(GrantType.pre_authorized_code.value)) throw CredentialOfferError(null, credentialOffer, CredentialOfferErrorCode.invalid_request, "Pre-authorized code issuance flow executed, but no pre-authorized_code found on credential offer") + val issuerMetadataUrl = getCIProviderMetadataUrl(credentialOffer.credentialIssuer) + val issuerMetadata = httpGetAsJson(Url(issuerMetadataUrl))?.jsonObject?.let { OpenIDProviderMetadata.fromJSON(it) } ?: throw CredentialOfferError(null, credentialOffer, CredentialOfferErrorCode.invalid_issuer, "Could not resolve issuer provider metadata from $issuerMetadataUrl") + val authorizationServerMetadata = issuerMetadata.authorizationServer?.let { authServer -> + httpGetAsJson(Url(getCommonProviderMetadataUrl(authServer)))?.jsonObject?.let { OpenIDProviderMetadata.fromJSON(it) } + } ?: issuerMetadata + val offeredCredentials = credentialOffer.resolveOfferedCredentials(issuerMetadata) + + return executeAuthorizedIssuanceCodeFlow( + authorizationServerMetadata, issuerMetadata, credentialOffer, GrantType.pre_authorized_code, + offeredCredentials, holderDid, client, null, null, userPIN) } @OptIn(ExperimentalEncodingApi::class) - open fun executeFullAuthIssuance(credentialOfferRequest: CredentialOfferRequest, holderDid: String, client: OpenIDClientConfig): List { - val credentialOffer = getCredentialOffer(credentialOfferRequest) - if(!credentialOffer.grants.containsKey(GrantType.authorization_code.value)) throw CredentialOfferError(credentialOfferRequest, CredentialOfferErrorCode.invalid_request, "Full authorization issuance flow executed, but no authorization_code found on credential offer") + open fun executeFullAuthIssuance(credentialOffer: CredentialOffer, holderDid: String, client: OpenIDClientConfig): List { + if(!credentialOffer.grants.containsKey(GrantType.authorization_code.value)) throw CredentialOfferError(null, credentialOffer, CredentialOfferErrorCode.invalid_request, "Full authorization issuance flow executed, but no authorization_code found on credential offer") val issuerMetadataUrl = getCIProviderMetadataUrl(credentialOffer.credentialIssuer) - val issuerMetadata = httpGetAsJson(Url(issuerMetadataUrl))?.jsonObject?.let { OpenIDProviderMetadata.fromJSON(it) } ?: throw CredentialOfferError(credentialOfferRequest, CredentialOfferErrorCode.invalid_issuer, "Could not resolve issuer provider metadata from $issuerMetadataUrl") + val issuerMetadata = httpGetAsJson(Url(issuerMetadataUrl))?.jsonObject?.let { OpenIDProviderMetadata.fromJSON(it) } ?: throw CredentialOfferError(null, credentialOffer, CredentialOfferErrorCode.invalid_issuer, "Could not resolve issuer provider metadata from $issuerMetadataUrl") val authorizationServerMetadata = issuerMetadata.authorizationServer?.let { authServer -> httpGetAsJson(Url(getCommonProviderMetadataUrl(authServer)))?.jsonObject?.let { OpenIDProviderMetadata.fromJSON(it) } } ?: issuerMetadata @@ -223,11 +235,35 @@ abstract class OpenIDCredentialWallet( println("location: $location") location = if(location.parameters.contains("response_type") && location.parameters["response_type"] == ResponseType.id_token.name) { executeIdTokenAuthorization(location, holderDid, client) + } else if(location.parameters.contains("response_type") && location.parameters["response_type"] == ResponseType.vp_token.name) { + executeVpTokenAuthorization(location, holderDid, client) } else location val code = location.parameters["code"] ?: throw AuthorizationError(authReq, AuthorizationErrorCode.server_error, "No authorization code received from server") - val tokenReq = TokenRequest(GrantType.authorization_code, client.clientID, config.redirectUri, code, codeVerifier = codeVerifier) + return executeAuthorizedIssuanceCodeFlow( + authorizationServerMetadata, issuerMetadata, credentialOffer, + GrantType.authorization_code, offeredCredentials, holderDid, client, code, codeVerifier + ) + } + + open fun fetchDeferredCredential(credentialOffer: CredentialOffer, credentialResponse: CredentialResponse): CredentialResponse { + if(credentialResponse.acceptanceToken.isNullOrEmpty()) throw CredentialOfferError(null, credentialOffer, CredentialOfferErrorCode.invalid_request, "Credential offer has no acceptance token for fetching deferred credential") + val issuerMetadataUrl = getCIProviderMetadataUrl(credentialOffer.credentialIssuer) + val issuerMetadata = httpGetAsJson(Url(issuerMetadataUrl))?.jsonObject?.let { OpenIDProviderMetadata.fromJSON(it) } ?: throw CredentialOfferError(null, credentialOffer, CredentialOfferErrorCode.invalid_issuer, "Could not resolve issuer provider metadata from $issuerMetadataUrl") + if(issuerMetadata.deferredCredentialEndpoint.isNullOrEmpty()) throw CredentialOfferError(null, credentialOffer, CredentialOfferErrorCode.invalid_issuer, "No deferred credential endpoint found in issuer metadata") + val deferredCredResp = httpSubmitForm(Url(issuerMetadata.deferredCredentialEndpoint), parametersOf(), headers { + append(HttpHeaders.Authorization, "Bearer ${credentialResponse.acceptanceToken}") + }) + if(!deferredCredResp.status.isSuccess() || deferredCredResp.body.isNullOrEmpty()) throw CredentialError(null, CredentialErrorCode.server_error, "No credential received from deferred credential endpoint, or server responded with error status ${deferredCredResp.status}") + return CredentialResponse.fromJSONString(deferredCredResp.body) + } + + protected open fun executeAuthorizedIssuanceCodeFlow(authorizationServerMetadata: OpenIDProviderMetadata, issuerMetadata: OpenIDProviderMetadata, + credentialOffer: CredentialOffer, + grantType: GrantType, offeredCredentials: List, holderDid: String, + client: OpenIDClientConfig, authorizationCode: String? = null, codeVerifier: String? = null, userPIN: String? = null): List { + val tokenReq = TokenRequest(grantType, client.clientID, config.redirectUri, authorizationCode, credentialOffer.grants[grantType.value]?.preAuthorizedCode, userPIN, codeVerifier) val tokenHttpResp = httpSubmitForm(Url(authorizationServerMetadata.tokenEndpoint!!), parametersOf(tokenReq.toHttpParameters())) if(!tokenHttpResp.status.isSuccess() || tokenHttpResp.body == null) throw TokenError(tokenReq, TokenErrorCode.server_error, "Server returned error code ${tokenHttpResp.status}, or empty body") val tokenResp = TokenResponse.fromJSONString(tokenHttpResp.body) @@ -241,7 +277,7 @@ abstract class OpenIDCredentialWallet( executeCredentialRequest( issuerMetadata.credentialEndpoint ?: throw CredentialError(credReq, CredentialErrorCode.server_error, "No credential endpoint specified in issuer metadata"), tokenResp.accessToken, credReq).also { - nonce = it.cNonce ?: nonce + nonce = it.cNonce ?: nonce } } } else { @@ -288,4 +324,17 @@ abstract class OpenIDCredentialWallet( ?: throw AuthorizationError(authReq, AuthorizationErrorCode.server_error, "Location parameter missing on http response for id_token response") } + open fun executeVpTokenAuthorization(vpTokenRequestUri: Url, holderDid: String, client: OpenIDClientConfig): Url { + val authReq = AuthorizationRequest.fromHttpQueryString(vpTokenRequestUri.encodedQuery) + val tokenResp = processImplicitFlowAuthorization(authReq.copy( + clientId = client.clientID, + )) + val httpResp = httpSubmitForm(Url(authReq.responseUri ?: authReq.redirectUri!!), parametersOf(tokenResp.toHttpParameters())) + return when(httpResp.status) { + HttpStatusCode.Found -> httpResp.headers[HttpHeaders.Location] + HttpStatusCode.OK -> httpResp.body?.let { AuthorizationDirectPostResponse.fromJSONString(it) }?.redirectUri + else -> null + }?.let { Url(it) } ?: throw AuthorizationError(authReq, AuthorizationErrorCode.invalid_request, "Request could not be executed") + } + } diff --git a/src/commonMain/kotlin/id/walt/oid4vc/responses/CredentialResponse.kt b/src/commonMain/kotlin/id/walt/oid4vc/responses/CredentialResponse.kt index 58387d9..47260a8 100644 --- a/src/commonMain/kotlin/id/walt/oid4vc/responses/CredentialResponse.kt +++ b/src/commonMain/kotlin/id/walt/oid4vc/responses/CredentialResponse.kt @@ -26,8 +26,8 @@ data class CredentialResponse private constructor( @SerialName("error_uri") val errorUri: String? = null, override val customParameters: Map = mapOf() ) : JsonDataObject() { - val isSuccess get() = format != null - val isDeferred get() = isSuccess && credential == null + val isSuccess get() = (format != null && credential != null) || isDeferred + val isDeferred get() = acceptanceToken != null override fun toJSON() = Json.encodeToJsonElement(CredentialResponseSerializer, this).jsonObject companion object : JsonDataObjectFactory() { diff --git a/src/jvmTest/kotlin/id/walt/oid4vc/CI_JVM_Test.kt b/src/jvmTest/kotlin/id/walt/oid4vc/CI_JVM_Test.kt index 348db35..f9fbb1f 100644 --- a/src/jvmTest/kotlin/id/walt/oid4vc/CI_JVM_Test.kt +++ b/src/jvmTest/kotlin/id/walt/oid4vc/CI_JVM_Test.kt @@ -943,7 +943,7 @@ class CI_JVM_Test : AnnotationSpec() { println("// as WALLET: receive credential offer, either being called via deeplink or by scanning QR code") println("// parse credential URI") - val credentialOffer = credentialWallet.getCredentialOffer(CredentialOfferRequest.fromHttpParameters(Url(offerUri).parameters.toMap())) + val credentialOffer = credentialWallet.resolveCredentialOffer(CredentialOfferRequest.fromHttpParameters(Url(offerUri).parameters.toMap())) credentialOffer.credentialIssuer shouldNotBe null credentialOffer.grants.keys shouldContain GrantType.pre_authorized_code.value diff --git a/src/jvmTest/kotlin/id/walt/oid4vc/EBSITestWallet.kt b/src/jvmTest/kotlin/id/walt/oid4vc/EBSITestWallet.kt index ad5f827..5f2db51 100644 --- a/src/jvmTest/kotlin/id/walt/oid4vc/EBSITestWallet.kt +++ b/src/jvmTest/kotlin/id/walt/oid4vc/EBSITestWallet.kt @@ -3,7 +3,6 @@ package id.walt.oid4vc import id.walt.core.crypto.utils.JwsUtils.decodeJws import id.walt.credentials.w3c.PresentableCredential import id.walt.custodian.Custodian -import id.walt.model.DidMethod import id.walt.oid4vc.data.OpenIDProviderMetadata import id.walt.oid4vc.data.dif.DescriptorMapping import id.walt.oid4vc.data.dif.PresentationDefinition @@ -24,7 +23,6 @@ import id.walt.services.jwt.JwtService import id.walt.services.key.KeyService import io.kotest.common.runBlocking import io.ktor.client.* -import io.ktor.client.call.* import io.ktor.client.engine.java.* import io.ktor.client.plugins.contentnegotiation.* import io.ktor.client.plugins.logging.* @@ -33,9 +31,10 @@ import io.ktor.client.request.forms.* import io.ktor.client.statement.* import io.ktor.http.* import io.ktor.serialization.kotlinx.json.* -import io.ktor.util.* import kotlinx.datetime.Instant import kotlinx.serialization.json.* +import java.time.Duration +import java.util.* const val EBSI_WALLET_PORT = 8011 const val EBSI_WALLET_BASE_URL = "http://localhost:${EBSI_WALLET_PORT}" @@ -62,6 +61,7 @@ class EBSITestWallet(config: CredentialWalletConfig): OpenIDCredentialWallet SimpleHttpResponse(httpResponse.status, httpResponse.headers, httpResponse.bodyAsText()) } } } override fun generatePresentationForVPToken(session: SIOPSession, tokenRequest: TokenRequest): PresentationResult { - val presentationDefinition = session.presentationDefinition ?: throw PresentationError(TokenErrorCode.invalid_request, tokenRequest, session.presentationDefinition) - val filterString = presentationDefinition.inputDescriptors.flatMap { it.constraints?.fields ?: listOf() } - .firstOrNull { field -> field.path.any { it.contains("type") } }?.filter?.jsonObject.toString() - val presentationJwtStr = Custodian.getService() - .createPresentation( - Custodian.getService().listCredentials().filter { filterString.contains(it.type.last()) }.map { - PresentableCredential( - it, - selectiveDisclosure = null, - discloseAll = false - ) - }, TEST_DID, challenge = session.nonce - ) + val presentationDefinition = session.presentationDefinition ?: throw PresentationError( + TokenErrorCode.invalid_request, tokenRequest, session.presentationDefinition + ) + val credentialDescriptorMapping = mapCredentialTypes(presentationDefinition) + val presentationJwtStr = generatePresentationJwt(credentialDescriptorMapping.map { it.credential }, session) println("================") println("PRESENTATION IS: $presentationJwtStr") println("================") val presentationJws = presentationJwtStr.decodeJws() - val jwtCredentials = - ((presentationJws.payload["vp"] - ?: throw IllegalArgumentException("VerifiablePresentation string does not contain `vp` attribute?")) - .jsonObject["verifiableCredential"] - ?: throw IllegalArgumentException("VerifiablePresentation does not contain verifiableCredential list?")) - .jsonArray.map { it.jsonPrimitive.content } + val jwtCredentials = ((presentationJws.payload["vp"] + ?: throw IllegalArgumentException("VerifiablePresentation string does not contain `vp` attribute?")).jsonObject["verifiableCredential"] + ?: throw IllegalArgumentException("VerifiablePresentation does not contain verifiableCredential list?")).jsonArray.map { it.jsonPrimitive.content } return PresentationResult( - listOf(JsonPrimitive(presentationJwtStr)), PresentationSubmission( - id = "submission 1", + presentations = listOf(JsonPrimitive(presentationJwtStr)), + presentationSubmission = PresentationSubmission( + id = UUID.randomUUID().toString(), definitionId = session.presentationDefinition!!.id, - descriptorMap = jwtCredentials.mapIndexed { index, vcJwsStr -> - - val vcJws = vcJwsStr.decodeJws() - val type = - vcJws.payload["vc"]?.jsonObject?.get("type")?.jsonArray?.last()?.jsonPrimitive?.contentOrNull - ?: "VerifiableCredential" - - DescriptorMapping( - id = type, - format = VCFormat.jwt_vp, // jwt_vp_json - path = "$", - pathNested = DescriptorMapping( - format = VCFormat.jwt_vc, - path = "$.vp.verifiableCredential[0]", - ) - ) - } + descriptorMap = getDescriptorMap(jwtCredentials, credentialDescriptorMapping) ) ) } override fun putSession(id: String, session: SIOPSession): SIOPSession? = sessionCache.put(id, session) + private fun generatePresentationJwt(credentialTypes: List, session: SIOPSession): String = + let { + val presentationJwtStr = Custodian.getService().createPresentation( + vcs = Custodian.getService().listCredentials() + .filter { c -> credentialTypes.contains(c.type.last()) } + .map { + PresentableCredential( + verifiableCredential = it, + selectiveDisclosure = null, + discloseAll = false + ) + }, + holderDid = TEST_DID, + verifierDid = "https://api-conformance.ebsi.eu/conformance/v3/auth-mock", + expirationDate = java.time.Instant.now().plus(Duration.ofDays(1)), + challenge = session.nonce, + ) + presentationJwtStr + } + + private fun mapCredentialTypes(presentationDefinition: PresentationDefinition) = + presentationDefinition.inputDescriptors.flatMap { descriptor -> + descriptor.constraints?.fields?.mapNotNull { field -> + field.takeIf { it.path.any { it.contains("type") } } + }?.mapNotNull { + it.filter?.jsonObject?.get("contains")?.jsonObject?.jsonObject?.get("const")?.jsonPrimitive?.content?.let { + CredentialDescriptorMapping(it, descriptor.id) + } + } ?: emptyList() + } + + private fun getDescriptorMap( + jwtCredentials: List, credentialDescriptor: List + ): List = jwtCredentials.mapIndexedNotNull { index, vc -> + vc.decodeJws().let { + it.payload["vc"]?.jsonObject?.get("type")?.jsonArray?.last()?.jsonPrimitive?.contentOrNull + ?: "VerifiableCredential" + }.let { c -> + credentialDescriptor.find { it.credential == c } + }?.let { + DescriptorMapping( + id = it.descriptor, + format = VCFormat.jwt_vp, // jwt_vp_json + path = "$", + pathNested = DescriptorMapping( + id = it.descriptor, + format = VCFormat.jwt_vc, + path = "$.vp.verifiableCredential[$index]", + ) + ) + } + } + + private data class CredentialDescriptorMapping( + val credential: String, + val descriptor: String, + ) } \ No newline at end of file diff --git a/src/jvmTest/kotlin/id/walt/oid4vc/EBSI_Conformance_Test.kt b/src/jvmTest/kotlin/id/walt/oid4vc/EBSI_Conformance_Test.kt index 372aa89..1b5be7f 100644 --- a/src/jvmTest/kotlin/id/walt/oid4vc/EBSI_Conformance_Test.kt +++ b/src/jvmTest/kotlin/id/walt/oid4vc/EBSI_Conformance_Test.kt @@ -1,31 +1,36 @@ package id.walt.oid4vc -import id.walt.oid4vc.data.GrantType -import id.walt.oid4vc.data.OpenIDProviderMetadata -import id.walt.oid4vc.data.ResponseMode +import id.walt.credentials.w3c.VerifiableCredential +import id.walt.custodian.Custodian import id.walt.oid4vc.providers.CredentialWalletConfig import id.walt.oid4vc.providers.OpenIDClientConfig -import id.walt.oid4vc.requests.AuthorizationRequest import id.walt.oid4vc.requests.CredentialOfferRequest +import id.walt.oid4vc.responses.CredentialResponse +import id.walt.oid4vc.util.randomUUID import id.walt.servicematrix.ServiceMatrix import io.kotest.common.runBlocking -import io.kotest.core.spec.style.AnnotationSpec -import io.kotest.core.spec.style.Test +import io.kotest.core.spec.style.StringSpec +import io.kotest.data.forAll +import io.kotest.data.row import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldNotBe import io.ktor.client.* -import io.ktor.client.call.* import io.ktor.client.engine.java.* import io.ktor.client.plugins.contentnegotiation.* import io.ktor.client.request.* import io.ktor.client.statement.* import io.ktor.http.* import io.ktor.serialization.kotlinx.json.* -import kotlinx.serialization.json.JsonObject +import io.ktor.util.* +import kotlinx.serialization.json.jsonPrimitive -class EBSI_Conformance_Test: AnnotationSpec() { +class EBSI_Conformance_Test: StringSpec({ + + val VcTestsEnabled = true + val VpTestsEnabled = true lateinit var credentialWallet: EBSITestWallet + lateinit var ebsiClientConfig: OpenIDClientConfig val ktorClient = HttpClient(Java) { install(ContentNegotiation) { @@ -34,19 +39,144 @@ class EBSI_Conformance_Test: AnnotationSpec() { followRedirects = false } - @BeforeAll - fun init() { + val crossDeviceCredentialOfferRequestCaller: credentialOfferRequestCaller = { initCredentialOfferUrl -> + val inTimeCredentialOfferRequestUri = runBlocking { ktorClient.get(initCredentialOfferUrl).bodyAsText() } + CredentialOfferRequest.fromHttpQueryString(Url(inTimeCredentialOfferRequestUri).encodedQuery) + } + + val sameDeviceCredentialOfferRequestCaller: credentialOfferRequestCaller = { initCredentialOfferUrl -> + val httpResp = runBlocking { ktorClient.get(initCredentialOfferUrl) } + httpResp.status shouldBe HttpStatusCode.Found + val inTimeCredentialOfferRequestUri = httpResp.headers[HttpHeaders.Location]!! + CredentialOfferRequest.fromHttpQueryString(Url(inTimeCredentialOfferRequestUri).encodedQuery) + } + + beforeSpec { ServiceMatrix("service-matrix.properties") credentialWallet = EBSITestWallet(CredentialWalletConfig("https://blank/")) + ebsiClientConfig = OpenIDClientConfig(credentialWallet.TEST_DID, null, credentialWallet.config.redirectUri, useCodeChallenge = true) + VcTestsEnabled.takeIf { it }?.run { + Custodian.getService().listCredentialIds().forEach { + Custodian.getService().deleteCredential(it) + } + } } - @Test - fun testReceiveCredential() { - val initCredentialOfferUrl = "https://api-conformance.ebsi.eu/conformance/v3/issuer-mock/initiate-credential-offer?credential_type=CTWalletCrossInTime&client_id=${credentialWallet.TEST_DID}&credential_offer_endpoint=openid-credential-offer://" - val inTimeCredentialOfferRequestUri = runBlocking { ktorClient.get(Url(initCredentialOfferUrl)).bodyAsText() } - val credentialOfferRequest = CredentialOfferRequest.fromHttpQueryString(Url(inTimeCredentialOfferRequestUri).encodedQuery) + /** + * CTWalletCrossInTime, CTWalletSameInTime + */ + "issue in-time credential".config(enabled = VcTestsEnabled) { + forAll( + row("CTWalletCrossInTime", crossDeviceCredentialOfferRequestCaller), + row("CTWalletSameInTime", sameDeviceCredentialOfferRequestCaller), + ) { credentialType, credentialOfferRequestCall -> + val initCredentialOfferUrl = + URLBuilder("https://api-conformance.ebsi.eu/conformance/v3/issuer-mock/initiate-credential-offer?credential_type=$credentialType").run { + parameters.appendAll(StringValues.build { + append("client_id", credentialWallet.TEST_DID) + append("credential_offer_endpoint", "openid-credential-offer://") + }) + build() + } + val credentialOfferRequest = credentialOfferRequestCall(initCredentialOfferUrl) + val credentialOffer = credentialWallet.resolveCredentialOffer(credentialOfferRequest) + val credentialResponses = + credentialWallet.executeFullAuthIssuance(credentialOffer, credentialWallet.TEST_DID, ebsiClientConfig) + credentialResponses.size shouldBe 1 + credentialResponses[0].isDeferred shouldBe false + credentialResponses[0].credential shouldNotBe null + storeCredentials(credentialResponses[0]) + } + } - val credentialResponses = credentialWallet.executeFullAuthIssuance(credentialOfferRequest, credentialWallet.TEST_DID, OpenIDClientConfig(credentialWallet.TEST_DID, null, credentialWallet.config.redirectUri, useCodeChallenge = true)) - credentialResponses.size shouldNotBe 0 + /** + * CTWalletCrossDeferred, CTWalletSameDeferred + */ + "issue deferred credential".config(enabled = VcTestsEnabled) { + forAll( + row("CTWalletCrossDeferred", crossDeviceCredentialOfferRequestCaller), + row("CTWalletSameDeferred", sameDeviceCredentialOfferRequestCaller), + ) { credentialType, credentialOfferRequestCall -> + val initCredentialOfferUrl = + URLBuilder("https://api-conformance.ebsi.eu/conformance/v3/issuer-mock/initiate-credential-offer?credential_type=$credentialType").run { + parameters.appendAll(StringValues.build { + append("client_id", credentialWallet.TEST_DID) + append("credential_offer_endpoint", "openid-credential-offer://") + }) + build() + } + val deferredCredentialOfferRequest = credentialOfferRequestCall(initCredentialOfferUrl) + val deferredCredentialOffer = credentialWallet.resolveCredentialOffer(deferredCredentialOfferRequest) + val deferredCredentialResponses = + credentialWallet.executeFullAuthIssuance(deferredCredentialOffer, credentialWallet.TEST_DID, ebsiClientConfig) + deferredCredentialResponses.size shouldBe 1 + deferredCredentialResponses[0].isDeferred shouldBe true + println("Waiting for deferred credential to be issued (5 seconds delay)") + Thread.sleep(5500) + println("Trying to fetch deferred credential") + val credentialResponse = + credentialWallet.fetchDeferredCredential(deferredCredentialOffer, deferredCredentialResponses[0]) + credentialResponse.isDeferred shouldBe false + credentialResponse.isSuccess shouldBe true + credentialResponse.credential shouldNotBe null + storeCredentials(credentialResponse) + } } + + /** + * CTWalletCrossPreAuthorised, CTWalletSamePreAuthorised + */ + "issue pre-authorized code credential".config(enabled = VcTestsEnabled) { + forAll( + row("CTWalletCrossPreAuthorised", crossDeviceCredentialOfferRequestCaller), + row("CTWalletSamePreAuthorised", sameDeviceCredentialOfferRequestCaller), + ) { credentialType, credentialOfferRequestCall -> + val initCredentialOfferUrl = + URLBuilder("https://api-conformance.ebsi.eu/conformance/v3/issuer-mock/initiate-credential-offer?credential_type=$credentialType").run { + parameters.appendAll(StringValues.build { + append("client_id", credentialWallet.TEST_DID) + append("credential_offer_endpoint", "openid-credential-offer://") + }) + build() + } + val preAuthCredentialOfferRequest = credentialOfferRequestCall(initCredentialOfferUrl) + val preAuthCredentialOffer = credentialWallet.resolveCredentialOffer(preAuthCredentialOfferRequest) + val preAuthCredentialResponses = credentialWallet.executePreAuthorizedCodeFlow( + preAuthCredentialOffer, credentialWallet.TEST_DID, ebsiClientConfig, "3818" + ) + preAuthCredentialResponses.size shouldBe 1 + preAuthCredentialResponses[0].isSuccess shouldBe true + preAuthCredentialResponses[0].credential shouldNotBe null + storeCredentials(preAuthCredentialResponses[0]) + } + } + + /** + * CTWalletQualificationCredential + * Requires all VCs from above + */ + "issue credential using presentation exchange".config(enabled = VpTestsEnabled) { + val initIssuanceWithPresentationExchangeUrl = URLBuilder("https://api-conformance.ebsi.eu/conformance/v3/issuer-mock/initiate-credential-offer?credential_type=CTWalletQualificationCredential").run { + parameters.appendAll(StringValues.build { + append("client_id", credentialWallet.TEST_DID) + append("credential_offer_endpoint", "openid-credential-offer://") + }) + build() + } + val credentialOfferRequestUri = runBlocking { ktorClient.get(initIssuanceWithPresentationExchangeUrl).bodyAsText() } + val credentialOfferRequest = CredentialOfferRequest.fromHttpQueryString(Url(credentialOfferRequestUri).encodedQuery) + val credentialOffer = credentialWallet.resolveCredentialOffer(credentialOfferRequest) + val credentialResponses = credentialWallet.executeFullAuthIssuance(credentialOffer, credentialWallet.TEST_DID, ebsiClientConfig) + credentialResponses.size shouldBe 1 + credentialResponses[0].isDeferred shouldBe false + credentialResponses[0].credential shouldNotBe null + storeCredentials(credentialResponses[0]) + } +}) + +internal typealias credentialOfferRequestCaller = (initCredentialOfferUrl: Url) -> CredentialOfferRequest + +internal fun storeCredentials(vararg credentialResponses: CredentialResponse) = credentialResponses.forEach { + val cred = VerifiableCredential.fromString(it.credential!!.jsonPrimitive.content) + Custodian.getService().storeCredential(cred.id ?: randomUUID(), cred) } \ No newline at end of file