Skip to content
This repository has been archived by the owner on Nov 20, 2023. It is now read-only.

Commit

Permalink
feat: ebsi compliance - holder wallet (#3)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>
  • Loading branch information
mikeplotean and severinstampler authored Nov 6, 2023
1 parent f11785d commit fcbdd91
Show file tree
Hide file tree
Showing 7 changed files with 287 additions and 76 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
@@ -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() {
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -160,18 +159,31 @@ abstract class OpenIDCredentialWallet<S: SIOPSession>(

// ==========================================================
// =============== 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<CredentialResponse> {
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<CredentialResponse> {
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<CredentialResponse> {
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
Expand Down Expand Up @@ -223,11 +235,35 @@ abstract class OpenIDCredentialWallet<S: SIOPSession>(
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<OfferedCredential>, holderDid: String,
client: OpenIDClientConfig, authorizationCode: String? = null, codeVerifier: String? = null, userPIN: String? = null): List<CredentialResponse> {
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)
Expand All @@ -241,7 +277,7 @@ abstract class OpenIDCredentialWallet<S: SIOPSession>(
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 {
Expand Down Expand Up @@ -288,4 +324,17 @@ abstract class OpenIDCredentialWallet<S: SIOPSession>(
?: 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")
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ data class CredentialResponse private constructor(
@SerialName("error_uri") val errorUri: String? = null,
override val customParameters: Map<String, JsonElement> = 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<CredentialResponse>() {
Expand Down
2 changes: 1 addition & 1 deletion src/jvmTest/kotlin/id/walt/oid4vc/CI_JVM_Test.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
119 changes: 75 additions & 44 deletions src/jvmTest/kotlin/id/walt/oid4vc/EBSITestWallet.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.*
Expand All @@ -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}"
Expand All @@ -62,6 +61,7 @@ class EBSITestWallet(config: CredentialWalletConfig): OpenIDCredentialWallet<SIO
KeyService.getService().addAlias(keyId, EBSI_WALLET_TEST_DID)
val didDoc = DidService.resolve(EBSI_WALLET_TEST_DID)
KeyService.getService().addAlias(keyId, didDoc.verificationMethod!!.first().id)
DidService.importDid(EBSI_WALLET_TEST_DID)
}
}
override fun resolveDID(did: String): String {
Expand Down Expand Up @@ -116,64 +116,95 @@ class EBSITestWallet(config: CredentialWalletConfig): OpenIDCredentialWallet<SIO
headers {
headers?.let { appendAll(it) }
}
//parameters {
// appendAll(formParameters)
//}
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() }
.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<String>, 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<String>, credentialDescriptor: List<CredentialDescriptorMapping>
): List<DescriptorMapping> = 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,
)

}
Loading

0 comments on commit fcbdd91

Please sign in to comment.