From c0218d1e804ce696e4ac82d0f654be6ace5dccdd Mon Sep 17 00:00:00 2001 From: Severin Stampler Date: Mon, 9 Oct 2023 12:28:09 +0200 Subject: [PATCH 1/6] feat: support credential offer by reference in issuer and credential wallet base refactor: rename SIOPCredentialProvider to OpenIDCredentialWallet --- .../oid4vc/errors/CredentialOfferError.kt | 14 ++ ...derConfig.kt => CredentialWalletConfig.kt} | 2 +- .../providers/OpenIDCredentialIssuer.kt | 20 +++ ...lProvider.kt => OpenIDCredentialWallet.kt} | 15 +- .../kotlin/id/walt/oid4vc/CITestProvider.kt | 9 ++ .../kotlin/id/walt/oid4vc/CI_JVM_Test.kt | 131 +++++++++++++++++- .../id/walt/oid4vc/TestCredentialWallet.kt | 9 +- .../kotlin/id/walt/oid4vc/VP_JVM_Test.kt | 4 +- .../kotlin/id/walt/oid4vc/wallettest.kt | 4 +- 9 files changed, 190 insertions(+), 18 deletions(-) create mode 100644 src/commonMain/kotlin/id/walt/oid4vc/errors/CredentialOfferError.kt rename src/commonMain/kotlin/id/walt/oid4vc/providers/{SIOPProviderConfig.kt => CredentialWalletConfig.kt} (73%) rename src/commonMain/kotlin/id/walt/oid4vc/providers/{SIOPCredentialProvider.kt => OpenIDCredentialWallet.kt} (88%) diff --git a/src/commonMain/kotlin/id/walt/oid4vc/errors/CredentialOfferError.kt b/src/commonMain/kotlin/id/walt/oid4vc/errors/CredentialOfferError.kt new file mode 100644 index 0000000..c11401e --- /dev/null +++ b/src/commonMain/kotlin/id/walt/oid4vc/errors/CredentialOfferError.kt @@ -0,0 +1,14 @@ +package id.walt.oid4vc.errors + +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 +): Exception() { +} + +enum class CredentialOfferErrorCode { + invalid_request +} \ No newline at end of file diff --git a/src/commonMain/kotlin/id/walt/oid4vc/providers/SIOPProviderConfig.kt b/src/commonMain/kotlin/id/walt/oid4vc/providers/CredentialWalletConfig.kt similarity index 73% rename from src/commonMain/kotlin/id/walt/oid4vc/providers/SIOPProviderConfig.kt rename to src/commonMain/kotlin/id/walt/oid4vc/providers/CredentialWalletConfig.kt index cfe4168..c2352b6 100644 --- a/src/commonMain/kotlin/id/walt/oid4vc/providers/SIOPProviderConfig.kt +++ b/src/commonMain/kotlin/id/walt/oid4vc/providers/CredentialWalletConfig.kt @@ -1,5 +1,5 @@ package id.walt.oid4vc.providers -data class SIOPProviderConfig( +data class CredentialWalletConfig( val redirectUri: String? = null ) : OpenIDProviderConfig() diff --git a/src/commonMain/kotlin/id/walt/oid4vc/providers/OpenIDCredentialIssuer.kt b/src/commonMain/kotlin/id/walt/oid4vc/providers/OpenIDCredentialIssuer.kt index fa178be..02de224 100644 --- a/src/commonMain/kotlin/id/walt/oid4vc/providers/OpenIDCredentialIssuer.kt +++ b/src/commonMain/kotlin/id/walt/oid4vc/providers/OpenIDCredentialIssuer.kt @@ -329,4 +329,24 @@ abstract class OpenIDCredentialIssuer( return url } + + /** + * Returns the URI on which the credential offer object can be retrieved for this issuance session, if the request object is passed by reference. + * The returned URI will be used for the credential_offer_uri parameter of the credential offer request. + * Override, to use custom path, by default, the path will be: "$baseUrl/credential_offer/, e.g.: "https://issuer.myhost.com/api/credential_offer/1234-4567-8900" + * @param issuanceSession The issuance session for which the credential offer uri is created + */ + open protected fun getCredentialOfferByReferenceUri(issuanceSession: IssuanceSession): String { + return URLBuilder(baseUrl).appendPathSegments("credential_offer", issuanceSession.id).buildString() + } + + open fun getCredentialOfferRequest( + issuanceSession: IssuanceSession, byReference: Boolean = false + ): CredentialOfferRequest { + return if(byReference) { + CredentialOfferRequest(null, getCredentialOfferByReferenceUri(issuanceSession)) + } else { + CredentialOfferRequest(issuanceSession.credentialOffer) + } + } } diff --git a/src/commonMain/kotlin/id/walt/oid4vc/providers/SIOPCredentialProvider.kt b/src/commonMain/kotlin/id/walt/oid4vc/providers/OpenIDCredentialWallet.kt similarity index 88% rename from src/commonMain/kotlin/id/walt/oid4vc/providers/SIOPCredentialProvider.kt rename to src/commonMain/kotlin/id/walt/oid4vc/providers/OpenIDCredentialWallet.kt index 69ca41a..20a4a8a 100644 --- a/src/commonMain/kotlin/id/walt/oid4vc/providers/SIOPCredentialProvider.kt +++ b/src/commonMain/kotlin/id/walt/oid4vc/providers/OpenIDCredentialWallet.kt @@ -1,15 +1,19 @@ package id.walt.oid4vc.providers +import id.walt.oid4vc.data.CredentialOffer import id.walt.oid4vc.data.OpenIDClientMetadata import id.walt.oid4vc.data.ProofOfPossession import id.walt.oid4vc.data.ResponseType import id.walt.oid4vc.data.dif.PresentationDefinition import id.walt.oid4vc.definitions.JWTClaims import id.walt.oid4vc.errors.AuthorizationError +import id.walt.oid4vc.errors.CredentialOfferError +import id.walt.oid4vc.errors.CredentialOfferErrorCode import id.walt.oid4vc.errors.TokenError import id.walt.oid4vc.interfaces.ITokenProvider import id.walt.oid4vc.interfaces.IVPTokenProvider import id.walt.oid4vc.requests.AuthorizationRequest +import id.walt.oid4vc.requests.CredentialOfferRequest import id.walt.oid4vc.requests.TokenRequest import id.walt.oid4vc.responses.AuthorizationErrorCode import id.walt.oid4vc.responses.TokenErrorCode @@ -30,9 +34,9 @@ import kotlin.time.Duration * in reply to OpenID4VP authorization requests. * e.g.: Verifiable Credentials holder wallets */ -abstract class SIOPCredentialProvider( +abstract class OpenIDCredentialWallet( baseUrl: String, - override val config: SIOPProviderConfig + override val config: CredentialWalletConfig ) : OpenIDProvider(baseUrl), ITokenProvider, IVPTokenProvider { /** * Resolve DID to key ID @@ -156,4 +160,11 @@ abstract class SIOPCredentialProvider( ) } } + + // issuance + open fun getCredentialOffer(credentialOfferRequest: CredentialOfferRequest): CredentialOffer { + return credentialOfferRequest.credentialOffer ?: credentialOfferRequest.credentialOfferUri?.let { uri -> + resolveJSON(uri)?.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") + } } diff --git a/src/jvmTest/kotlin/id/walt/oid4vc/CITestProvider.kt b/src/jvmTest/kotlin/id/walt/oid4vc/CITestProvider.kt index 9bf0d9f..1013c36 100644 --- a/src/jvmTest/kotlin/id/walt/oid4vc/CITestProvider.kt +++ b/src/jvmTest/kotlin/id/walt/oid4vc/CITestProvider.kt @@ -256,6 +256,15 @@ class CITestProvider() : OpenIDCredentialIssuer( } } } + get("/credential_offer/{session_id}") { + val sessionId = call.parameters["session_id"]!! + val credentialOffer = getSession(sessionId)?.credentialOffer + if(credentialOffer != null) { + call.respond(HttpStatusCode.Created, credentialOffer.toJSON()) + } else { + call.respond(HttpStatusCode.NotFound, "Issuance session with given ID not found") + } + } } }.start() } 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 779ab36..6cbe50a 100644 --- a/src/jvmTest/kotlin/id/walt/oid4vc/CI_JVM_Test.kt +++ b/src/jvmTest/kotlin/id/walt/oid4vc/CI_JVM_Test.kt @@ -6,13 +6,12 @@ import id.walt.credentials.w3c.VerifiableCredential import id.walt.oid4vc.data.* import id.walt.oid4vc.definitions.OPENID_CREDENTIAL_AUTHORIZATION_TYPE import id.walt.oid4vc.providers.OpenIDClientConfig -import id.walt.oid4vc.providers.SIOPProviderConfig +import id.walt.oid4vc.providers.CredentialWalletConfig import id.walt.oid4vc.requests.* import id.walt.oid4vc.responses.* import id.walt.servicematrix.ServiceMatrix import io.kotest.assertions.json.shouldMatchJson import io.kotest.core.spec.style.AnnotationSpec -import io.kotest.core.spec.style.Test import io.kotest.matchers.collections.shouldContain import io.kotest.matchers.collections.shouldContainExactly import io.kotest.matchers.maps.shouldContainKey @@ -101,7 +100,7 @@ class CI_JVM_Test : AnnotationSpec() { fun init() { ServiceMatrix("service-matrix.properties") ciTestProvider = CITestProvider() - credentialWallet = TestCredentialWallet(SIOPProviderConfig("http://blank")) + credentialWallet = TestCredentialWallet(CredentialWalletConfig("http://blank")) ciTestProvider.start() } @@ -838,8 +837,7 @@ class CI_JVM_Test : AnnotationSpec() { val credReq = CredentialRequest.forOfferedCredential( offeredCredentials.first(), credentialWallet.generateDidProof( - credentialWallet.TEST_DID, credOfferReq.credentialOfferUri - ?: credOfferReq.credentialOffer!!.credentialIssuer, tokenResp.cNonce + credentialWallet.TEST_DID, credOfferReq.credentialOffer!!.credentialIssuer, tokenResp.cNonce ) ) println("credReq: $credReq") @@ -893,7 +891,7 @@ class CI_JVM_Test : AnnotationSpec() { val credReq = CredentialRequest.forOfferedCredential( offeredCredentials.first(), credentialWallet.generateDidProof( - credentialWallet.TEST_DID, credOfferReq.credentialOfferUri + credentialWallet.TEST_DID, credOfferReq.credentialOffer?.credentialIssuer ?: credOfferReq.credentialOffer!!.credentialIssuer, tokenResp.cNonce ) ) @@ -910,4 +908,125 @@ class CI_JVM_Test : AnnotationSpec() { credentialResp.credential shouldNotBe null println(credentialResp.credential!!.let { VerifiableCredential.fromString(it.jsonPrimitive.content) }.toJson()) } + + // issuance by reference + + @Test + suspend fun testCredentialOfferByReference() { + println("// -------- CREDENTIAL ISSUER ----------") + println("// as CI provider, initialize credential offer for user, this time providing full offered credential object, and allowing pre-authorized code flow with user pin") + val issuanceSession = ciTestProvider.initializeCredentialOffer( + CredentialOffer.Builder(ciTestProvider.baseUrl) + .addOfferedCredential(OfferedCredential.fromProviderMetadata(ciTestProvider.metadata.credentialsSupported!!.first())), + 5.minutes, allowPreAuthorized = true, preAuthUserPin = "1234" + ) + println("issuanceSession: $issuanceSession") + + issuanceSession.credentialOffer shouldNotBe null + issuanceSession.credentialOffer!!.credentials.first() shouldBe instanceOf() + + val offerRequest = ciTestProvider.getCredentialOfferRequest(issuanceSession, byReference = true) + println("offerRequest: $offerRequest") + offerRequest.credentialOffer shouldBe null + offerRequest.credentialOfferUri shouldNotBe null + + println("// create credential offer request url (this time cross-device)") + val offerUri = ciTestProvider.getCredentialOfferRequestUrl(offerRequest) + println("Offer URI: $offerUri") + + println("// -------- WALLET ----------") + 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())) + + credentialOffer.credentialIssuer shouldNotBe null + credentialOffer.grants.keys shouldContain GrantType.pre_authorized_code.value + credentialOffer.grants[GrantType.pre_authorized_code.value]?.preAuthorizedCode shouldNotBe null + credentialOffer.grants[GrantType.pre_authorized_code.value]?.userPinRequired shouldBe true + + println("// get issuer metadata") + val providerMetadataUri = + credentialWallet.getCIProviderMetadataUrl(credentialOffer.credentialIssuer) + val providerMetadata = ktorClient.get(providerMetadataUri).call.body() + println("providerMetadata: $providerMetadata") + + providerMetadata.credentialsSupported shouldNotBe null + + println("// resolve offered credentials") + val offeredCredentials = credentialOffer.resolveOfferedCredentials(providerMetadata) + println("offeredCredentials: $offeredCredentials") + offeredCredentials.size shouldBe 1 + offeredCredentials.first().format shouldBe CredentialFormat.jwt_vc_json + offeredCredentials.first().types?.last() shouldBe "VerifiableId" + val offeredCredential = offeredCredentials.first() + println("offeredCredentials[0]: $offeredCredential") + + println("// fetch access token using pre-authorized code (skipping authorization step)") + println("// try without user PIN, should be rejected!") + var tokenReq = TokenRequest( + grantType = GrantType.pre_authorized_code, + //clientId = testCIClientConfig.clientID, + redirectUri = credentialWallet.config.redirectUri, + preAuthorizedCode = credentialOffer.grants[GrantType.pre_authorized_code.value]!!.preAuthorizedCode, + userPin = null + ) + println("tokenReq: $tokenReq") + + var tokenResp = ktorClient.submitForm( + providerMetadata.tokenEndpoint!!, formParameters = parametersOf(tokenReq.toHttpParameters()) + ).body().let { TokenResponse.fromJSON(it) } + println("tokenResp: $tokenResp") + + tokenResp.isSuccess shouldBe false + tokenResp.error shouldBe TokenErrorCode.invalid_grant.name + + println("// try with user PIN, should work:") + tokenReq = TokenRequest( + grantType = GrantType.pre_authorized_code, + clientId = testCIClientConfig.clientID, + redirectUri = credentialWallet.config.redirectUri, + preAuthorizedCode = credentialOffer.grants[GrantType.pre_authorized_code.value]!!.preAuthorizedCode, + userPin = issuanceSession.preAuthUserPin + ) + println("tokenReq: $tokenReq") + + tokenResp = ktorClient.submitForm( + providerMetadata.tokenEndpoint!!, formParameters = parametersOf(tokenReq.toHttpParameters()) + ).body().let { TokenResponse.fromJSON(it) } + println("tokenResp: $tokenResp") + + tokenResp.isSuccess shouldBe true + tokenResp.accessToken shouldNotBe null + tokenResp.cNonce shouldNotBe null + + println("// receive credential") + ciTestProvider.deferIssuance = false + var nonce = tokenResp.cNonce!! + + val credReq = CredentialRequest.forOfferedCredential( + offeredCredential, + credentialWallet.generateDidProof(credentialWallet.TEST_DID, ciTestProvider.baseUrl, nonce) + ) + println("credReq: $credReq") + + val credentialResp = ktorClient.post(providerMetadata.credentialEndpoint!!) { + contentType(ContentType.Application.Json) + bearerAuth(tokenResp.accessToken!!) + setBody(credReq.toJSON()) + }.body().let { CredentialResponse.fromJSON(it) } + println("credentialResp: $credentialResp") + + credentialResp.isSuccess shouldBe true + credentialResp.isDeferred shouldBe false + credentialResp.format!! shouldBe CredentialFormat.jwt_vc_json + credentialResp.credential.shouldBeInstanceOf() + + println("// parse and verify credential") + val credential = VerifiableCredential.fromString(credentialResp.credential!!.jsonPrimitive.content) + println(">>> Issued credential: $credential") + credential.issuer?.id shouldBe ciTestProvider.baseUrl + credential.credentialSubject?.id shouldBe credentialWallet.TEST_DID + Auditor.getService().verify(credential, listOf(SignaturePolicy())).result shouldBe true + } } diff --git a/src/jvmTest/kotlin/id/walt/oid4vc/TestCredentialWallet.kt b/src/jvmTest/kotlin/id/walt/oid4vc/TestCredentialWallet.kt index 1ed3a9b..229a18c 100644 --- a/src/jvmTest/kotlin/id/walt/oid4vc/TestCredentialWallet.kt +++ b/src/jvmTest/kotlin/id/walt/oid4vc/TestCredentialWallet.kt @@ -13,10 +13,9 @@ import id.walt.oid4vc.data.dif.PresentationSubmission import id.walt.oid4vc.data.dif.VCFormat import id.walt.oid4vc.errors.AuthorizationError import id.walt.oid4vc.errors.PresentationError -import id.walt.oid4vc.errors.TokenError import id.walt.oid4vc.interfaces.PresentationResult -import id.walt.oid4vc.providers.SIOPCredentialProvider -import id.walt.oid4vc.providers.SIOPProviderConfig +import id.walt.oid4vc.providers.OpenIDCredentialWallet +import id.walt.oid4vc.providers.CredentialWalletConfig import id.walt.oid4vc.providers.SIOPSession import id.walt.oid4vc.providers.TokenTarget import id.walt.oid4vc.requests.AuthorizationRequest @@ -48,8 +47,8 @@ const val WALLET_PORT = 8001 const val WALLET_BASE_URL = "http://localhost:${WALLET_PORT}" class TestCredentialWallet( - config: SIOPProviderConfig -) : SIOPCredentialProvider(WALLET_BASE_URL, config) { + config: CredentialWalletConfig +) : OpenIDCredentialWallet(WALLET_BASE_URL, config) { private val sessionCache = mutableMapOf() private val ktorClient = HttpClient(CIO) { diff --git a/src/jvmTest/kotlin/id/walt/oid4vc/VP_JVM_Test.kt b/src/jvmTest/kotlin/id/walt/oid4vc/VP_JVM_Test.kt index c9ff72d..cd0a8ee 100644 --- a/src/jvmTest/kotlin/id/walt/oid4vc/VP_JVM_Test.kt +++ b/src/jvmTest/kotlin/id/walt/oid4vc/VP_JVM_Test.kt @@ -8,7 +8,7 @@ import id.walt.oid4vc.data.OpenIDClientMetadata import id.walt.oid4vc.data.ResponseMode import id.walt.oid4vc.data.ResponseType import id.walt.oid4vc.data.dif.* -import id.walt.oid4vc.providers.SIOPProviderConfig +import id.walt.oid4vc.providers.CredentialWalletConfig import id.walt.oid4vc.requests.AuthorizationRequest import id.walt.oid4vc.responses.TokenResponse import id.walt.servicematrix.ServiceMatrix @@ -51,7 +51,7 @@ class VP_JVM_Test : AnnotationSpec() { @BeforeAll fun init() { ServiceMatrix("service-matrix.properties") - testWallet = TestCredentialWallet(SIOPProviderConfig(WALLET_BASE_URL)) + testWallet = TestCredentialWallet(CredentialWalletConfig(WALLET_BASE_URL)) testWallet.start() testVerifier = VPTestVerifier() diff --git a/src/jvmTest/kotlin/id/walt/oid4vc/wallettest.kt b/src/jvmTest/kotlin/id/walt/oid4vc/wallettest.kt index a9e913c..b1084da 100644 --- a/src/jvmTest/kotlin/id/walt/oid4vc/wallettest.kt +++ b/src/jvmTest/kotlin/id/walt/oid4vc/wallettest.kt @@ -8,7 +8,7 @@ import id.walt.oid4vc.data.CredentialFormat import id.walt.oid4vc.data.GrantType import id.walt.oid4vc.data.OpenIDProviderMetadata import id.walt.oid4vc.providers.OpenIDClientConfig -import id.walt.oid4vc.providers.SIOPProviderConfig +import id.walt.oid4vc.providers.CredentialWalletConfig import id.walt.oid4vc.requests.AuthorizationRequest import id.walt.oid4vc.requests.CredentialOfferRequest import id.walt.oid4vc.requests.CredentialRequest @@ -58,7 +58,7 @@ class wallettest : AnnotationSpec() { fun init() { ServiceMatrix("service-matrix.properties") ciTestProvider = CITestProvider() - credentialWallet = TestCredentialWallet(SIOPProviderConfig("http://blank")) + credentialWallet = TestCredentialWallet(CredentialWalletConfig("http://blank")) //ciTestProvider.start() } From 8de5c920b254be9699147a2866d1d5f9848407a0 Mon Sep 17 00:00:00 2001 From: Severin Stampler Date: Mon, 9 Oct 2023 14:12:32 +0200 Subject: [PATCH 2/6] refactor: some changes for first steps of ebsi compliance --- build.gradle.kts | 3 +- .../id/walt/oid4vc/data/CredentialFormat.kt | 4 +- .../kotlin/id/walt/oid4vc/CI_JVM_Test.kt | 4 +- .../kotlin/id/walt/oid4vc/EBSITestWallet.kt | 142 ++++++++++++++++++ .../id/walt/oid4vc/EBSI_Conformance_Test.kt | 62 ++++++++ .../id/walt/oid4vc/TestCredentialWallet.kt | 4 +- .../kotlin/id/walt/oid4vc/VP_JVM_Test.kt | 4 +- .../kotlin/id/walt/oid4vc/wallettest.kt | 4 +- 8 files changed, 217 insertions(+), 10 deletions(-) create mode 100644 src/jvmTest/kotlin/id/walt/oid4vc/EBSITestWallet.kt create mode 100644 src/jvmTest/kotlin/id/walt/oid4vc/EBSI_Conformance_Test.kt diff --git a/build.gradle.kts b/build.gradle.kts index 4ffec51..e3912be 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -107,7 +107,8 @@ kotlin { implementation("io.ktor:ktor-server-default-headers-jvm:$ktor_version") implementation("io.ktor:ktor-server-content-negotiation:$ktor_version") implementation("io.ktor:ktor-client-core:$ktor_version") - implementation("io.ktor:ktor-client-cio:$ktor_version") + //implementation("io.ktor:ktor-client-cio:$ktor_version") + implementation("io.ktor:ktor-client-java:$ktor_version") implementation("io.ktor:ktor-client-content-negotiation:$ktor_version") implementation("io.ktor:ktor-serialization-kotlinx-json:$ktor_version") implementation("io.ktor:ktor-client-logging-jvm:$ktor_version") diff --git a/src/commonMain/kotlin/id/walt/oid4vc/data/CredentialFormat.kt b/src/commonMain/kotlin/id/walt/oid4vc/data/CredentialFormat.kt index 37c30af..423d33e 100644 --- a/src/commonMain/kotlin/id/walt/oid4vc/data/CredentialFormat.kt +++ b/src/commonMain/kotlin/id/walt/oid4vc/data/CredentialFormat.kt @@ -14,7 +14,9 @@ enum class CredentialFormat(val value: String) { mso_mdoc("mso_mdoc"), jwt_vp_json("jwt_vp_json"), jwt_vp_json_ld("jwt_vp_json-ld"), - ldp_vp("ldp_vp"); + ldp_vp("ldp_vp"), + jwt_vc("jwt_vc"), + jwt_vp("jwt_vp"); companion object { fun fromValue(value: String): CredentialFormat? { 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 6cbe50a..e1bca99 100644 --- a/src/jvmTest/kotlin/id/walt/oid4vc/CI_JVM_Test.kt +++ b/src/jvmTest/kotlin/id/walt/oid4vc/CI_JVM_Test.kt @@ -24,7 +24,7 @@ import io.kotest.matchers.types.instanceOf import io.kotest.matchers.types.shouldBeInstanceOf import io.ktor.client.* import io.ktor.client.call.* -import io.ktor.client.engine.cio.* +import io.ktor.client.engine.java.* import io.ktor.client.plugins.contentnegotiation.* import io.ktor.client.request.* import io.ktor.client.request.forms.* @@ -85,7 +85,7 @@ class CI_JVM_Test : AnnotationSpec() { ) ) - val ktorClient = HttpClient(CIO) { + val ktorClient = HttpClient(Java) { install(ContentNegotiation) { json() } diff --git a/src/jvmTest/kotlin/id/walt/oid4vc/EBSITestWallet.kt b/src/jvmTest/kotlin/id/walt/oid4vc/EBSITestWallet.kt new file mode 100644 index 0000000..551d758 --- /dev/null +++ b/src/jvmTest/kotlin/id/walt/oid4vc/EBSITestWallet.kt @@ -0,0 +1,142 @@ +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 +import id.walt.oid4vc.data.dif.PresentationSubmission +import id.walt.oid4vc.data.dif.VCFormat +import id.walt.oid4vc.errors.PresentationError +import id.walt.oid4vc.interfaces.PresentationResult +import id.walt.oid4vc.providers.CredentialWalletConfig +import id.walt.oid4vc.providers.OpenIDCredentialWallet +import id.walt.oid4vc.providers.SIOPSession +import id.walt.oid4vc.providers.TokenTarget +import id.walt.oid4vc.requests.AuthorizationRequest +import id.walt.oid4vc.requests.TokenRequest +import id.walt.oid4vc.responses.TokenErrorCode +import id.walt.services.did.DidService +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.request.* +import io.ktor.serialization.kotlinx.json.* +import kotlinx.datetime.Instant +import kotlinx.serialization.json.* + +const val EBSI_WALLET_PORT = 8011 +const val EBSI_WALLET_BASE_URL = "http://localhost:${EBSI_WALLET_PORT}" +const val EBSI_WALLET_TEST_KEY = "{\"kty\":\"OKP\",\"d\":\"2jDhK3fFep3oaqcVr8548CLJ3_8bgxG9z8DBL6PIrGI\",\"use\":\"sig\",\"crv\":\"Ed25519\",\"kid\":\"ed757b38cbf34afbb1f157c5e2bf08f8\",\"x\":\"1nve9sZ_SmDpuo3A5x4ccjKan5Up2qvMg7qsXOtXVkU\",\"alg\":\"EdDSA\"}" +const val EBSI_WALLET_TEST_DID = "did:key:zmYg9bgKmRiCqTTd9MA1ufVE9tfzUptwQp4GMRxptXquJWw4Uj5bVzbAR3ScDrvTYPMZzRCCyZUidTqbgTvbDjZDEzf3XwwVPothBG3iX7xxc9r1A" +class EBSITestWallet(config: CredentialWalletConfig): OpenIDCredentialWallet(EBSI_WALLET_BASE_URL, config) { + private val sessionCache = mutableMapOf() + private val ktorClient = HttpClient(Java) { + install(ContentNegotiation) { + json() + } + } + + val TEST_DID = EBSI_WALLET_TEST_DID + + init { + if(!KeyService.getService().hasKey(EBSI_WALLET_TEST_DID)) { + val keyId = KeyService.getService().importKey(EBSI_WALLET_TEST_KEY) + KeyService.getService().addAlias(keyId, EBSI_WALLET_TEST_DID) + val didDoc = DidService.resolve(EBSI_WALLET_TEST_DID) + KeyService.getService().addAlias(keyId, didDoc.verificationMethod!!.first().id) + } + } + override fun resolveDID(did: String): String { + val didObj = DidService.resolve(did) + return (didObj.authentication ?: didObj.assertionMethod ?: didObj.verificationMethod)?.firstOrNull()?.id ?: did + } + + override fun resolveJSON(url: String): JsonObject? { + return runBlocking { ktorClient.get(url).body() } + } + + override fun isPresentationDefinitionSupported(presentationDefinition: PresentationDefinition): Boolean { + return true + } + + override fun createSIOPSession( + id: String, + authorizationRequest: AuthorizationRequest?, + expirationTimestamp: Instant + ) = SIOPSession(id, authorizationRequest, expirationTimestamp) + + override val metadata: OpenIDProviderMetadata + get() = createDefaultProviderMetadata() + + override fun getSession(id: String): SIOPSession? = sessionCache[id] + + override fun removeSession(id: String): SIOPSession? = sessionCache.remove(id) + + override fun signToken(target: TokenTarget, payload: JsonObject, header: JsonObject?, keyId: String?) = + JwtService.getService().sign(payload, keyId) + + override fun verifyTokenSignature(target: TokenTarget, token: String) = + JwtService.getService().verify(token).verified + + 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 + ) + + 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 } + return PresentationResult( + listOf(JsonPrimitive(presentationJwtStr)), PresentationSubmission( + id = "submission 1", + 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]", + ) + ) + } + ) + ) + } + + override fun putSession(id: String, session: SIOPSession): SIOPSession? = sessionCache.put(id, session) + + +} \ 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 new file mode 100644 index 0000000..cd081a8 --- /dev/null +++ b/src/jvmTest/kotlin/id/walt/oid4vc/EBSI_Conformance_Test.kt @@ -0,0 +1,62 @@ +package id.walt.oid4vc + +import id.walt.oid4vc.data.GrantType +import id.walt.oid4vc.data.OpenIDProviderMetadata +import id.walt.oid4vc.providers.CredentialWalletConfig +import id.walt.oid4vc.requests.CredentialOfferRequest +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.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 + +class EBSI_Conformance_Test: AnnotationSpec() { + + lateinit var credentialWallet: EBSITestWallet + + val ktorClient = HttpClient(Java) { + install(ContentNegotiation) { + json() + } + followRedirects = false + } + + @BeforeAll + fun init() { + ServiceMatrix("service-matrix.properties") + credentialWallet = EBSITestWallet(id.walt.oid4vc.providers.CredentialWalletConfig()) + } + + @Test + fun testReceiveCredential() { + val initCredentialOfferUrl = "https://api-conformance.ebsi.eu/conformance/v3/issuer-mock/initiate-credential-offer?credential_type=CTWalletCrossInTime&client_id=did:key:zmYg9bgKmRiCqTTd9MA1ufVE9tfzUptwQp4GMRxptXquJWw4Uj5bVzbAR3ScDrvTYPMZzRCCyZUidTqbgTvbDjZDEzf3XwwVPothBG3iX7xxc9r1A&credential_offer_endpoint=openid-credential-offer://" + val inTimeCredentialOfferRequest = runBlocking { ktorClient.get(Url(initCredentialOfferUrl)).bodyAsText() } + + val credentialOffer = credentialWallet.getCredentialOffer(CredentialOfferRequest.fromHttpQueryString(Url(inTimeCredentialOfferRequest).encodedQuery)) + credentialOffer.credentialIssuer shouldBe "https://api-conformance.ebsi.eu/conformance/v3/issuer-mock" + + val state = credentialOffer.grants[GrantType.authorization_code.value]?.issuerState + println("// get issuer metadata") + val providerMetadataUri = + credentialWallet.getCIProviderMetadataUrl(credentialOffer.credentialIssuer) + val providerMetadata = credentialWallet.resolveJSON(providerMetadataUri)?.let { OpenIDProviderMetadata.fromJSON(it) } + providerMetadata shouldNotBe null + println("providerMetadata: $providerMetadata") + println("// resolve offered credentials") + val offeredCredentials = credentialOffer.resolveOfferedCredentials(providerMetadata!!) + println("offeredCredentials: $offeredCredentials") + offeredCredentials.size shouldNotBe 0 + + + } +} \ No newline at end of file diff --git a/src/jvmTest/kotlin/id/walt/oid4vc/TestCredentialWallet.kt b/src/jvmTest/kotlin/id/walt/oid4vc/TestCredentialWallet.kt index 229a18c..b4d9c89 100644 --- a/src/jvmTest/kotlin/id/walt/oid4vc/TestCredentialWallet.kt +++ b/src/jvmTest/kotlin/id/walt/oid4vc/TestCredentialWallet.kt @@ -28,7 +28,7 @@ import id.walt.services.jwt.JwtService import io.kotest.common.runBlocking import io.ktor.client.* import io.ktor.client.call.* -import io.ktor.client.engine.cio.* +import io.ktor.client.engine.java.* import io.ktor.client.request.* import io.ktor.client.request.forms.* import io.ktor.http.* @@ -51,7 +51,7 @@ class TestCredentialWallet( ) : OpenIDCredentialWallet(WALLET_BASE_URL, config) { private val sessionCache = mutableMapOf() - private val ktorClient = HttpClient(CIO) { + private val ktorClient = HttpClient(Java) { install(io.ktor.client.plugins.contentnegotiation.ContentNegotiation) { json() } diff --git a/src/jvmTest/kotlin/id/walt/oid4vc/VP_JVM_Test.kt b/src/jvmTest/kotlin/id/walt/oid4vc/VP_JVM_Test.kt index cd0a8ee..0cb394a 100644 --- a/src/jvmTest/kotlin/id/walt/oid4vc/VP_JVM_Test.kt +++ b/src/jvmTest/kotlin/id/walt/oid4vc/VP_JVM_Test.kt @@ -19,7 +19,7 @@ import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldNotBe import io.ktor.client.* import io.ktor.client.call.* -import io.ktor.client.engine.cio.* +import io.ktor.client.engine.java.* import io.ktor.client.plugins.contentnegotiation.* import io.ktor.client.plugins.logging.* import io.ktor.client.request.* @@ -37,7 +37,7 @@ class VP_JVM_Test : AnnotationSpec() { private lateinit var testWallet: TestCredentialWallet private lateinit var testVerifier: VPTestVerifier - val http = HttpClient(CIO) { + val http = HttpClient(Java) { install(ContentNegotiation) { json() } diff --git a/src/jvmTest/kotlin/id/walt/oid4vc/wallettest.kt b/src/jvmTest/kotlin/id/walt/oid4vc/wallettest.kt index b1084da..72fe660 100644 --- a/src/jvmTest/kotlin/id/walt/oid4vc/wallettest.kt +++ b/src/jvmTest/kotlin/id/walt/oid4vc/wallettest.kt @@ -23,7 +23,7 @@ import io.kotest.matchers.shouldNotBe import io.kotest.matchers.types.shouldBeInstanceOf import io.ktor.client.* import io.ktor.client.call.* -import io.ktor.client.engine.cio.* +import io.ktor.client.engine.java.* import io.ktor.client.plugins.contentnegotiation.* import io.ktor.client.request.* import io.ktor.client.request.forms.* @@ -43,7 +43,7 @@ class wallettest : AnnotationSpec() { * 3. Run test "wallettest" (this file) */ - private val ktorClient = HttpClient(CIO) { + private val ktorClient = HttpClient(Java) { install(ContentNegotiation) { json() } From b4b9f1f65245c07837ee3a43b9d0c5c8247d039e Mon Sep 17 00:00:00 2001 From: Severin Stampler Date: Mon, 9 Oct 2023 19:46:34 +0200 Subject: [PATCH 3/6] feat: implement full auth code flow in openid lib, with http client interface abstraction test: start implementing ebsi issuance test, to figure out missing aspects --- .../oid4vc/data/OpenIDProviderMetadata.kt | 2 +- .../oid4vc/errors/CredentialOfferError.kt | 3 +- .../id/walt/oid4vc/interfaces/IHttpClient.kt | 17 +++ .../providers/OpenIDCredentialWallet.kt | 138 ++++++++++++++---- .../oid4vc/responses/CredentialResponse.kt | 3 +- .../id/walt/oid4vc/responses/TokenResponse.kt | 3 +- .../kotlin/id/walt/oid4vc/EBSITestWallet.kt | 40 ++++- .../id/walt/oid4vc/EBSI_Conformance_Test.kt | 26 +--- .../id/walt/oid4vc/TestCredentialWallet.kt | 36 ++++- 9 files changed, 210 insertions(+), 58 deletions(-) create mode 100644 src/commonMain/kotlin/id/walt/oid4vc/interfaces/IHttpClient.kt diff --git a/src/commonMain/kotlin/id/walt/oid4vc/data/OpenIDProviderMetadata.kt b/src/commonMain/kotlin/id/walt/oid4vc/data/OpenIDProviderMetadata.kt index b7228ba..70c5ad0 100644 --- a/src/commonMain/kotlin/id/walt/oid4vc/data/OpenIDProviderMetadata.kt +++ b/src/commonMain/kotlin/id/walt/oid4vc/data/OpenIDProviderMetadata.kt @@ -111,7 +111,7 @@ data class OpenIDProviderMetadata( @SerialName("authorization_server") val authorizationServer: String? = null, @SerialName("display") @Serializable(DisplayPropertiesListSerializer::class) val display: List? = null, @SerialName("presentation_definition_uri_supported") val presentationDefinitionUriSupported: Boolean? = null, - @SerialName("vp_formats_supported") @Serializable(SupportedVPFormatMapSerializer::class) val vpFormatsSupported: Map? = null, + //@SerialName("vp_formats_supported") @Serializable(SupportedVPFormatMapSerializer::class) val vpFormatsSupported: Map? = null, @SerialName("client_id_schemes_supported") val clientIdSchemesSupported: List? = null, override val customParameters: Map = mapOf() ) : JsonDataObject() { diff --git a/src/commonMain/kotlin/id/walt/oid4vc/errors/CredentialOfferError.kt b/src/commonMain/kotlin/id/walt/oid4vc/errors/CredentialOfferError.kt index c11401e..965d34e 100644 --- a/src/commonMain/kotlin/id/walt/oid4vc/errors/CredentialOfferError.kt +++ b/src/commonMain/kotlin/id/walt/oid4vc/errors/CredentialOfferError.kt @@ -10,5 +10,6 @@ class CredentialOfferError( } enum class CredentialOfferErrorCode { - invalid_request + invalid_request, + invalid_issuer } \ No newline at end of file diff --git a/src/commonMain/kotlin/id/walt/oid4vc/interfaces/IHttpClient.kt b/src/commonMain/kotlin/id/walt/oid4vc/interfaces/IHttpClient.kt new file mode 100644 index 0000000..c5a0c89 --- /dev/null +++ b/src/commonMain/kotlin/id/walt/oid4vc/interfaces/IHttpClient.kt @@ -0,0 +1,17 @@ +package id.walt.oid4vc.interfaces + +import io.ktor.http.* +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject + +data class SimpleHttpResponse ( + val status: HttpStatusCode, + val headers: Headers, + val body: String? +) +interface IHttpClient { + fun httpGet(url: Url, headers: Headers? = null): SimpleHttpResponse + fun httpPostObject(url: Url, jsonObject: JsonObject, headers: Headers? = null): SimpleHttpResponse + fun httpSubmitForm(url: Url, formParameters: Parameters, headers: Headers? = null): SimpleHttpResponse +} \ No newline at end of file diff --git a/src/commonMain/kotlin/id/walt/oid4vc/providers/OpenIDCredentialWallet.kt b/src/commonMain/kotlin/id/walt/oid4vc/providers/OpenIDCredentialWallet.kt index 20a4a8a..3e85539 100644 --- a/src/commonMain/kotlin/id/walt/oid4vc/providers/OpenIDCredentialWallet.kt +++ b/src/commonMain/kotlin/id/walt/oid4vc/providers/OpenIDCredentialWallet.kt @@ -1,32 +1,21 @@ package id.walt.oid4vc.providers -import id.walt.oid4vc.data.CredentialOffer -import id.walt.oid4vc.data.OpenIDClientMetadata -import id.walt.oid4vc.data.ProofOfPossession -import id.walt.oid4vc.data.ResponseType +import id.walt.oid4vc.data.* import id.walt.oid4vc.data.dif.PresentationDefinition import id.walt.oid4vc.definitions.JWTClaims -import id.walt.oid4vc.errors.AuthorizationError -import id.walt.oid4vc.errors.CredentialOfferError -import id.walt.oid4vc.errors.CredentialOfferErrorCode -import id.walt.oid4vc.errors.TokenError +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 import id.walt.oid4vc.interfaces.IVPTokenProvider -import id.walt.oid4vc.requests.AuthorizationRequest -import id.walt.oid4vc.requests.CredentialOfferRequest -import id.walt.oid4vc.requests.TokenRequest -import id.walt.oid4vc.responses.AuthorizationErrorCode -import id.walt.oid4vc.responses.TokenErrorCode -import id.walt.oid4vc.responses.TokenResponse +import id.walt.oid4vc.requests.* +import id.walt.oid4vc.responses.* import id.walt.oid4vc.util.randomUUID import io.ktor.http.* import kotlinx.datetime.Clock import kotlinx.datetime.Instant import kotlinx.serialization.SerializationException -import kotlinx.serialization.json.JsonArray -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.put +import kotlinx.serialization.json.* import kotlin.time.Duration /** @@ -37,7 +26,7 @@ import kotlin.time.Duration abstract class OpenIDCredentialWallet( baseUrl: String, override val config: CredentialWalletConfig -) : OpenIDProvider(baseUrl), ITokenProvider, IVPTokenProvider { +) : OpenIDProvider(baseUrl), ITokenProvider, IVPTokenProvider, IHttpClient { /** * Resolve DID to key ID * @param did DID to resolve @@ -45,6 +34,8 @@ abstract class OpenIDCredentialWallet( */ abstract fun resolveDID(did: String): String + fun httpGetAsJson(url: Url): JsonElement? = httpGet(url).body?.let { Json.decodeFromString(it) } + open fun generateDidProof( did: String, issuerUrl: String, @@ -66,18 +57,16 @@ abstract class OpenIDCredentialWallet( open fun getCIProviderMetadataUrl(baseUrl: String): String { return URLBuilder(baseUrl).apply { - pathSegments = this.pathSegments.plus(listOf(".well-known", "openid-credential-issuer")) + appendPathSegments(".well-known", "openid-credential-issuer") }.buildString() } fun getCommonProviderMetadataUrl(baseUrl: String): String { return URLBuilder(baseUrl).apply { - pathSegments = listOf(".well-known", "openid-configuration") + appendPathSegments(".well-known", "openid-configuration") }.buildString() } - abstract fun resolveJSON(url: String): JsonObject? - protected abstract fun isPresentationDefinitionSupported(presentationDefinition: PresentationDefinition): Boolean override fun validateAuthorizationRequest(authorizationRequest: AuthorizationRequest): Boolean { @@ -93,7 +82,7 @@ abstract class OpenIDCredentialWallet( presentationDefinition = authorizationRequest.presentationDefinition ?: authorizationRequest.presentationDefinitionUri?.let { PresentationDefinition.fromJSON( - resolveJSON(it) + httpGetAsJson(Url(it))?.jsonObject ?: throw AuthorizationError( authorizationRequest, AuthorizationErrorCode.invalid_presentation_definition_uri, @@ -106,8 +95,8 @@ abstract class OpenIDCredentialWallet( message = "Presentation definition could not be resolved from presentation_definition or presentation_definition_uri parameters" ), clientMetadata = authorizationRequest.clientMetadata - ?: authorizationRequest.clientMetadataUri?.let { - resolveJSON(it)?.let { OpenIDClientMetadata.fromJSON(it) } + ?: authorizationRequest.clientMetadataUri?.let { uri -> + httpGetAsJson(Url(uri))?.jsonObject?.let { OpenIDClientMetadata.fromJSON(it) } } ) } catch (exc: SerializationException) { @@ -161,10 +150,103 @@ abstract class OpenIDCredentialWallet( } } - // issuance + // ========================================================== + // =============== issuance flow =========================== open fun getCredentialOffer(credentialOfferRequest: CredentialOfferRequest): CredentialOffer { return credentialOfferRequest.credentialOffer ?: credentialOfferRequest.credentialOfferUri?.let { uri -> - resolveJSON(uri)?.let { CredentialOffer.fromJSON(it) } + 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") } + + 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") + 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 authorizationServerMetadata = issuerMetadata.authorizationServer?.let { authServer -> + httpGetAsJson(Url(getCommonProviderMetadataUrl(authServer)))?.jsonObject?.let { OpenIDProviderMetadata.fromJSON(it) } + } ?: issuerMetadata + val offeredCredentials = credentialOffer.resolveOfferedCredentials(issuerMetadata) + + val authReq = AuthorizationRequest( + responseType = ResponseType.getResponseTypeString(ResponseType.code), + clientId = client.clientID, + redirectUri = config.redirectUri, + scope = setOf("openid"), + issuerState = credentialOffer.grants[GrantType.authorization_code.value]!!.issuerState, + authorizationDetails = offeredCredentials.map { AuthorizationDetails.fromOfferedCredential(it) } + ).let { authReq -> + if (authorizationServerMetadata.pushedAuthorizationRequestEndpoint != null) { + // execute pushed authorization request + println("// 1. send pushed authorization request with authorization details, containing info of credentials to be issued, receive session id") + println("pushedAuthReq: $authReq") + + val pushedAuthResp = httpSubmitForm( + Url(authorizationServerMetadata.pushedAuthorizationRequestEndpoint), + formParameters = parametersOf(authReq.toHttpParameters()) + ).body?.let { PushedAuthorizationResponse.fromJSONString(it) } ?: throw AuthorizationError( + authReq, + AuthorizationErrorCode.server_error, + "Pushed authorization request didn't succeed" + ) + println("pushedAuthResp: $pushedAuthResp") + + println("// 2. call authorize endpoint with request uri, receive HTTP redirect (302 Found) with Location header") + AuthorizationRequest( + responseType = ResponseType.code.name, + clientId = client.clientID, + requestUri = pushedAuthResp.requestUri + ) + } else authReq + } + + println("authReq: $authReq") + val authResp = httpGet(URLBuilder(Url(authorizationServerMetadata.authorizationEndpoint!!)).also { + it.parameters.appendAll(parametersOf(authReq.toHttpParameters())) + }.build()) + println("authResp: $authResp") + if(authResp.status != HttpStatusCode.Found) throw AuthorizationError(authReq, AuthorizationErrorCode.server_error, "Got unexpected status code ${authResp.status.value} from issuer") + val location = Url(authResp.headers[HttpHeaders.Location]!!) + println("location: $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) + 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) + if(tokenResp.accessToken == null) throw TokenError(tokenReq, TokenErrorCode.server_error, "No access token returned by server") + + var nonce = tokenResp.cNonce + return if(issuerMetadata.batchCredentialEndpoint.isNullOrEmpty() || offeredCredentials.size == 1) { + // execute credential requests individually + offeredCredentials.map { offeredCredential -> + val credReq = CredentialRequest.forOfferedCredential(offeredCredential, generateDidProof(holderDid, credentialOffer.credentialIssuer, nonce, client)) + executeCredentialRequest( + issuerMetadata.credentialEndpoint ?: throw CredentialError(credReq, CredentialErrorCode.server_error, "No credential endpoint specified in issuer metadata"), + tokenResp.accessToken, credReq).also { + nonce = it.cNonce ?: nonce + } + } + } else { + // execute batch credential request + executeBatchCredentialRequest(issuerMetadata.batchCredentialEndpoint, tokenResp.accessToken, offeredCredentials.map { + CredentialRequest.forOfferedCredential(it, generateDidProof(holderDid, credentialOffer.credentialIssuer, nonce, client)) + }) + } + } + + protected open fun executeBatchCredentialRequest(batchEndpoint: String, accessToken: String, credentialRequests: List): List { + val req = BatchCredentialRequest(credentialRequests) + val httpResp = httpPostObject(Url(batchEndpoint), req.toJSON(), Headers.build { set(HttpHeaders.Authorization, "Bearer $accessToken") }) + if(!httpResp.status.isSuccess() || httpResp.body == null) throw BatchCredentialError(req, CredentialErrorCode.server_error, "Batch credential endpoint returned error status ${httpResp.status}, or body is empty") + return BatchCredentialResponse.fromJSONString(httpResp.body).credentialResponses ?: listOf() + } + + protected open fun executeCredentialRequest(credentialEndpoint: String, accessToken: String, credentialRequest: CredentialRequest): CredentialResponse { + val httpResp = httpPostObject(Url(credentialEndpoint), credentialRequest.toJSON(), Headers.build { set(HttpHeaders.Authorization, "Bearer $accessToken") }) + if(!httpResp.status.isSuccess() || httpResp.body == null) throw CredentialError(credentialRequest, CredentialErrorCode.server_error, "Credential error returned error status ${httpResp.status}, or body is empty") + return CredentialResponse.fromJSONString(httpResp.body) + } + } diff --git a/src/commonMain/kotlin/id/walt/oid4vc/responses/CredentialResponse.kt b/src/commonMain/kotlin/id/walt/oid4vc/responses/CredentialResponse.kt index 1f3b4c5..58387d9 100644 --- a/src/commonMain/kotlin/id/walt/oid4vc/responses/CredentialResponse.kt +++ b/src/commonMain/kotlin/id/walt/oid4vc/responses/CredentialResponse.kt @@ -79,7 +79,8 @@ enum class CredentialErrorCode { insufficient_scope, unsupported_credential_type, unsupported_credential_format, - invalid_or_missing_proof + invalid_or_missing_proof, + server_error } object CredentialResponseListSerializer : KSerializer> { diff --git a/src/commonMain/kotlin/id/walt/oid4vc/responses/TokenResponse.kt b/src/commonMain/kotlin/id/walt/oid4vc/responses/TokenResponse.kt index 8ccff87..c08840d 100644 --- a/src/commonMain/kotlin/id/walt/oid4vc/responses/TokenResponse.kt +++ b/src/commonMain/kotlin/id/walt/oid4vc/responses/TokenResponse.kt @@ -126,5 +126,6 @@ enum class TokenErrorCode { invalid_grant, unauthorized_client, unsupported_grant_type, - invalid_scope + invalid_scope, + server_error } diff --git a/src/jvmTest/kotlin/id/walt/oid4vc/EBSITestWallet.kt b/src/jvmTest/kotlin/id/walt/oid4vc/EBSITestWallet.kt index 551d758..5b1e755 100644 --- a/src/jvmTest/kotlin/id/walt/oid4vc/EBSITestWallet.kt +++ b/src/jvmTest/kotlin/id/walt/oid4vc/EBSITestWallet.kt @@ -11,6 +11,7 @@ import id.walt.oid4vc.data.dif.PresentationSubmission import id.walt.oid4vc.data.dif.VCFormat import id.walt.oid4vc.errors.PresentationError import id.walt.oid4vc.interfaces.PresentationResult +import id.walt.oid4vc.interfaces.SimpleHttpResponse import id.walt.oid4vc.providers.CredentialWalletConfig import id.walt.oid4vc.providers.OpenIDCredentialWallet import id.walt.oid4vc.providers.SIOPSession @@ -27,7 +28,11 @@ 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.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.* @@ -41,6 +46,7 @@ class EBSITestWallet(config: CredentialWalletConfig): OpenIDCredentialWallet SimpleHttpResponse(httpResponse.status, httpResponse.headers, httpResponse.bodyAsText()) } } + } + + override fun httpPostObject(url: Url, jsonObject: JsonObject, headers: Headers?): SimpleHttpResponse { + return runBlocking { ktorClient.post(url) { + headers { + headers?.let { appendAll(it) } + } + contentType(ContentType.Application.Json) + setBody(jsonObject) + }.let { httpResponse -> SimpleHttpResponse(httpResponse.status, httpResponse.headers, httpResponse.bodyAsText()) } } + } + + override fun httpSubmitForm(url: Url, formParameters: Parameters, headers: Headers?): SimpleHttpResponse { + return runBlocking { ktorClient.submitForm { + url(url) + headers { + headers?.let { appendAll(it) } + } + parameters { + appendAll(formParameters) + } + }.let { httpResponse -> 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() } 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 cd081a8..359de22 100644 --- a/src/jvmTest/kotlin/id/walt/oid4vc/EBSI_Conformance_Test.kt +++ b/src/jvmTest/kotlin/id/walt/oid4vc/EBSI_Conformance_Test.kt @@ -2,7 +2,10 @@ 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.oid4vc.providers.CredentialWalletConfig +import id.walt.oid4vc.providers.OpenIDClientConfig +import id.walt.oid4vc.requests.AuthorizationRequest import id.walt.oid4vc.requests.CredentialOfferRequest import id.walt.servicematrix.ServiceMatrix import io.kotest.common.runBlocking @@ -34,29 +37,16 @@ class EBSI_Conformance_Test: AnnotationSpec() { @BeforeAll fun init() { ServiceMatrix("service-matrix.properties") - credentialWallet = EBSITestWallet(id.walt.oid4vc.providers.CredentialWalletConfig()) + credentialWallet = EBSITestWallet(CredentialWalletConfig("https://blank/")) } @Test fun testReceiveCredential() { val initCredentialOfferUrl = "https://api-conformance.ebsi.eu/conformance/v3/issuer-mock/initiate-credential-offer?credential_type=CTWalletCrossInTime&client_id=did:key:zmYg9bgKmRiCqTTd9MA1ufVE9tfzUptwQp4GMRxptXquJWw4Uj5bVzbAR3ScDrvTYPMZzRCCyZUidTqbgTvbDjZDEzf3XwwVPothBG3iX7xxc9r1A&credential_offer_endpoint=openid-credential-offer://" - val inTimeCredentialOfferRequest = runBlocking { ktorClient.get(Url(initCredentialOfferUrl)).bodyAsText() } - - val credentialOffer = credentialWallet.getCredentialOffer(CredentialOfferRequest.fromHttpQueryString(Url(inTimeCredentialOfferRequest).encodedQuery)) - credentialOffer.credentialIssuer shouldBe "https://api-conformance.ebsi.eu/conformance/v3/issuer-mock" - - val state = credentialOffer.grants[GrantType.authorization_code.value]?.issuerState - println("// get issuer metadata") - val providerMetadataUri = - credentialWallet.getCIProviderMetadataUrl(credentialOffer.credentialIssuer) - val providerMetadata = credentialWallet.resolveJSON(providerMetadataUri)?.let { OpenIDProviderMetadata.fromJSON(it) } - providerMetadata shouldNotBe null - println("providerMetadata: $providerMetadata") - println("// resolve offered credentials") - val offeredCredentials = credentialOffer.resolveOfferedCredentials(providerMetadata!!) - println("offeredCredentials: $offeredCredentials") - offeredCredentials.size shouldNotBe 0 - + val inTimeCredentialOfferRequestUri = runBlocking { ktorClient.get(Url(initCredentialOfferUrl)).bodyAsText() } + val credentialOfferRequest = CredentialOfferRequest.fromHttpQueryString(Url(inTimeCredentialOfferRequestUri).encodedQuery) + val credentialResponses = credentialWallet.executeFullAuthIssuance(credentialOfferRequest, credentialWallet.TEST_DID, OpenIDClientConfig(credentialWallet.TEST_DID, null, credentialWallet.config.redirectUri)) + credentialResponses.size shouldNotBe 0 } } \ No newline at end of file diff --git a/src/jvmTest/kotlin/id/walt/oid4vc/TestCredentialWallet.kt b/src/jvmTest/kotlin/id/walt/oid4vc/TestCredentialWallet.kt index b4d9c89..836082f 100644 --- a/src/jvmTest/kotlin/id/walt/oid4vc/TestCredentialWallet.kt +++ b/src/jvmTest/kotlin/id/walt/oid4vc/TestCredentialWallet.kt @@ -14,6 +14,7 @@ import id.walt.oid4vc.data.dif.VCFormat import id.walt.oid4vc.errors.AuthorizationError import id.walt.oid4vc.errors.PresentationError import id.walt.oid4vc.interfaces.PresentationResult +import id.walt.oid4vc.interfaces.SimpleHttpResponse import id.walt.oid4vc.providers.OpenIDCredentialWallet import id.walt.oid4vc.providers.CredentialWalletConfig import id.walt.oid4vc.providers.SIOPSession @@ -31,6 +32,7 @@ import io.ktor.client.call.* import io.ktor.client.engine.java.* import io.ktor.client.request.* import io.ktor.client.request.forms.* +import io.ktor.client.statement.* import io.ktor.http.* import io.ktor.serialization.kotlinx.json.* import io.ktor.server.application.* @@ -69,6 +71,36 @@ class TestCredentialWallet( override fun verifyTokenSignature(target: TokenTarget, token: String) = JwtService.getService().verify(token).verified + override fun httpGet(url: Url, headers: Headers?): SimpleHttpResponse { + return runBlocking { ktorClient.get(url) { + headers { + headers?.let { appendAll(it) } + } + }.let { httpResponse -> SimpleHttpResponse(httpResponse.status, httpResponse.headers, httpResponse.bodyAsText()) } } + } + + override fun httpPostObject(url: Url, jsonObject: JsonObject, headers: Headers?): SimpleHttpResponse { + return runBlocking { ktorClient.post(url) { + headers { + headers?.let { appendAll(it) } + } + contentType(ContentType.Application.Json) + setBody(jsonObject) + }.let { httpResponse -> SimpleHttpResponse(httpResponse.status, httpResponse.headers, httpResponse.bodyAsText()) } } + } + + override fun httpSubmitForm(url: Url, formParameters: Parameters, headers: Headers?): SimpleHttpResponse { + return runBlocking { ktorClient.submitForm { + url(url) + headers { + headers?.let { appendAll(it) } + } + parameters { + appendAll(formParameters) + } + }.let { httpResponse -> SimpleHttpResponse(httpResponse.status, httpResponse.headers, httpResponse.bodyAsText()) } } + } + override fun generatePresentationForVPToken(session: SIOPSession, tokenRequest: TokenRequest): PresentationResult { // find credential(s) matching the presentation definition // for this test wallet implementation, present all credentials in the wallet @@ -170,10 +202,6 @@ class TestCredentialWallet( return (didObj.authentication ?: didObj.assertionMethod ?: didObj.verificationMethod)?.firstOrNull()?.id ?: did } - override fun resolveJSON(url: String): JsonObject? { - return runBlocking { ktorClient.get(url).body() } - } - override fun isPresentationDefinitionSupported(presentationDefinition: PresentationDefinition): Boolean { return true } From d36d6b14620baa15556d357809af174ef6ec1a71 Mon Sep 17 00:00:00 2001 From: Severin Stampler Date: Tue, 10 Oct 2023 16:21:47 +0200 Subject: [PATCH 4/6] feat: adaptations to make ebsi issuance conformance test pass --- build.gradle.kts | 4 +- .../walt/oid4vc/data/AuthorizationDetails.kt | 5 +- .../walt/oid4vc/errors/AuthorizationError.kt | 3 +- .../oid4vc/providers/OpenIDClientConfig.kt | 3 +- .../providers/OpenIDCredentialWallet.kt | 47 +++++++++++++++++-- .../requests/AuthorizationJSONRequest.kt | 30 ++++++++++++ .../oid4vc/requests/AuthorizationRequest.kt | 36 ++++++++++---- .../id/walt/oid4vc/requests/TokenRequest.kt | 5 +- .../kotlin/id/walt/oid4vc/util/UUID.kt | 3 +- .../kotlin/id/walt/oid4vc/util/UUID.js.kt | 4 ++ .../kotlin/id/walt/oid4vc/util/UUID.jvm.kt | 4 ++ .../kotlin/id/walt/oid4vc/EBSITestWallet.kt | 21 +++++---- .../kotlin/id/walt/oid4vc/util/UUID.native.kt | 4 ++ 13 files changed, 140 insertions(+), 29 deletions(-) create mode 100644 src/commonMain/kotlin/id/walt/oid4vc/requests/AuthorizationJSONRequest.kt diff --git a/build.gradle.kts b/build.gradle.kts index e3912be..f3f04d2 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -74,7 +74,7 @@ kotlin { implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3") implementation("io.ktor:ktor-http:$ktor_version") implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.4.1") - implementation("id.walt:waltid-sd-jwt:1.2309211114.0") + implementation("id.walt:waltid-sd-jwt:1.2310101347.0") } } val commonTest by getting { @@ -98,7 +98,7 @@ kotlin { implementation("io.kotest:kotest-assertions-json:5.7.2") implementation("id.walt.servicematrix:WaltID-ServiceMatrix:1.1.3") - implementation("id.walt:waltid-ssikit:1.JWTPRESENT") + implementation("id.walt:waltid-ssikit:1.JWTTYP") implementation("id.walt:core-crypto:1.0.2-SNAPSHOT") implementation("io.ktor:ktor-server-core-jvm:$ktor_version") diff --git a/src/commonMain/kotlin/id/walt/oid4vc/data/AuthorizationDetails.kt b/src/commonMain/kotlin/id/walt/oid4vc/data/AuthorizationDetails.kt index 62c1cf1..4161a04 100644 --- a/src/commonMain/kotlin/id/walt/oid4vc/data/AuthorizationDetails.kt +++ b/src/commonMain/kotlin/id/walt/oid4vc/data/AuthorizationDetails.kt @@ -34,6 +34,7 @@ data class AuthorizationDetails( @SerialName("doctype") val docType: String? = null, @Serializable(ClaimDescriptorNamespacedMapSerializer::class) val claims: Map>? = null, @SerialName("credential_definition") val credentialDefinition: JsonLDCredentialDefinition? = null, + val locations: List? = null, override val customParameters: Map = mapOf() ) : JsonDataObject() { override fun toJSON() = Json.encodeToJsonElement(AuthorizationDetailsSerializer, this).jsonObject @@ -42,11 +43,11 @@ data class AuthorizationDetails( override fun fromJSON(jsonObject: JsonObject): AuthorizationDetails = Json.decodeFromJsonElement(AuthorizationDetailsSerializer, jsonObject) - fun fromOfferedCredential(offeredCredential: OfferedCredential) = AuthorizationDetails( + fun fromOfferedCredential(offeredCredential: OfferedCredential, issuerLocation: String? = null) = AuthorizationDetails( OPENID_CREDENTIAL_AUTHORIZATION_TYPE, offeredCredential.format, offeredCredential.types, null, offeredCredential.docType, null, - offeredCredential.credentialDefinition, offeredCredential.customParameters + offeredCredential.credentialDefinition, issuerLocation?.let { listOf(it) }, offeredCredential.customParameters ) } } diff --git a/src/commonMain/kotlin/id/walt/oid4vc/errors/AuthorizationError.kt b/src/commonMain/kotlin/id/walt/oid4vc/errors/AuthorizationError.kt index fa074d3..4c4a4d3 100644 --- a/src/commonMain/kotlin/id/walt/oid4vc/errors/AuthorizationError.kt +++ b/src/commonMain/kotlin/id/walt/oid4vc/errors/AuthorizationError.kt @@ -1,12 +1,13 @@ package id.walt.oid4vc.errors import id.walt.oid4vc.requests.AuthorizationRequest +import id.walt.oid4vc.requests.IAuthorizationRequest import id.walt.oid4vc.responses.AuthorizationCodeResponse import id.walt.oid4vc.responses.AuthorizationErrorCode import id.walt.oid4vc.responses.PushedAuthorizationResponse class AuthorizationError( - val authorizationRequest: AuthorizationRequest, + val authorizationRequest: IAuthorizationRequest, val errorCode: AuthorizationErrorCode, override val message: String? = null ) : Exception() { diff --git a/src/commonMain/kotlin/id/walt/oid4vc/providers/OpenIDClientConfig.kt b/src/commonMain/kotlin/id/walt/oid4vc/providers/OpenIDClientConfig.kt index 385e81f..4fa4a53 100644 --- a/src/commonMain/kotlin/id/walt/oid4vc/providers/OpenIDClientConfig.kt +++ b/src/commonMain/kotlin/id/walt/oid4vc/providers/OpenIDClientConfig.kt @@ -3,5 +3,6 @@ package id.walt.oid4vc.providers data class OpenIDClientConfig( val clientID: String, val clientSecret: String?, - val redirectUri: String? + val redirectUri: String?, + val useCodeChallenge: Boolean = false ) diff --git a/src/commonMain/kotlin/id/walt/oid4vc/providers/OpenIDCredentialWallet.kt b/src/commonMain/kotlin/id/walt/oid4vc/providers/OpenIDCredentialWallet.kt index 3e85539..f25c767 100644 --- a/src/commonMain/kotlin/id/walt/oid4vc/providers/OpenIDCredentialWallet.kt +++ b/src/commonMain/kotlin/id/walt/oid4vc/providers/OpenIDCredentialWallet.kt @@ -11,12 +11,19 @@ import id.walt.oid4vc.interfaces.IVPTokenProvider import id.walt.oid4vc.requests.* import id.walt.oid4vc.responses.* import id.walt.oid4vc.util.randomUUID +import id.walt.oid4vc.util.sha256 +import id.walt.sdjwt.SDJwt import io.ktor.http.* +import io.ktor.utils.io.charsets.* +import io.ktor.utils.io.core.* import kotlinx.datetime.Clock import kotlinx.datetime.Instant import kotlinx.serialization.SerializationException import kotlinx.serialization.json.* +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.time.Duration +import kotlin.time.Duration.Companion.minutes /** * Base object for a self-issued OpenID provider, providing identity information by presenting verifiable credentials, @@ -51,6 +58,7 @@ abstract class OpenIDCredentialWallet( nonce?.let { put(JWTClaims.Payload.nonce, it) } }, header = buildJsonObject { put(JWTClaims.Header.keyID, keyId) + put(JWTClaims.Header.type, "openid4vci-proof+jwt") }, keyId = keyId) ) } @@ -158,6 +166,7 @@ abstract class OpenIDCredentialWallet( } ?: 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") } + @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") @@ -167,14 +176,18 @@ abstract class OpenIDCredentialWallet( httpGetAsJson(Url(getCommonProviderMetadataUrl(authServer)))?.jsonObject?.let { OpenIDProviderMetadata.fromJSON(it) } } ?: issuerMetadata val offeredCredentials = credentialOffer.resolveOfferedCredentials(issuerMetadata) + val codeVerifier = if(client.useCodeChallenge) randomUUID() else null + val codeChallenge = codeVerifier?.let { Base64.UrlSafe.encode(sha256(it.toByteArray(Charsets.UTF_8))).trimEnd('=') } val authReq = AuthorizationRequest( responseType = ResponseType.getResponseTypeString(ResponseType.code), clientId = client.clientID, redirectUri = config.redirectUri, scope = setOf("openid"), - issuerState = credentialOffer.grants[GrantType.authorization_code.value]!!.issuerState, - authorizationDetails = offeredCredentials.map { AuthorizationDetails.fromOfferedCredential(it) } + issuerState = credentialOffer.grants[GrantType.authorization_code.value]?.issuerState, + authorizationDetails = offeredCredentials.map { AuthorizationDetails.fromOfferedCredential(it, issuerMetadata.credentialIssuer) }, + codeChallenge = codeChallenge, + codeChallengeMethod = codeChallenge?.let { "S256" } ).let { authReq -> if (authorizationServerMetadata.pushedAuthorizationRequestEndpoint != null) { // execute pushed authorization request @@ -206,12 +219,15 @@ abstract class OpenIDCredentialWallet( }.build()) println("authResp: $authResp") if(authResp.status != HttpStatusCode.Found) throw AuthorizationError(authReq, AuthorizationErrorCode.server_error, "Got unexpected status code ${authResp.status.value} from issuer") - val location = Url(authResp.headers[HttpHeaders.Location]!!) + var location = Url(authResp.headers[HttpHeaders.Location]!!) println("location: $location") + location = if(location.parameters.contains("response_type") && location.parameters["response_type"] == ResponseType.id_token.name) { + executeIdTokenAuthorization(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) + val tokenReq = TokenRequest(GrantType.authorization_code, client.clientID, config.redirectUri, code, codeVerifier = 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) @@ -249,4 +265,27 @@ abstract class OpenIDCredentialWallet( return CredentialResponse.fromJSONString(httpResp.body) } + protected open fun executeIdTokenAuthorization(idTokenRequestUri: Url, holderDid: String, client: OpenIDClientConfig): Url { + var authReq = AuthorizationRequest.fromHttpQueryString(idTokenRequestUri.encodedQuery).let { authorizationRequest -> + authorizationRequest.customParameters["request"]?.let { AuthorizationJSONRequest.fromJSON(SDJwt.parse(it.first()).fullPayload) } ?: authorizationRequest + } + if(authReq.responseMode != ResponseMode.direct_post || authReq.responseType != ResponseType.id_token.name || authReq.redirectUri.isNullOrEmpty()) + throw AuthorizationError(authReq, AuthorizationErrorCode.server_error, "Unexpected response_mode ${authReq.responseMode}, or response_type ${authReq.responseType} returned from server, or redirect_uri is missing") + + val keyId = resolveDID(holderDid) + val idToken = signToken(TokenTarget.TOKEN, buildJsonObject { + put("iss", holderDid) + put("sub", holderDid) + put("aud", authReq.clientId) + put("exp", Clock.System.now().plus(5.minutes).epochSeconds) + put("iat", Clock.System.now().epochSeconds) + put("state", authReq.state) + put("nonce", authReq.nonce) + }, keyId = keyId) + val httpResp = httpSubmitForm(Url(authReq.redirectUri!!), parametersOf(Pair("id_token", listOf(idToken)), Pair("state", listOf(authReq.state!!)))) + if(httpResp.status != HttpStatusCode.Found) throw AuthorizationError(authReq, AuthorizationErrorCode.server_error, "Unexpected status code ${httpResp.status} returned from server for id_token response") + return httpResp.headers[HttpHeaders.Location]?.let { Url(it) } + ?: throw AuthorizationError(authReq, AuthorizationErrorCode.server_error, "Location parameter missing on http response for id_token response") + } + } diff --git a/src/commonMain/kotlin/id/walt/oid4vc/requests/AuthorizationJSONRequest.kt b/src/commonMain/kotlin/id/walt/oid4vc/requests/AuthorizationJSONRequest.kt new file mode 100644 index 0000000..4c1e099 --- /dev/null +++ b/src/commonMain/kotlin/id/walt/oid4vc/requests/AuthorizationJSONRequest.kt @@ -0,0 +1,30 @@ +package id.walt.oid4vc.requests + +import id.walt.oid4vc.data.* +import id.walt.oid4vc.data.dif.PresentationDefinition +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject + +@Serializable +data class AuthorizationJSONRequest( + @SerialName("response_type") override val responseType: String = ResponseType.getResponseTypeString(ResponseType.code), + @SerialName("client_id") override val clientId: String, + @SerialName("response_mode") override val responseMode: ResponseMode? = null, + @SerialName("redirect_uri") override val redirectUri: String? = null, + override val scope: Set = setOf(), + override val state: String? = null, + override val nonce: String? = null, + override val customParameters: Map = mapOf() +): JsonDataObject(), IAuthorizationRequest { + override fun toJSON() = Json.encodeToJsonElement(AuthorizationJSONRequestSerializer, this).jsonObject + + companion object: JsonDataObjectFactory() { + override fun fromJSON(jsonObject: JsonObject) = Json.decodeFromJsonElement(AuthorizationJSONRequestSerializer, jsonObject) + } +} + +object AuthorizationJSONRequestSerializer: JsonDataObjectSerializer(AuthorizationJSONRequest.serializer()) \ No newline at end of file diff --git a/src/commonMain/kotlin/id/walt/oid4vc/requests/AuthorizationRequest.kt b/src/commonMain/kotlin/id/walt/oid4vc/requests/AuthorizationRequest.kt index 23f3643..fb71ed8 100644 --- a/src/commonMain/kotlin/id/walt/oid4vc/requests/AuthorizationRequest.kt +++ b/src/commonMain/kotlin/id/walt/oid4vc/requests/AuthorizationRequest.kt @@ -4,13 +4,23 @@ import id.walt.oid4vc.data.* import id.walt.oid4vc.data.dif.PresentationDefinition import kotlinx.serialization.json.Json +interface IAuthorizationRequest { + val responseType: String + val clientId: String + val responseMode: ResponseMode? + val redirectUri: String? + val scope: Set + val state: String? + val nonce: String? +} + data class AuthorizationRequest( - val responseType: String = ResponseType.getResponseTypeString(ResponseType.code), - val clientId: String, - val responseMode: ResponseMode? = null, - val redirectUri: String? = null, - val scope: Set = setOf(), - val state: String? = null, + override val responseType: String = ResponseType.getResponseTypeString(ResponseType.code), + override val clientId: String, + override val responseMode: ResponseMode? = null, + override val redirectUri: String? = null, + override val scope: Set = setOf(), + override val state: String? = null, val authorizationDetails: List? = null, val walletIssuer: String? = null, val userHint: String? = null, @@ -21,10 +31,12 @@ data class AuthorizationRequest( val clientIdScheme: ClientIdScheme? = null, val clientMetadata: OpenIDClientMetadata? = null, val clientMetadataUri: String? = null, - val nonce: String? = null, + override val nonce: String? = null, val responseUri: String? = null, + val codeChallenge: String? = null, + val codeChallengeMethod: String? = null, override val customParameters: Map> = mapOf() -) : HTTPDataObject() { +) : HTTPDataObject(), IAuthorizationRequest { val isReferenceToPAR get() = requestUri != null override fun toHttpParameters(): Map> { return buildMap { @@ -52,6 +64,8 @@ data class AuthorizationRequest( clientMetadataUri?.let { put("client_metadata_uri", listOf(it)) } nonce?.let { put("nonce", listOf(it)) } responseUri?.let { put("response_uri", listOf(it)) } + codeChallenge?.let { put("code_challenge", listOf(it)) } + codeChallengeMethod?.let { put("code_challenge_method", listOf(it)) } putAll(customParameters) } } @@ -74,7 +88,9 @@ data class AuthorizationRequest( "client_metadata_uri", "nonce", "response_mode", - "response_uri" + "response_uri", + "code_challenge", + "code_challenge_method" ) override fun fromHttpParameters(parameters: Map>): AuthorizationRequest { @@ -102,6 +118,8 @@ data class AuthorizationRequest( parameters["client_metadata_uri"]?.firstOrNull(), parameters["nonce"]?.firstOrNull(), parameters["response_uri"]?.firstOrNull(), + parameters["code_challenge"]?.firstOrNull(), + parameters["code_challenge_method"]?.firstOrNull(), parameters.filterKeys { !knownKeys.contains(it) } ) } diff --git a/src/commonMain/kotlin/id/walt/oid4vc/requests/TokenRequest.kt b/src/commonMain/kotlin/id/walt/oid4vc/requests/TokenRequest.kt index 651ed71..5065bcf 100644 --- a/src/commonMain/kotlin/id/walt/oid4vc/requests/TokenRequest.kt +++ b/src/commonMain/kotlin/id/walt/oid4vc/requests/TokenRequest.kt @@ -11,6 +11,7 @@ data class TokenRequest( val code: String? = null, val preAuthorizedCode: String? = null, val userPin: String? = null, + val codeVerifier: String? = null, override val customParameters: Map> = mapOf() ) : HTTPDataObject() { override fun toHttpParameters(): Map> { @@ -21,13 +22,14 @@ data class TokenRequest( code?.let { put("code", listOf(it)) } preAuthorizedCode?.let { put("pre-authorized_code", listOf(it)) } userPin?.let { put("user_pin", listOf(it)) } + codeVerifier?.let { put("code_verifier", listOf(it)) } putAll(customParameters) } } companion object : HTTPDataObjectFactory() { private val knownKeys = - setOf("grant_type", "client_id", "redirect_uri", "code", "pre-authorized_code", "user_pin") + setOf("grant_type", "client_id", "redirect_uri", "code", "pre-authorized_code", "user_pin", "code_verifier") override fun fromHttpParameters(parameters: Map>): TokenRequest { return TokenRequest( @@ -37,6 +39,7 @@ data class TokenRequest( parameters["code"]?.firstOrNull(), parameters["pre-authorized_code"]?.firstOrNull(), parameters["user_pin"]?.firstOrNull(), + parameters["code_verifier"]?.firstOrNull(), parameters.filterKeys { !knownKeys.contains(it) } ) } diff --git a/src/commonMain/kotlin/id/walt/oid4vc/util/UUID.kt b/src/commonMain/kotlin/id/walt/oid4vc/util/UUID.kt index 7372728..417f379 100644 --- a/src/commonMain/kotlin/id/walt/oid4vc/util/UUID.kt +++ b/src/commonMain/kotlin/id/walt/oid4vc/util/UUID.kt @@ -1,3 +1,4 @@ package id.walt.oid4vc.util -expect fun randomUUID(): String \ No newline at end of file +expect fun randomUUID(): String +expect fun sha256(data: ByteArray): ByteArray \ No newline at end of file diff --git a/src/jsMain/kotlin/id/walt/oid4vc/util/UUID.js.kt b/src/jsMain/kotlin/id/walt/oid4vc/util/UUID.js.kt index 3f0d51a..58aa9df 100644 --- a/src/jsMain/kotlin/id/walt/oid4vc/util/UUID.js.kt +++ b/src/jsMain/kotlin/id/walt/oid4vc/util/UUID.js.kt @@ -3,3 +3,7 @@ package id.walt.oid4vc.util actual fun randomUUID(): String { TODO("Not yet implemented") } + +actual fun sha256(data: ByteArray): ByteArray { + TODO("Not yet implemented") +} \ No newline at end of file diff --git a/src/jvmMain/kotlin/id/walt/oid4vc/util/UUID.jvm.kt b/src/jvmMain/kotlin/id/walt/oid4vc/util/UUID.jvm.kt index f6a6dec..e34a8ba 100644 --- a/src/jvmMain/kotlin/id/walt/oid4vc/util/UUID.jvm.kt +++ b/src/jvmMain/kotlin/id/walt/oid4vc/util/UUID.jvm.kt @@ -1,7 +1,11 @@ package id.walt.oid4vc.util +import java.security.MessageDigest import java.util.* actual fun randomUUID(): String { return UUID.randomUUID().toString() } + +actual fun sha256(data: ByteArray): ByteArray + = MessageDigest.getInstance("SHA-256").digest(data) \ No newline at end of file diff --git a/src/jvmTest/kotlin/id/walt/oid4vc/EBSITestWallet.kt b/src/jvmTest/kotlin/id/walt/oid4vc/EBSITestWallet.kt index 5b1e755..ad5f827 100644 --- a/src/jvmTest/kotlin/id/walt/oid4vc/EBSITestWallet.kt +++ b/src/jvmTest/kotlin/id/walt/oid4vc/EBSITestWallet.kt @@ -27,6 +27,7 @@ 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.* import io.ktor.client.request.* import io.ktor.client.request.forms.* import io.ktor.client.statement.* @@ -38,14 +39,18 @@ import kotlinx.serialization.json.* const val EBSI_WALLET_PORT = 8011 const val EBSI_WALLET_BASE_URL = "http://localhost:${EBSI_WALLET_PORT}" -const val EBSI_WALLET_TEST_KEY = "{\"kty\":\"OKP\",\"d\":\"2jDhK3fFep3oaqcVr8548CLJ3_8bgxG9z8DBL6PIrGI\",\"use\":\"sig\",\"crv\":\"Ed25519\",\"kid\":\"ed757b38cbf34afbb1f157c5e2bf08f8\",\"x\":\"1nve9sZ_SmDpuo3A5x4ccjKan5Up2qvMg7qsXOtXVkU\",\"alg\":\"EdDSA\"}" -const val EBSI_WALLET_TEST_DID = "did:key:zmYg9bgKmRiCqTTd9MA1ufVE9tfzUptwQp4GMRxptXquJWw4Uj5bVzbAR3ScDrvTYPMZzRCCyZUidTqbgTvbDjZDEzf3XwwVPothBG3iX7xxc9r1A" +const val EBSI_WALLET_TEST_KEY = "{\"kty\":\"EC\",\"d\":\"AENUGJiPF4zRlF1uXV1NTWE5zcQPz-8Ie8SGLdQugec\",\"use\":\"sig\",\"crv\":\"P-256\",\"kid\":\"de8aca52c110485a87fa6fda8d1f2f4e\",\"x\":\"hJ0hFBtp72j1V2xugQI51ernWY_vPXzXjnEg7A709Fc\",\"y\":\"-Mm1j5Zz1mWJU7Nqylk0_6qKjZ5fn6ddzziEFscQPhQ\",\"alg\":\"ES256\"}" +const val EBSI_WALLET_TEST_DID = "did:key:z2dmzD81cgPx8Vki7JbuuMmFYrWPgYoytykUZ3eyqht1j9KbrksdXfcbvmhgF2h7YfpxWuywkXxDZ7ohTPNPTQpD39Rm9WiBWuEpvvgtfuPHtHi2wTEkZ95KC2ijUMUowyKMueaMhtA5bLYkt9k8Y8Gq4sm6PyTCHTxuyedMMrBKdRXNZS" class EBSITestWallet(config: CredentialWalletConfig): OpenIDCredentialWallet(EBSI_WALLET_BASE_URL, config) { private val sessionCache = mutableMapOf() private val ktorClient = HttpClient(Java) { install(ContentNegotiation) { json() } + install(Logging) { + logger = Logger.SIMPLE + level = LogLevel.ALL + } followRedirects = false } @@ -82,7 +87,7 @@ class EBSITestWallet(config: CredentialWalletConfig): OpenIDCredentialWallet SimpleHttpResponse(httpResponse.status, httpResponse.headers, httpResponse.bodyAsText()) } } } diff --git a/src/nativeMain/kotlin/id/walt/oid4vc/util/UUID.native.kt b/src/nativeMain/kotlin/id/walt/oid4vc/util/UUID.native.kt index 3f0d51a..4cab81e 100644 --- a/src/nativeMain/kotlin/id/walt/oid4vc/util/UUID.native.kt +++ b/src/nativeMain/kotlin/id/walt/oid4vc/util/UUID.native.kt @@ -3,3 +3,7 @@ package id.walt.oid4vc.util actual fun randomUUID(): String { TODO("Not yet implemented") } + +actual fun sha256(data: ByteArray): ByteArray { + TODO("Not yet implemented") +} \ No newline at end of file From 4800ead3fc85aaf2078a03459335c78ab19fec49 Mon Sep 17 00:00:00 2001 From: Severin Stampler Date: Tue, 10 Oct 2023 16:47:00 +0200 Subject: [PATCH 5/6] feat: adaptations to make ebsi issuance conformance test pass, ebsi issuance sequence diagram --- README.md | 32 +++++++++++++++++++ .../id/walt/oid4vc/EBSI_Conformance_Test.kt | 4 +-- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index baae8d7..40b5b8d 100644 --- a/README.md +++ b/README.md @@ -141,3 +141,35 @@ For the full demo verifier implementation, refer to `/src/jvmTest/kotlin/id/walt ## License Licensed under the [Apache License, Version 2.0](https://github.com/walt-id/waltid-xyzkit/blob/master/LICENSE) + + +# Example flows: + +## EBSI conformance test: Credential issuance: + +```mermaid +sequenceDiagram +Issuer -->> Wallet: Issuance request (QR, or by request) +Wallet ->> Issuer: Resolve credential offer +Issuer -->> Wallet: Credential offer +Wallet ->> Issuer: fetch OpenID Credential Issuer metadata +Issuer -->> Wallet: Credential issuer metadata +Wallet ->> Wallet: Check if external authorization service (AS) +Wallet ->> AS: fetch OpenID provider metadata +AS -->> Wallet: OpenID provider metadata +Wallet ->> Wallet: resolve offered credential metainfo +Wallet ->> Wallet: Generate code verifier and code challenge +Wallet ->> AS: Authorization request, auth details and code challenge +AS -->> Wallet: Redirect to wallet with id_token request +Wallet ->> Wallet: Generate id_token +Wallet ->> AS: POST id_token response to redirect_uri +AS -->> Wallet: Redirect to wallet with authorzation code +Wallet ->> AS: POST token request with code and code verifier +AS -->> Wallet: Respond with access_token and c_nonce +loop Fetch offered credentials +Wallet ->> Wallet: generate DID proof +Wallet ->> Issuer: Fetch credentials from credential endpoint or batch-credential endpoint, with DID proof +Issuer -->> Wallet: Credential (and updated c_nonce) +end + +``` \ 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 359de22..372aa89 100644 --- a/src/jvmTest/kotlin/id/walt/oid4vc/EBSI_Conformance_Test.kt +++ b/src/jvmTest/kotlin/id/walt/oid4vc/EBSI_Conformance_Test.kt @@ -42,11 +42,11 @@ class EBSI_Conformance_Test: AnnotationSpec() { @Test fun testReceiveCredential() { - val initCredentialOfferUrl = "https://api-conformance.ebsi.eu/conformance/v3/issuer-mock/initiate-credential-offer?credential_type=CTWalletCrossInTime&client_id=did:key:zmYg9bgKmRiCqTTd9MA1ufVE9tfzUptwQp4GMRxptXquJWw4Uj5bVzbAR3ScDrvTYPMZzRCCyZUidTqbgTvbDjZDEzf3XwwVPothBG3iX7xxc9r1A&credential_offer_endpoint=openid-credential-offer://" + 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) - val credentialResponses = credentialWallet.executeFullAuthIssuance(credentialOfferRequest, credentialWallet.TEST_DID, OpenIDClientConfig(credentialWallet.TEST_DID, null, credentialWallet.config.redirectUri)) + val credentialResponses = credentialWallet.executeFullAuthIssuance(credentialOfferRequest, credentialWallet.TEST_DID, OpenIDClientConfig(credentialWallet.TEST_DID, null, credentialWallet.config.redirectUri, useCodeChallenge = true)) credentialResponses.size shouldNotBe 0 } } \ No newline at end of file From 8966076462975cc691f2908892be94cf9adf92f3 Mon Sep 17 00:00:00 2001 From: Severin Stampler Date: Tue, 10 Oct 2023 17:34:38 +0200 Subject: [PATCH 6/6] test: fix tests --- src/jvmTest/kotlin/id/walt/oid4vc/CI_JVM_Test.kt | 9 +++++++-- src/jvmTest/kotlin/id/walt/oid4vc/VP_JVM_Test.kt | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) 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 e1bca99..348db35 100644 --- a/src/jvmTest/kotlin/id/walt/oid4vc/CI_JVM_Test.kt +++ b/src/jvmTest/kotlin/id/walt/oid4vc/CI_JVM_Test.kt @@ -26,6 +26,7 @@ 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.* import io.ktor.client.request.* import io.ktor.client.request.forms.* import io.ktor.client.statement.* @@ -89,6 +90,10 @@ class CI_JVM_Test : AnnotationSpec() { install(ContentNegotiation) { json() } + install(Logging) { + logger = Logger.SIMPLE + level = LogLevel.ALL + } followRedirects = false } @@ -295,7 +300,7 @@ class CI_JVM_Test : AnnotationSpec() { } println("authResp: $authResp") authResp.status shouldBe HttpStatusCode.Found - authResp.headers.names() shouldContain HttpHeaders.Location + authResp.headers.names() shouldContain HttpHeaders.Location.lowercase() val location = Url(authResp.headers[HttpHeaders.Location]!!) println("location: $location") location.toString() shouldStartWith credentialWallet.config.redirectUri!! @@ -698,7 +703,7 @@ class CI_JVM_Test : AnnotationSpec() { println("authResp: $authResp") authResp.status shouldBe HttpStatusCode.Found - authResp.headers.names() shouldContain HttpHeaders.Location + authResp.headers.names() shouldContain HttpHeaders.Location.lowercase() val location = Url(authResp.headers[HttpHeaders.Location]!!) println("location: $location") diff --git a/src/jvmTest/kotlin/id/walt/oid4vc/VP_JVM_Test.kt b/src/jvmTest/kotlin/id/walt/oid4vc/VP_JVM_Test.kt index 0cb394a..35cc784 100644 --- a/src/jvmTest/kotlin/id/walt/oid4vc/VP_JVM_Test.kt +++ b/src/jvmTest/kotlin/id/walt/oid4vc/VP_JVM_Test.kt @@ -122,7 +122,7 @@ class VP_JVM_Test : AnnotationSpec() { } println("Auth resp: $authReq") authResp.status shouldBe HttpStatusCode.Found - authResp.headers.names() shouldContain HttpHeaders.Location + authResp.headers.names() shouldContain HttpHeaders.Location.lowercase() val redirectUrl = Url(authResp.headers[HttpHeaders.Location]!!) val tokenResponse = TokenResponse.fromHttpParameters(redirectUrl.parameters.toMap()) tokenResponse.vpToken shouldNotBe null