Skip to content

Commit 6190b79

Browse files
Merge pull request #1740 from walt-id/wal-891
Wal 891
2 parents 0f73e42 + f54a5a3 commit 6190b79

16 files changed

Lines changed: 752 additions & 72 deletions

File tree

waltid-libraries/protocols/waltid-openid4vci/build.gradle.kts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,11 @@ kotlin {
1515

1616
// walt.id
1717
implementation(project(":waltid-libraries:crypto:waltid-crypto"))
18-
implementation(project(":waltid-libraries:protocols:waltid-openid4vc"))
18+
implementation(project(":waltid-libraries:crypto:waltid-x509"))
19+
implementation(project(":waltid-libraries:credentials:waltid-mdoc-credentials"))
20+
implementation(project(":waltid-libraries:credentials:waltid-mdoc-credentials2"))
21+
implementation(project(":waltid-libraries:waltid-did"))
22+
implementation(project(":waltid-libraries:credentials:waltid-w3c-credentials"))
1923
implementation(project(":waltid-libraries:sdjwt:waltid-sdjwt"))
2024

2125
// HTTP

waltid-libraries/protocols/waltid-openid4vci/src/commonMain/kotlin/id/walt/openid4vci/core/Builder.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package id.walt.openid4vci.core
22

33
import id.walt.openid4vci.handlers.credential.SdJwtVcCredentialHandler
4+
import id.walt.openid4vci.handlers.credential.MdocCredentialHandler
45
import id.walt.openid4vci.handlers.credential.W3cJwtVcCredentialHandler
56
import id.walt.openid4vci.handlers.granttypes.authorizationcode.AuthorizationCodeAuthorizationEndpoint
67
import id.walt.openid4vci.handlers.granttypes.authorizationcode.AuthorizationCodeTokenEndpoint
@@ -128,4 +129,9 @@ private fun registerDefaultCredentialHandlers(
128129
if (config.credentialEndpointHandlers.get(jwtVcFormat) == null) {
129130
config.credentialEndpointHandlers.register(jwtVcFormat, W3cJwtVcCredentialHandler())
130131
}
132+
133+
val mdocFormat = CredentialFormat.MSO_MDOC
134+
if (config.credentialEndpointHandlers.get(mdocFormat) == null) {
135+
config.credentialEndpointHandlers.register(mdocFormat, MdocCredentialHandler())
136+
}
131137
}

waltid-libraries/protocols/waltid-openid4vci/src/commonMain/kotlin/id/walt/openid4vci/core/DefaultOAuth2Provider.kt

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,19 @@ import id.walt.openid4vci.responses.credential.CredentialResponseHttp
2121
import id.walt.openid4vci.responses.credential.CredentialResponseResult
2222
import id.walt.openid4vci.requests.credential.CredentialRequestResult
2323
import id.walt.openid4vci.metadata.issuer.CredentialConfiguration
24+
import id.walt.openid4vci.metadata.issuer.CredentialDisplay
25+
import id.walt.mdoc.dataelement.json.JsonObjectToCborMappingConfig as LegacyMdocJsonObjectToCborMappingConfig
2426
import id.walt.crypto.keys.Key
27+
import id.walt.mdoc.objects.mso.Status
2528
import id.walt.openid4vci.tokens.AccessTokenContext
26-
import id.walt.oid4vc.data.DisplayProperties
2729
import id.walt.sdjwt.SDMap
30+
import id.walt.x509.CertificateDer
2831
import kotlinx.serialization.json.JsonElement
2932
import kotlinx.serialization.json.JsonNull
3033
import kotlinx.serialization.json.JsonObject
3134
import kotlinx.serialization.json.JsonPrimitive
3235
import kotlinx.serialization.json.buildJsonArray
36+
import kotlin.time.Instant
3337

3438

3539
/**
@@ -227,11 +231,15 @@ class DefaultOAuth2Provider(
227231
issuerKey: Key,
228232
issuerId: String,
229233
credentialData: JsonObject,
230-
dataMapping: JsonObject? ,
234+
dataMapping: JsonObject?,
231235
selectiveDisclosure: SDMap?,
232-
x5Chain: List<String>?,
233-
display: List<DisplayProperties>?,
236+
x5Chain: List<CertificateDer>?,
237+
display: List<CredentialDisplay>?,
234238
w3cVersion: String?,
239+
mDocNameSpacesDataMappingConfig: Map<String, LegacyMdocJsonObjectToCborMappingConfig>?,
240+
credentialStatus: Status?,
241+
validFrom: Instant?,
242+
validUntil: Instant?,
235243
): CredentialResponseResult {
236244
val handler = config.credentialEndpointHandlers.get(configuration.format)
237245
?: return CredentialResponseResult.Failure(
@@ -251,6 +259,10 @@ class DefaultOAuth2Provider(
251259
x5Chain = x5Chain,
252260
display = display,
253261
w3cVersion = w3cVersion,
262+
mDocNameSpacesDataMappingConfig = mDocNameSpacesDataMappingConfig,
263+
credentialStatus = credentialStatus,
264+
validFrom = validFrom,
265+
validUntil = validUntil,
254266
)
255267
}
256268

@@ -275,7 +287,7 @@ class DefaultOAuth2Provider(
275287
"credentials",
276288
issued.map { credentialEntry ->
277289
val credentialValue = credentialEntry.credential.let { element ->
278-
if (element is kotlinx.serialization.json.JsonPrimitive && element.isString) {
290+
if (element is JsonPrimitive && element.isString) {
279291
element.content
280292
} else {
281293
element.toString()

waltid-libraries/protocols/waltid-openid4vci/src/commonMain/kotlin/id/walt/openid4vci/core/OAuth2Provider.kt

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package id.walt.openid4vci.core
33
import id.walt.openid4vci.Session
44
import id.walt.openid4vci.errors.OAuthError
55
import id.walt.openid4vci.metadata.issuer.CredentialConfiguration
6+
import id.walt.mdoc.dataelement.json.JsonObjectToCborMappingConfig as LegacyMdocJsonObjectToCborMappingConfig
67
import id.walt.openid4vci.requests.authorization.AuthorizationRequest
78
import id.walt.openid4vci.requests.authorization.AuthorizationRequestResult
89
import id.walt.openid4vci.requests.token.AccessTokenRequest
@@ -19,10 +20,13 @@ import id.walt.openid4vci.responses.credential.CredentialResponseResult
1920
import id.walt.openid4vci.responses.credential.CredentialResponse
2021
import id.walt.openid4vci.responses.credential.CredentialResponseHttp
2122
import id.walt.crypto.keys.Key
23+
import id.walt.mdoc.objects.mso.Status
2224
import id.walt.openid4vci.tokens.AccessTokenContext
23-
import id.walt.oid4vc.data.DisplayProperties
25+
import id.walt.openid4vci.metadata.issuer.CredentialDisplay
2426
import id.walt.sdjwt.SDMap
27+
import id.walt.x509.CertificateDer
2528
import kotlinx.serialization.json.JsonObject
29+
import kotlin.time.Instant
2630

2731
/**
2832
* Minimal OAuth2 provider contract scoped to the authorization-code/pre-authorized code grants.
@@ -83,9 +87,13 @@ interface OAuth2Provider {
8387
credentialData: JsonObject,
8488
dataMapping: JsonObject? = null,
8589
selectiveDisclosure: SDMap? = null,
86-
x5Chain: List<String>? = null,
87-
display: List<DisplayProperties>? = null,
90+
x5Chain: List<CertificateDer>? = null,
91+
display: List<CredentialDisplay>? = null,
8892
w3cVersion: String? = null,
93+
mDocNameSpacesDataMappingConfig: Map<String, LegacyMdocJsonObjectToCborMappingConfig>? = null,
94+
credentialStatus: Status? = null,
95+
validFrom: Instant? = null,
96+
validUntil: Instant? = null,
8997
): CredentialResponseResult
9098

9199
fun writeCredentialError(request: CredentialRequest, error: OAuthError): CredentialResponseHttp
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package id.walt.openid4vci.errors
2+
3+
import kotlinx.serialization.Serializable
4+
5+
@Serializable
6+
data class CredentialError(
7+
val error: String,
8+
val description: String? = null,
9+
)
10+
11+
/**
12+
* OpenID4VCI credential endpoint error codes.
13+
*/
14+
object CredentialErrorCodes {
15+
const val INVALID_REQUEST = "invalid_request"
16+
const val INVALID_TOKEN = "invalid_token"
17+
const val INSUFFICIENT_SCOPE = "insufficient_scope"
18+
const val UNSUPPORTED_CREDENTIAL_TYPE = "unsupported_credential_type"
19+
const val UNSUPPORTED_CREDENTIAL_FORMAT = "unsupported_credential_format"
20+
const val INVALID_OR_MISSING_PROOF = "invalid_or_missing_proof"
21+
const val SERVER_ERROR = "server_error"
22+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package id.walt.openid4vci.handlers.credential
2+
3+
import id.walt.crypto.keys.Key
4+
import id.walt.crypto.keys.jwk.JWKKey
5+
import id.walt.cose.CoseKey
6+
import id.walt.cose.JWKKeyCoseTransform.getCosePublicKey
7+
import id.walt.crypto.utils.Base64Utils.decodeFromBase64Url
8+
import id.walt.did.dids.DidService
9+
import id.walt.did.dids.DidUtils
10+
import kotlinx.serialization.json.Json
11+
import kotlinx.serialization.json.JsonObject
12+
import kotlinx.serialization.json.jsonObject
13+
import kotlinx.serialization.json.jsonPrimitive
14+
15+
internal object JwtProofUtils {
16+
fun parseJwtHeader(jwt: String): JsonObject =
17+
jwt.substringBefore(".").let {
18+
Json.decodeFromString(it.decodeFromBase64Url().decodeToString())
19+
}
20+
21+
suspend fun resolveHolderKey(jwt: String): CoseKey {
22+
val header = parseJwtHeader(jwt)
23+
24+
return when {
25+
JWT_HEADER_JWK in header -> {
26+
val holderJwk = requireNotNull(header[JWT_HEADER_JWK]?.jsonObject) {
27+
"Proof JWT header contains jwk but it is not a JSON object"
28+
}
29+
JWKKey.importJWK(holderJwk.toString()).getOrThrow().getCosePublicKey()
30+
}
31+
32+
JWT_HEADER_KID in header -> {
33+
val holderKid = header[JWT_HEADER_KID]?.jsonPrimitive?.content
34+
?: throw IllegalArgumentException("Proof JWT header contains kid but it is blank")
35+
36+
require(DidUtils.isDidUrl(holderKid)) {
37+
"Proof JWT kid must be a DID URL when using kid-based holder key resolution: $holderKid"
38+
}
39+
40+
val holderKey: Key = DidService.resolveToKey(holderKid.substringBefore("#")).getOrThrow()
41+
JWKKey.importJWK(holderKey.exportJWK()).getOrThrow().getCosePublicKey()
42+
}
43+
44+
else -> throw IllegalArgumentException("Proof JWT header must contain kid or jwk claim")
45+
}
46+
}
47+
48+
private const val JWT_HEADER_JWK = "jwk"
49+
private const val JWT_HEADER_KID = "kid"
50+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
package id.walt.openid4vci.handlers.credential
2+
3+
import id.walt.cose.CoseCertificate
4+
import id.walt.crypto.keys.Key
5+
import id.walt.mdoc.objects.mso.Status
6+
import id.walt.openid4vci.CredentialFormat
7+
import id.walt.openid4vci.errors.OAuthError
8+
import id.walt.openid4vci.handlers.endpoints.credential.CredentialEndpointHandler
9+
import id.walt.openid4vci.metadata.issuer.CredentialConfiguration
10+
import id.walt.openid4vci.metadata.issuer.CredentialDisplay
11+
import id.walt.mdoc.dataelement.json.JsonObjectToCborMappingConfig as LegacyMdocJsonObjectToCborMappingConfig
12+
import id.walt.openid4vci.requests.credential.CredentialRequest
13+
import id.walt.openid4vci.responses.credential.CredentialResponse
14+
import id.walt.openid4vci.responses.credential.CredentialResponseResult
15+
import id.walt.openid4vci.responses.credential.IssuedCredential
16+
import id.walt.sdjwt.SDMap
17+
import id.walt.x509.CertificateDer
18+
import kotlinx.serialization.ExperimentalSerializationApi
19+
import kotlinx.serialization.json.JsonObject
20+
import kotlinx.serialization.json.JsonPrimitive
21+
import kotlinx.serialization.json.jsonObject
22+
import kotlin.time.Clock
23+
import kotlin.time.Duration.Companion.days
24+
import kotlin.time.Instant
25+
26+
/**
27+
* Native mDoc credential response handler.
28+
*
29+
* This mirrors the old issuer2 mDoc flow, but uses the native vci request model and the
30+
* new mdoc issuer directly.
31+
*/
32+
class MdocCredentialHandler : CredentialEndpointHandler {
33+
override suspend fun sign(
34+
request: CredentialRequest,
35+
configuration: CredentialConfiguration,
36+
issuerKey: Key,
37+
issuerId: String,
38+
credentialData: JsonObject,
39+
dataMapping: JsonObject?,
40+
selectiveDisclosure: SDMap?,
41+
x5Chain: List<CertificateDer>?,
42+
display: List<CredentialDisplay>?,
43+
w3cVersion: String?,
44+
mDocNameSpacesDataMappingConfig: Map<String, LegacyMdocJsonObjectToCborMappingConfig>?,
45+
credentialStatus: Status?,
46+
validFrom: Instant?,
47+
validUntil: Instant?,
48+
): CredentialResponseResult {
49+
return try {
50+
computeCredentialResult(
51+
request = request,
52+
configuration = configuration,
53+
issuerKey = issuerKey,
54+
credentialData = credentialData,
55+
x5Chain = x5Chain,
56+
mDocNameSpacesDataMappingConfig = mDocNameSpacesDataMappingConfig,
57+
credentialStatus = credentialStatus,
58+
validFrom = validFrom,
59+
validUntil = validUntil,
60+
)
61+
} catch (e: Exception) {
62+
CredentialResponseResult.Failure(OAuthError("invalid_request", e.message))
63+
}
64+
}
65+
66+
@OptIn(ExperimentalSerializationApi::class)
67+
private suspend fun computeCredentialResult(
68+
request: CredentialRequest,
69+
configuration: CredentialConfiguration,
70+
issuerKey: Key,
71+
credentialData: JsonObject,
72+
x5Chain: List<CertificateDer>?,
73+
mDocNameSpacesDataMappingConfig: Map<String, LegacyMdocJsonObjectToCborMappingConfig>?,
74+
credentialStatus: Status?,
75+
validFrom: Instant?,
76+
validUntil: Instant?,
77+
): CredentialResponseResult.Success {
78+
if (configuration.format != CredentialFormat.MSO_MDOC) {
79+
throw IllegalArgumentException("Unsupported format ${configuration.format.value}")
80+
}
81+
82+
val docType = configuration.doctype
83+
?: throw IllegalArgumentException("Missing doctype for mDoc credential configuration")
84+
85+
val namespaceIdentifiers = credentialData.keys
86+
require(namespaceIdentifiers.isNotEmpty()) {
87+
"At least one namespace identifier needs to be specified for mDoc issuance, found none in credentialData: $credentialData"
88+
}
89+
mDocNameSpacesDataMappingConfig?.let { mappingConfig ->
90+
require(namespaceIdentifiers.containsAll(mappingConfig.keys)) {
91+
"Invalid mDoc nameSpace data mapping configuration: found data mapping configuration for nameSpace that is not defined in credentialData namespaces"
92+
}
93+
}
94+
95+
namespaceIdentifiers.forEach { namespaceIdentifier ->
96+
requireNotNull(credentialData[namespaceIdentifier]?.jsonObject) {
97+
"Credential data for namespace $namespaceIdentifier must be a JSON object"
98+
}
99+
}
100+
101+
val issuerCertificateChain = requireNotNull(x5Chain?.takeIf { it.isNotEmpty() }) {
102+
"mDoc issuance requests require that the x5Chain parameter contains at least one entry"
103+
}.map { CoseCertificate(it.bytes.toByteArray()) }
104+
105+
val issuedCredential = MdocCredentialSigner.generateMdocCredential(
106+
credentialRequest = request,
107+
credentialData = credentialData,
108+
issuerKey = issuerKey,
109+
issuerCertificate = issuerCertificateChain,
110+
docType = docType,
111+
validFrom = validFrom,
112+
validUntil = resolveValidUntil(request, validUntil),
113+
status = credentialStatus,
114+
mDocNameSpacesDataMappingConfig = mDocNameSpacesDataMappingConfig,
115+
)
116+
117+
return CredentialResponseResult.Success(
118+
CredentialResponse(
119+
credentials = listOf(
120+
IssuedCredential(credential = JsonPrimitive(issuedCredential)),
121+
),
122+
),
123+
)
124+
}
125+
126+
private fun resolveValidUntil(
127+
request: CredentialRequest,
128+
configuredValidUntil: Instant?,
129+
): Instant =
130+
request.requestForm["validUntil"]
131+
?.firstOrNull()
132+
?.toLongOrNull()
133+
?.let(Instant::fromEpochMilliseconds)
134+
?: configuredValidUntil
135+
?: Clock.System.now().plus(365.days)
136+
}

0 commit comments

Comments
 (0)