From 4d481ec07d54d8f2d7acf6fe4e042aedbf420b9b Mon Sep 17 00:00:00 2001 From: Christian Kollmann Date: Wed, 10 Apr 2024 11:43:23 +0200 Subject: [PATCH 01/18] Use our EU PID credential implementation for tests --- vclib-openid/build.gradle.kts | 1 + .../lib/oidc/DummyCredentialDataProvider.kt | 17 +++++++++++++---- .../at/asitplus/wallet/lib/oidc/EudiwPid1.kt | 14 -------------- .../wallet/lib/oidc/EudiwPidCredentialScheme.kt | 11 ----------- .../wallet/lib/oidc/OidcSiopInteropTest.kt | 14 +++----------- 5 files changed, 17 insertions(+), 40 deletions(-) delete mode 100644 vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/EudiwPid1.kt delete mode 100644 vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/EudiwPidCredentialScheme.kt diff --git a/vclib-openid/build.gradle.kts b/vclib-openid/build.gradle.kts index 529f156a2..1ad1bb398 100644 --- a/vclib-openid/build.gradle.kts +++ b/vclib-openid/build.gradle.kts @@ -31,6 +31,7 @@ kotlin { commonTest { dependencies { + implementation("at.asitplus.wallet:eupidcredential:1.0.0") } } diff --git a/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/DummyCredentialDataProvider.kt b/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/DummyCredentialDataProvider.kt index fea590b56..da92c9a95 100644 --- a/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/DummyCredentialDataProvider.kt +++ b/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/DummyCredentialDataProvider.kt @@ -2,6 +2,8 @@ package at.asitplus.wallet.lib.oidc import at.asitplus.KmmResult import at.asitplus.crypto.datatypes.CryptoPublicKey +import at.asitplus.wallet.eupid.EuPidCredential +import at.asitplus.wallet.eupid.EuPidScheme import at.asitplus.wallet.lib.agent.ClaimToBeIssued import at.asitplus.wallet.lib.agent.CredentialToBeIssued import at.asitplus.wallet.lib.agent.Issuer @@ -106,10 +108,12 @@ class DummyCredentialDataProvider( ) } - if (credentialScheme == EudiwPidCredentialScheme) { + if (credentialScheme == EuPidScheme) { val subjectId = subjectPublicKey.didEncoded val claims = listOfNotNull( - optionalClaim(claimNames, "family_name", "someone"), + optionalClaim(claimNames, EuPidScheme.Attributes.FAMILY_NAME, "Musterfrau"), + optionalClaim(claimNames, EuPidScheme.Attributes.GIVEN_NAME, "Maria"), + optionalClaim(claimNames, EuPidScheme.Attributes.BIRTH_DATE, LocalDate.parse("1970-01-01")), ) credentials += when (representation) { ConstantIndex.CredentialRepresentation.SD_JWT -> listOf( @@ -118,8 +122,13 @@ class DummyCredentialDataProvider( ConstantIndex.CredentialRepresentation.PLAIN_JWT -> listOf( CredentialToBeIssued.VcJwt( - subject = EudiwPid1(subjectId, "someone"), - expiration = expiration, + EuPidCredential( + id = subjectId, + familyName = "Musterfrau", + givenName = "Maria", + birthDate = LocalDate.parse("1970-01-01") + ), + expiration, ) ) diff --git a/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/EudiwPid1.kt b/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/EudiwPid1.kt deleted file mode 100644 index 1b4e501b2..000000000 --- a/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/EudiwPid1.kt +++ /dev/null @@ -1,14 +0,0 @@ -package at.asitplus.wallet.lib.oidc - -import at.asitplus.wallet.lib.data.CredentialSubject -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -@Serializable -@SerialName("EudiwPid1") -data class EudiwPid1( - override val id: String, - - @SerialName("family_name") - val familyName: String, -) : CredentialSubject() \ No newline at end of file diff --git a/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/EudiwPidCredentialScheme.kt b/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/EudiwPidCredentialScheme.kt deleted file mode 100644 index 85f7f29b4..000000000 --- a/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/EudiwPidCredentialScheme.kt +++ /dev/null @@ -1,11 +0,0 @@ -package at.asitplus.wallet.lib.oidc - -import at.asitplus.wallet.lib.data.ConstantIndex - -object EudiwPidCredentialScheme : ConstantIndex.CredentialScheme { - override val schemaUri: String = "https://wallet.a-sit.at/schemas/1.0.0/EudiwPid1.json" - override val vcType: String = "EudiwPid1" - override val isoNamespace: String = "eu.europa.ec.eudiw.pid.1" - override val isoDocType: String = "eu.europa.ec.eudiw.pid.1" - override val claimNames: Collection = listOf("family_name") -} \ No newline at end of file diff --git a/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopInteropTest.kt b/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopInteropTest.kt index 9bc393f26..aeb5cd8e4 100644 --- a/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopInteropTest.kt +++ b/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopInteropTest.kt @@ -3,6 +3,7 @@ package at.asitplus.wallet.lib.oidc import at.asitplus.crypto.datatypes.jws.JweAlgorithm import at.asitplus.crypto.datatypes.jws.JwsAlgorithm import at.asitplus.crypto.datatypes.jws.JwsSigned +import at.asitplus.wallet.eupid.EuPidScheme import at.asitplus.wallet.lib.LibraryInitializer import at.asitplus.wallet.lib.agent.CryptoService import at.asitplus.wallet.lib.agent.DefaultCryptoService @@ -36,16 +37,7 @@ class OidcSiopInteropTest : FreeSpec({ lateinit var holderSiop: OidcSiopWallet beforeSpec { - LibraryInitializer.registerExtensionLibrary( - LibraryInitializer.ExtensionLibraryInfo( - credentialScheme = EudiwPidCredentialScheme, - serializersModule = SerializersModule { - polymorphic(CredentialSubject::class) { - subclass(EudiwPid1::class) - } - }, - ) - ) + at.asitplus.wallet.eupid.Initializer.initWithVcLib() } beforeEach { @@ -58,7 +50,7 @@ class OidcSiopInteropTest : FreeSpec({ dataProvider = DummyCredentialDataProvider(), ).issueCredential( subjectPublicKey = holderCryptoService.publicKey, - attributeTypes = listOf(EudiwPidCredentialScheme.vcType), + attributeTypes = listOf(EuPidScheme.vcType), representation = ConstantIndex.CredentialRepresentation.ISO_MDOC, ).toStoreCredentialInput() ) From 5c12cf74a9901c15dacb2275bc6d344772a534fe Mon Sep 17 00:00:00 2001 From: Christian Kollmann Date: Wed, 10 Apr 2024 19:35:10 +0200 Subject: [PATCH 02/18] Implement data classes for OID4VCI draft 13 --- CHANGELOG.md | 1 + .../oidc/AuthenticationRequestParameters.kt | 8 + .../asitplus/wallet/lib/oidc/IdTokenType.kt | 26 ++- .../wallet/lib/oidc/IdTokenTypeSerializer.kt | 23 --- .../wallet/lib/oidc/OidcSiopWallet.kt | 16 +- .../wallet/lib/oidvci/AuthorizationDetails.kt | 82 ++++----- .../wallet/lib/oidvci/CredentialFormatEnum.kt | 28 ++- .../lib/oidvci/CredentialFormatSerializer.kt | 22 --- .../wallet/lib/oidvci/CredentialOffer.kt | 160 ++++++++++++++++ .../lib/oidvci/CredentialRequestParameters.kt | 5 +- .../lib/oidvci/CredentialRequestProof.kt | 9 +- .../oidvci/CredentialRequestProofSupported.kt | 15 ++ .../oidvci/CredentialSubjectMetadataSingle.kt | 9 +- .../lib/oidvci/DisplayLogoProperties.kt | 23 +++ .../wallet/lib/oidvci/DisplayProperties.kt | 36 +++- .../wallet/lib/oidvci/IssuerMetadata.kt | 171 +++++++----------- .../wallet/lib/oidvci/IssuerService.kt | 65 +++---- .../oidvci/SupportedAlgorithmsContainer.kt | 36 ++++ .../lib/oidvci/SupportedCredentialFormat.kt | 162 ++++++----------- .../SupportedCredentialFormatDefinition.kt | 33 ++++ .../wallet/lib/oidvci/VpFormatsSupported.kt | 23 --- .../wallet/lib/oidvci/WalletService.kt | 26 ++- .../lib/oidvci/mdl/ClaimDisplayProperties.kt | 20 -- .../RequestedCredentialClaimSpecification.kt | 3 +- .../wallet/lib/oidvci/OidvciProcessTest.kt | 4 +- .../wallet/lib/oidvci/SerializationTest.kt | 7 +- .../lib/oidvci/mdl/SerializationTest.kt | 31 ---- 27 files changed, 604 insertions(+), 440 deletions(-) delete mode 100644 vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/IdTokenTypeSerializer.kt delete mode 100644 vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CredentialFormatSerializer.kt create mode 100644 vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CredentialOffer.kt create mode 100644 vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CredentialRequestProofSupported.kt create mode 100644 vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/DisplayLogoProperties.kt create mode 100644 vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/SupportedAlgorithmsContainer.kt create mode 100644 vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/SupportedCredentialFormatDefinition.kt delete mode 100644 vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/mdl/ClaimDisplayProperties.kt delete mode 100644 vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/mdl/SerializationTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index e2ea15724..1ea796da6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Release 3.6.0: - Conventions 1.9.23+20240410 - Ktor 2.3.10 - Auto-publish version catalogs + - Implement OpenID for Verifiable Credential Issuance draft 13, from 2024-02-08 Release 3.5.0: - Kotlin 1.9.23 diff --git a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/AuthenticationRequestParameters.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/AuthenticationRequestParameters.kt index 17b97f857..99d947f25 100644 --- a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/AuthenticationRequestParameters.kt +++ b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/AuthenticationRequestParameters.kt @@ -227,6 +227,14 @@ data class AuthenticationRequestParameters( @SerialName("iat") @Serializable(with = InstantLongSerializer::class) val issuedAt: Instant? = null, + + /** + * RFC8707: In requests to the authorization server, a client MAY indicate the protected resource (a.k.a. + * resource server, application, API, etc.) to which it is requesting access. Its value MUST be an absolute URI, + * as specified by Section 4.3 of (RFC3986). + */ + @SerialName("resource") + val resource: String? = null, ) { fun serialize() = jsonSerializer.encodeToString(this) diff --git a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/IdTokenType.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/IdTokenType.kt index ab4f85e8a..ce03a6291 100644 --- a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/IdTokenType.kt +++ b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/IdTokenType.kt @@ -1,8 +1,32 @@ package at.asitplus.wallet.lib.oidc +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +@Serializable(with = IdTokenTypeSerializer::class) enum class IdTokenType(val text: String) { SUBJECT_SIGNED("subject_signed_id_token"), ATTESTER_SIGNED("attester_signed_id_token") -} \ No newline at end of file +} + +object IdTokenTypeSerializer : KSerializer { + + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("IdTokenType", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: IdTokenType) { + encoder.encodeString(value.text) + } + + override fun deserialize(decoder: Decoder): IdTokenType { + val decoded = decoder.decodeString() + return IdTokenType.entries.first { it.text == decoded } + } +} diff --git a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/IdTokenTypeSerializer.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/IdTokenTypeSerializer.kt deleted file mode 100644 index 91329402c..000000000 --- a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/IdTokenTypeSerializer.kt +++ /dev/null @@ -1,23 +0,0 @@ -package at.asitplus.wallet.lib.oidc - -import kotlinx.serialization.KSerializer -import kotlinx.serialization.descriptors.PrimitiveKind -import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor -import kotlinx.serialization.descriptors.SerialDescriptor -import kotlinx.serialization.encoding.Decoder -import kotlinx.serialization.encoding.Encoder - -object IdTokenTypeSerializer : KSerializer { - - override val descriptor: SerialDescriptor = - PrimitiveSerialDescriptor("IdTokenTypeSerializer", PrimitiveKind.STRING) - - override fun serialize(encoder: Encoder, value: IdTokenType) { - encoder.encodeString(value.text) - } - - override fun deserialize(decoder: Decoder): IdTokenType { - val decoded = decoder.decodeString() - return IdTokenType.values().first { it.text == decoded } - } -} diff --git a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopWallet.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopWallet.kt index ac223e156..74b30920b 100644 --- a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopWallet.kt +++ b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopWallet.kt @@ -91,7 +91,7 @@ class OidcSiopWallet( sealed class AuthenticationResponseResult { /** * Wallet returns the [AuthenticationResponseParameters] as form parameters, which shall be posted to - * `redirect_uri of the Relying Party, i.e. clients should execute that POST with [params] to [url]. + * `redirect_uri` of the Relying Party, i.e. clients should execute that POST with [params] to [url]. */ data class Post(val url: String, val params: Map) : AuthenticationResponseResult() @@ -106,13 +106,13 @@ class OidcSiopWallet( IssuerMetadata( issuer = clientId, authorizationEndpointUrl = clientId, - responseTypesSupported = arrayOf(ID_TOKEN), - scopesSupported = arrayOf(SCOPE_OPENID), - subjectTypesSupported = arrayOf("pairwise", "public"), - idTokenSigningAlgorithmsSupported = arrayOf(jwsService.algorithm.identifier), - requestObjectSigningAlgorithmsSupported = arrayOf(jwsService.algorithm.identifier), - subjectSyntaxTypesSupported = arrayOf(URN_TYPE_JWK_THUMBPRINT, PREFIX_DID_KEY), - idTokenTypesSupported = arrayOf(IdTokenType.SUBJECT_SIGNED), + responseTypesSupported = listOf(ID_TOKEN), + scopesSupported = listOf(SCOPE_OPENID), + subjectTypesSupported = listOf("pairwise", "public"), + idTokenSigningAlgorithmsSupported = listOf(jwsService.algorithm.identifier), + requestObjectSigningAlgorithmsSupported = listOf(jwsService.algorithm.identifier), + subjectSyntaxTypesSupported = listOf(URN_TYPE_JWK_THUMBPRINT, PREFIX_DID_KEY), + idTokenTypesSupported = listOf(IdTokenType.SUBJECT_SIGNED), presentationDefinitionUriSupported = false, ) } diff --git a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/AuthorizationDetails.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/AuthorizationDetails.kt index 0b398d4b6..8da516d3c 100644 --- a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/AuthorizationDetails.kt +++ b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/AuthorizationDetails.kt @@ -5,72 +5,68 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable /** - * To be contained in an array sent in parameter `authorization_details` from the Wallet to the Issuer + * OID4VCI: The request parameter `authorization_details` defined in Section 2 of (RFC9396) MUST be used to convey the + * details about the Credentials the Wallet wants to obtain. This specification introduces a new authorization details + * type `openid_credential` and defines the following parameters to be used with this authorization details type. */ @Serializable data class AuthorizationDetails( /** - * Must be `openid_credential` + * OID4VCI: REQUIRED. String that determines the authorization details type. It MUST be set to `openid_credential` + * for the purpose of this specification. */ @SerialName("type") val type: String, + /** + * OID4VC: REQUIRED when [format] parameter is not present. String specifying a unique identifier of the Credential + * being described in [IssuerMetadata.supportedCredentialConfigurations]. + */ + @SerialName("credential_configuration_id") + val credentialConfigurationId: String? = null, + + /** + * OID4VCI: REQUIRED when [credentialConfigurationId] parameter is not present. + * String identifying the format of the Credential the Wallet needs. + * This Credential format identifier determines further claims in the authorization details object needed to + * identify the Credential type in the requested format. + */ @SerialName("format") - val format: CredentialFormatEnum, + val format: CredentialFormatEnum? = null, /** - * OIDVCI: Required for ISO mDL: JSON string identifying the credential type. + * OID4VCI: ISO mDL: OPTIONAL. This claim contains the type value the Wallet requests authorization for at the + * Credential Issuer. It MUST only be present if the [format] claim is present. It MUST not be present otherwise. */ @SerialName("doctype") val docType: String? = null, /** - * OIDVCI: Optional for ISO mDL: A JSON object containing a list of key value pairs, where the key is a certain - * namespace as defined in ISO.18013-5 (or any profile of it), and the value is a JSON object. This object also - * contains a list of key value pairs, where the key is a claim that is defined in the respective namespace and is - * offered in the Credential. + * OID4VCI: ISO mDL: OPTIONAL. Object as defined in Appendix A.3.2 excluding the `display` and `value_type` + * parameters. The `mandatory` parameter here is used by the Wallet to indicate to the Issuer that it only accepts + * Credential(s) issued with those claim(s). */ @SerialName("claims") val claims: Map>? = null, /** - * e.g. `VerifiableCredential`, `UniversityDegreeCredential` + * OID4VCI: W3C VC: OPTIONAL. Object containing a detailed description of the Credential consisting of the + * following parameters. see [SupportedCredentialFormatDefinition]. */ - @SerialName("types") - val types: Array, + @SerialName("credential_definition") + val credentialDefinition: SupportedCredentialFormatDefinition? = null, /** - * Must contain the `authorization_server` entry from the Issuer's metadata + * OID4VCI: IETF SD-JWT VC: REQUIRED. String as defined in Appendix A.3.2. This claim contains the type values + * the Wallet requests authorization for at the Credential Issuer. + * It MUST only be present if the [format] claim is present. It MUST not be present otherwise. */ - @SerialName("locations") - val locations: Array? = null, -) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other == null || this::class != other::class) return false + @SerialName("vct") + val sdJwtVcType: String? = null, - other as AuthorizationDetails - - if (type != other.type) return false - if (format != other.format) return false - if (docType != other.docType) return false - if (claims != other.claims) return false - if (!types.contentEquals(other.types)) return false - if (locations != null) { - if (other.locations == null) return false - if (!locations.contentEquals(other.locations)) return false - } else if (other.locations != null) return false - - return true - } - - override fun hashCode(): Int { - var result = type.hashCode() - result = 31 * result + format.hashCode() - result = 31 * result + (docType?.hashCode() ?: 0) - result = 31 * result + (claims?.hashCode() ?: 0) - result = 31 * result + types.contentHashCode() - result = 31 * result + (locations?.contentHashCode() ?: 0) - return result - } -} \ No newline at end of file + /** + * Must contain an entry form [IssuerMetadata.authorizationServers]. + */ + @SerialName("locations") + val locations: Collection? = null, +) \ No newline at end of file diff --git a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CredentialFormatEnum.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CredentialFormatEnum.kt index 97b874d7a..1247199e3 100644 --- a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CredentialFormatEnum.kt +++ b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CredentialFormatEnum.kt @@ -1,17 +1,41 @@ package at.asitplus.wallet.lib.oidvci +import kotlinx.serialization.KSerializer import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder @Serializable(with = CredentialFormatSerializer::class) enum class CredentialFormatEnum(val text: String) { NONE("none"), JWT_VC("jwt_vc_json"), - JWT_VC_SD("jwt_vc_sd"), // NOTE This value is not official + /** + * Unofficial constant, used by this library prior to implementing OID4VCI Draft 13. + */ + JWT_VC_SD_UNOFFICIAL("jwt_vc_sd"), + VC_SD_JWT("vc+sd-jwt"), JWT_VC_JSON_LD("jwt_vc_json-ld"), JSON_LD("ldp_vc"), MSO_MDOC("mso_mdoc"); companion object { - fun parse(text: String) = values().firstOrNull { it.text == text } + fun parse(text: String) = entries.firstOrNull { it.text == text } + } +} + +object CredentialFormatSerializer : KSerializer { + + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("CredentialFormatEnumSerializer", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: CredentialFormatEnum) { + encoder.encodeString(value.text) + } + + override fun deserialize(decoder: Decoder): CredentialFormatEnum { + return CredentialFormatEnum.parse(decoder.decodeString()) ?: CredentialFormatEnum.NONE } } \ No newline at end of file diff --git a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CredentialFormatSerializer.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CredentialFormatSerializer.kt deleted file mode 100644 index 223caea11..000000000 --- a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CredentialFormatSerializer.kt +++ /dev/null @@ -1,22 +0,0 @@ -package at.asitplus.wallet.lib.oidvci - -import kotlinx.serialization.KSerializer -import kotlinx.serialization.descriptors.PrimitiveKind -import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor -import kotlinx.serialization.descriptors.SerialDescriptor -import kotlinx.serialization.encoding.Decoder -import kotlinx.serialization.encoding.Encoder - -object CredentialFormatSerializer : KSerializer { - - override val descriptor: SerialDescriptor = - PrimitiveSerialDescriptor("CredentialFormatEnumSerializer", PrimitiveKind.STRING) - - override fun serialize(encoder: Encoder, value: CredentialFormatEnum) { - encoder.encodeString(value.text) - } - - override fun deserialize(decoder: Decoder): CredentialFormatEnum { - return CredentialFormatEnum.parse(decoder.decodeString()) ?: CredentialFormatEnum.NONE - } -} \ No newline at end of file diff --git a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CredentialOffer.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CredentialOffer.kt new file mode 100644 index 000000000..55d17c1bc --- /dev/null +++ b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CredentialOffer.kt @@ -0,0 +1,160 @@ +package at.asitplus.wallet.lib.oidvci + +import at.asitplus.KmmResult +import at.asitplus.KmmResult.Companion.wrap +import at.asitplus.wallet.lib.oidc.jsonSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString + +/** + * OID4VCI: The Credential Issuer sends Credential Offer using an HTTP GET request or an HTTP redirect to the Wallet's + * Credential Offer Endpoint defined in Section 11.1.The Credential Offer object, which is a JSON-encoded object with + * the Credential Offer parameters, can be sent by value or by reference. + */ +@Serializable +data class CredentialOfferUrlParameters( + /** + * OID4VCI: Object with the Credential Offer parameters. This MUST NOT be present when the `credential_offer_uri` + * parameter is present. + */ + @SerialName("credential_offer") + val credentialOffer: String, + + /** + * OID4VCI: String that is a URL using the `https` scheme referencing a resource containing a JSON object with the + * Credential Offer parameters. This MUST NOT be present when the `credential_offer` parameter is present. + */ + @SerialName("credential_offer_uri") + val credentialOfferUrl: String, +) + +@Serializable +data class CredentialOffer( + /** + * OID4VCI: REQUIRED. The URL of the Credential Issuer, as defined in Section 11.2.1, from which the Wallet is + * requested to obtain one or more Credentials. The Wallet uses it to obtain the Credential Issuer's Metadata + * following the steps defined in Section 11.2.2. + */ + @SerialName("credential_issuer") + val credentialIssuer: String, + + /** + * OID4VCI: REQUIRED. Array of unique strings that each identify one of the keys in the name/value pairs stored in + * the `credential_configurations_supported` Credential Issuer metadata. The Wallet uses these string values to + * obtain the respective object that contains information about the Credential being offered as defined in + * Section 11.2.3. For example, these string values can be used to obtain `scope` values to be used in the + * Authorization Request. + */ + @SerialName("credential_configuration_ids") + val configurationIds: Collection, + + /** + * OID4VCI: OPTIONAL. If `grants` is not present or is empty, the Wallet MUST determine the Grant Types the + * Credential Issuer's Authorization Server supports using the respective metadata. When multiple grants are + * present, it is at the Wallet's discretion which one to use. + */ + @SerialName("grants") + val grants: CredentialOfferGrants? = null, +) { + fun serialize() = jsonSerializer.encodeToString(this) + + companion object { + fun deserialize(input: String): KmmResult = + runCatching { jsonSerializer.decodeFromString(input) }.wrap() + } +} + +/** + * OID4VCI: Object indicating to the Wallet the Grant Types the Credential Issuer's Authorization Server is prepared to + * process for this Credential Offer. Every grant is represented by a name/value pair. The name is the Grant Type + * identifier; the value is an object that contains parameters either determining the way the Wallet MUST use the + * particular grant and/or parameters the Wallet MUST send with the respective request(s). + */ +@Serializable +data class CredentialOfferGrants( + @SerialName("authorization_code") + val authorizationCode: CredentialOfferGrantsAuthCode? = null, + + @SerialName("urn:ietf:params:oauth:grant-type:pre-authorized_code") + val preAuthorizedCode: CredentialOfferGrantsPreAuthCode? = null +) + +@Serializable +data class CredentialOfferGrantsAuthCode( + /** + * OID4VCI: OPTIONAL. String value created by the Credential Issuer and opaque to the Wallet that is used to bind + * the subsequent Authorization Request with the Credential Issuer to a context set up during previous steps. If the + * Wallet decides to use the Authorization Code Flow and received a value for this parameter, it MUST include it in + * the subsequent Authorization Request to the Credential Issuer as the `issuer_state` parameter value. + */ + @SerialName("issuer_state") + val issuerState: String? = null, + + /** + * OID4VCI: OPTIONAL string that the Wallet can use to identify the Authorization Server to use with this grant + * type when `authorization_servers` parameter in the Credential Issuer metadata has multiple entries. It MUST NOT + * be used otherwise. The value of this parameter MUST match with one of the values in the `authorization_servers` + * array obtained from the Credential Issuer metadata. + */ + @SerialName("authorization_server") + val authorizationServer: String? = null, +) + +@Serializable +data class CredentialOfferGrantsPreAuthCode( + /** + * OID4VCI: REQUIRED. The code representing the Credential Issuer's authorization for the Wallet to obtain + * Credentials of a certain type. This code MUST be short lived and single use. If the Wallet decides to use the + * Pre-Authorized Code Flow, this parameter value MUST be included in the subsequent Token Request with the + * Pre-Authorized Code Flow. + */ + @SerialName("pre-authorized_code") + val preAuthorizedCode: String, + + /** + * OID4VCI: OPTIONAL. Object specifying whether the Authorization Server expects presentation of a Transaction Code + * by the End-User along with the Token Request in a Pre-Authorized Code Flow. If the Authorization Server does not + * expect a Transaction Code, this object is absent; this is the default. + */ + @SerialName("tx_code") + val transactionCode: CredentialOfferGrantsPreAuthCodeTransactionCode? = null, + + /** + * OID4VCI: OPTIONAL. The minimum amount of time in seconds that the Wallet SHOULD wait between polling requests to + * the token endpoint. If no value is provided, Wallets MUST use 5 as the default. + */ + @SerialName("interval") + val waitIntervalSeconds: Int? = 5, + + /** + * OID4VCI: OPTIONAL string that the Wallet can use to identify the Authorization Server to use with this grant + * type when `authorization_servers` parameter in the Credential Issuer metadata has multiple entries. + */ + @SerialName("authorization_server") + val authorizationServer: String? = null +) + +@Serializable +data class CredentialOfferGrantsPreAuthCodeTransactionCode( + /** + * OID4VCI: OPTIONAL. String specifying the input character set. Possible values are `numeric` (only digits) and + * `text` (any characters). The default is `numeric`. + */ + @SerialName("input_mode") + val inputMode: String? = "numeric", + + /** + * OID4VCI: OPTIONAL. Integer specifying the length of the Transaction Code. This helps the Wallet to render the + * input screen and improve the user experience. + */ + @SerialName("length") + val length: Int? = null, + + /** + * OID4VCI: OPTIONAL. String containing guidance for the Holder of the Wallet on how to obtain the Transaction + * Code, e.g., describing over which communication channel it is delivered. + */ + @SerialName("description") + val description: String? = null, +) \ No newline at end of file diff --git a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CredentialRequestParameters.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CredentialRequestParameters.kt index a1f325bc5..448be679f 100644 --- a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CredentialRequestParameters.kt +++ b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CredentialRequestParameters.kt @@ -16,8 +16,7 @@ data class CredentialRequestParameters( /** * OID4VCI: - * ISO mDL: N/A. - * W3C Verifiable Credentials: REQUIRED. + * W3C VC: REQUIRED. * * JSON array designating the types a certain credential type supports according to (VC_DATA), * Section 4.3. @@ -29,7 +28,6 @@ data class CredentialRequestParameters( /** * OID4VCI: * ISO mDL: REQUIRED. - * W3C Verifiable Credentials: N/A. * * JSON string identifying the credential type. */ @@ -39,7 +37,6 @@ data class CredentialRequestParameters( /** * OID4VCI: * ISO mDL: OPTIONAL. - * W3C Verifiable Credentials: N/A. * * A JSON object containing a list of key value pairs, * where the key is a certain namespace as defined in [ISO.18013-5] (or any profile of it), diff --git a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CredentialRequestProof.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CredentialRequestProof.kt index 3d964a778..8132c4114 100644 --- a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CredentialRequestProof.kt +++ b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CredentialRequestProof.kt @@ -6,15 +6,14 @@ import kotlinx.serialization.Serializable @Serializable data class CredentialRequestProof( /** - * OID4VCI: - * e.g. `jwt` + * OID4VCI: e.g. `jwt`, or `cwt`, or `ldp_vp`. */ @SerialName("proof_type") val proofType: String, /** - * See OID4VCI Proof Type "JWT" + * See OID4VCI Proof Types for contents. */ - @SerialName("jwt") - val jwt: String + @SerialName("proof") + val proof: String, ) \ No newline at end of file diff --git a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CredentialRequestProofSupported.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CredentialRequestProofSupported.kt new file mode 100644 index 000000000..7caadc8fe --- /dev/null +++ b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CredentialRequestProofSupported.kt @@ -0,0 +1,15 @@ +package at.asitplus.wallet.lib.oidvci + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class CredentialRequestProofSupported( + /** + * OID4VCI: REQUIRED. Array of case sensitive strings that identify the algorithms that the Issuer supports for + * this proof type. The Wallet uses one of them to sign the proof. Algorithm names used are determined by the + * key proof type and are defined in Section 7.2.1. + */ + @SerialName("proof_signing_alg_values_supported") + val supportedSigningAlgorithms: Collection, +) \ No newline at end of file diff --git a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CredentialSubjectMetadataSingle.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CredentialSubjectMetadataSingle.kt index e7f8b512e..399315635 100644 --- a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CredentialSubjectMetadataSingle.kt +++ b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CredentialSubjectMetadataSingle.kt @@ -3,11 +3,14 @@ package at.asitplus.wallet.lib.oidvci import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable - +/** + * OID4VCI: W3C VC: To express the specifics about the claim, the most deeply nested value MAY be an object that + * includes the following parameters defined by this specification (other parameters MAY also be used). + */ @Serializable data class CredentialSubjectMetadataSingle( /** - * OID4VCI: OPTIONAL. Boolean which when set to true indicates the claim MUST be present in the issued Credential. + * OID4VCI: OPTIONAL. Boolean which when set to `true` indicates the claim MUST be present in the issued Credential. * If the mandatory property is omitted its default should be assumed to be false. */ @SerialName("mandatory") @@ -26,7 +29,7 @@ data class CredentialSubjectMetadataSingle( * Credential for a certain language. */ @SerialName("display") - val display: DisplayProperties? = null, + val display: Collection? = null, ) diff --git a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/DisplayLogoProperties.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/DisplayLogoProperties.kt new file mode 100644 index 000000000..36db9560b --- /dev/null +++ b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/DisplayLogoProperties.kt @@ -0,0 +1,23 @@ +package at.asitplus.wallet.lib.oidvci + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * OID4VCI: OPTIONAL. Object with information about the logo of the Credential. + */ +@Serializable +data class DisplayLogoProperties( + /** + * OID4VCI: REQUIRED. String value that contains a URI where the Wallet can obtain the logo of the Credential from + * the Credential Issuer. + */ + @SerialName("uri") + val uri: String, + + /** + * OID4VCI: OPTIONAL. String value of the alternative text for the logo image. + */ + @SerialName("alt_text") + val altText: String? = null, +) \ No newline at end of file diff --git a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/DisplayProperties.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/DisplayProperties.kt index bf70d1eda..b516dafb9 100644 --- a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/DisplayProperties.kt +++ b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/DisplayProperties.kt @@ -4,8 +4,8 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable /** - * OID4VCI: OPTIONAL. An array of objects, where each object contains display properties of a Credential Issuer for a - * certain language. + * OID4VCI: OPTIONAL. Array of objects, where each object contains the display properties of the supported Credential + * for a certain language. */ @Serializable data class DisplayProperties( @@ -21,4 +21,36 @@ data class DisplayProperties( */ @SerialName("locale") val locale: String? = null, + + /** + * OID4VCI: OPTIONAL. Object with information about the logo of the Credential. + */ + @SerialName("logo") + val logo: DisplayLogoProperties? = null, + + /** + * OID4VCI: OPTIONAL. String value of a description of the Credential. + */ + @SerialName("description") + val description: String? = null, + + /** + * OID4VCI: OPTIONAL. String value of a background color of the Credential represented as numerical color values + * defined in CSS Color Module Level 37. + */ + @SerialName("background_color") + val backgroundColor: String? = null, + + /** + * OID4VCI: OPTIONAL. Object with information about the background image of the Credential. + */ + @SerialName("background_image") + val backgroundImage: DisplayLogoProperties? = null, + + /** + * OID4VCI: OPTIONAL. String value of a text color of the Credential represented as numerical color values defined + * in CSS Color Module Level 37 + */ + @SerialName("text_color") + val textColor: String? = null, ) \ No newline at end of file diff --git a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/IssuerMetadata.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/IssuerMetadata.kt index 12faa3590..65c186d79 100644 --- a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/IssuerMetadata.kt +++ b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/IssuerMetadata.kt @@ -1,9 +1,12 @@ package at.asitplus.wallet.lib.oidvci +import at.asitplus.KmmResult +import at.asitplus.KmmResult.Companion.wrap import at.asitplus.wallet.lib.oidc.IdTokenType -import at.asitplus.wallet.lib.oidc.IdTokenTypeSerializer +import at.asitplus.wallet.lib.oidc.jsonSerializer import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString /** * To be serialized into `/.well-known/openid-credential-issuer` @@ -17,7 +20,7 @@ data class IssuerMetadata( * from this Issuer. */ @SerialName("issuer") - val issuer: String, + val issuer: String? = null, /** * OID4VCI: REQUIRED. The Credential Issuer's identifier. @@ -26,13 +29,13 @@ data class IssuerMetadata( val credentialIssuer: String? = null, /** - * OID4VCI: OPTIONAL. Identifier of the OAuth 2.0 Authorization Server (as defined in RFC8414) the Credential - * Issuer relies on for authorization. If this element is omitted, the entity providing the Credential Issuer is - * also acting as the AS, i.e. the Credential Issuer's identifier is used as the OAuth 2.0 Issuer value to obtain - * the Authorization Server metadata as per RFC8414. + * OID4VCI: OPTIONAL. Array of strings, where each string is an identifier of the OAuth 2.0 Authorization Server + * (as defined in RFC8414) the Credential Issuer relies on for authorization. If this parameter is omitted, the + * entity providing the Credential Issuer is also acting as the Authorization Server, i.e., the Credential Issuer's + * identifier is used to obtain the Authorization Server metadata. */ - @SerialName("authorization_server") - val authorizationServer: String? = null, + @SerialName("authorization_servers") + val authorizationServers: Collection? = null, /** * OID4VCI: REQUIRED. URL of the Credential Issuer's Credential Endpoint. This URL MUST use the https scheme and @@ -66,30 +69,61 @@ data class IssuerMetadata( * Can be custom URI scheme, or Universal Links/App links. */ @SerialName("authorization_endpoint") - val authorizationEndpointUrl: String, + val authorizationEndpointUrl: String? = null, /** - * OID4VCI: OPTIONAL. URL of the Credential Issuer's Batch Credential Endpoint. This URL MUST use the https scheme - * and MAY contain port, path and query parameter components. If omitted, the Credential Issuer does not support - * the Batch Credential Endpoint. + * OID4VCI: OPTIONAL. URL of the Credential Issuer's Batch Credential Endpoint, as defined in Section 8. + * This URL MUST use the `https` scheme and MAY contain port, path, and query parameter components. + * If omitted, the Credential Issuer does not support the Batch Credential Endpoint. */ @SerialName("batch_credential_endpoint") val batchCredentialEndpointUrl: String? = null, /** - * OID4VCI: REQUIRED. A JSON array containing a list of JSON objects, each of them representing metadata about a - * separate credential type that the Credential Issuer can issue. The JSON objects in the array MUST conform to the - * structure of the Section 10.2.3.1. + * OID4VCI: OPTIONAL. URL of the Credential Issuer's Deferred Credential Endpoint, as defined in Section 9. + * This URL MUST use the `https` scheme and MAY contain port, path, and query parameter components. + * If omitted, the Credential Issuer does not support the Deferred Credential Endpoint. */ - @SerialName("credentials_supported") - val supportedCredentialFormat: Array? = null, + @SerialName("deferred_credential_endpoint") + val deferredCredentialEndpointUrl: String? = null, + + /** + * OID4VCI: OPTIONAL. URL of the Credential Issuer's Notification Endpoint, as defined in Section 10. + * This URL MUST use the `https` scheme and MAY contain port, path, and query parameter components. + * If omitted, the Credential Issuer does not support the Notification Endpoint. + */ + @SerialName("notification_endpoint") + val notificationEndpointUrl: String? = null, + + /** + * OID4VCI: OPTIONAL. Object containing information about whether the Credential Issuer supports encryption of the + * Credential and Batch Credential Response on top of TLS. + */ + @SerialName("credential_response_encryption") + val credentialResponseEncryption: SupportedAlgorithmsContainer? = null, + + /** + * OID4VCI: OPTIONAL. Boolean value specifying whether the Credential Issuer supports returning + * `credential_identifiers` parameter in the `authorization_details` Token Response parameter, with `true` + * indicating support. If omitted, the default value is `false`. + */ + @SerialName("credential_identifiers_supported") + val supportsCredentialIdentifiers: Boolean? = false, + + /** + * OID4VCI: REQUIRED. Object that describes specifics of the Credential that the Credential Issuer supports + * issuance of. This object contains a list of name/value pairs, where each name is a unique identifier of the + * supported Credential being described. + */ + @SerialName("credential_configurations_supported") + val supportedCredentialConfigurations: Map? = null, /** * OID4VCI: OPTIONAL. An array of objects, where each object contains display properties of a Credential Issuer for * a certain language. */ @SerialName("display") - val displayProperties: Array? = null, + val displayProperties: Collection? = null, /** * OIDC Discovery: REQUIRED. JSON array containing a list of the OAuth 2.0 `response_type` values that this OP @@ -98,21 +132,21 @@ data class IssuerMetadata( * OIDC SIOPv2: MUST be `id_token`. */ @SerialName("response_types_supported") - val responseTypesSupported: Array? = null, + val responseTypesSupported: Collection? = null, /** * OIDC SIOPv2: REQUIRED. A JSON array of strings representing supported scopes. * MUST support the `openid` scope value. */ @SerialName("scopes_supported") - val scopesSupported: Array? = null, + val scopesSupported: Collection? = null, /** * OIDC Discovery: REQUIRED. JSON array containing a list of the Subject Identifier types that this OP supports. * Valid types include `pairwise` and `public`. */ @SerialName("subject_types_supported") - val subjectTypesSupported: Array? = null, + val subjectTypesSupported: Collection? = null, /** * OIDC Discovery: REQUIRED. A JSON array containing a list of the JWS signing algorithms (`alg` values) supported @@ -120,7 +154,7 @@ data class IssuerMetadata( * Valid values include `RS256`, `ES256`, `ES256K`, and `EdDSA`. */ @SerialName("id_token_signing_alg_values_supported") - val idTokenSigningAlgorithmsSupported: Array? = null, + val idTokenSigningAlgorithmsSupported: Collection? = null, /** * OIDC SIOPv2: REQUIRED. A JSON array containing a list of the JWS signing algorithms (alg values) supported by the @@ -128,7 +162,7 @@ data class IssuerMetadata( * Valid values include `none`, `RS256`, `ES256`, `ES256K`, and `EdDSA`. */ @SerialName("request_object_signing_alg_values_supported") - val requestObjectSigningAlgorithmsSupported: Array? = null, + val requestObjectSigningAlgorithmsSupported: Collection? = null, /** * OIDC SIOPv2: REQUIRED. A JSON array of strings representing URI scheme identifiers and optionally method names of @@ -136,7 +170,7 @@ data class IssuerMetadata( * Valid values include `urn:ietf:params:oauth:jwk-thumbprint`, `did:example` and others. */ @SerialName("subject_syntax_types_supported") - val subjectSyntaxTypesSupported: Array? = null, + val subjectSyntaxTypesSupported: Collection? = null, /** * OIDC SIOPv2: OPTIONAL. A JSON array of strings containing the list of ID Token types supported by the OP, @@ -145,7 +179,7 @@ data class IssuerMetadata( * ID Token, i.e. the id token is signed with key material under the end-user's control). */ @SerialName("id_token_types_supported") - val idTokenTypesSupported: Array<@Serializable(with = IdTokenTypeSerializer::class) IdTokenType>? = null, + val idTokenTypesSupported: Collection? = null, /** * OID4VP: OPTIONAL. Boolean value specifying whether the Wallet supports the transfer of `presentation_definition` @@ -168,89 +202,12 @@ data class IssuerMetadata( * If omitted, the default value is pre-registered. */ @SerialName("client_id_schemes_supported") - val clientIdSchemesSupported: Array? = null, + val clientIdSchemesSupported: Collection? = null, ) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other == null || this::class != other::class) return false - - other as IssuerMetadata - - if (issuer != other.issuer) return false - if (credentialIssuer != other.credentialIssuer) return false - if (authorizationServer != other.authorizationServer) return false - if (credentialEndpointUrl != other.credentialEndpointUrl) return false - if (tokenEndpointUrl != other.tokenEndpointUrl) return false - if (jsonWebKeySetUrl != other.jsonWebKeySetUrl) return false - if (authorizationEndpointUrl != other.authorizationEndpointUrl) return false - if (batchCredentialEndpointUrl != other.batchCredentialEndpointUrl) return false - if (supportedCredentialFormat != null) { - if (other.supportedCredentialFormat == null) return false - if (!supportedCredentialFormat.contentEquals(other.supportedCredentialFormat)) return false - } else if (other.supportedCredentialFormat != null) return false - if (displayProperties != null) { - if (other.displayProperties == null) return false - if (!displayProperties.contentEquals(other.displayProperties)) return false - } else if (other.displayProperties != null) return false - if (responseTypesSupported != null) { - if (other.responseTypesSupported == null) return false - if (!responseTypesSupported.contentEquals(other.responseTypesSupported)) return false - } else if (other.responseTypesSupported != null) return false - if (scopesSupported != null) { - if (other.scopesSupported == null) return false - if (!scopesSupported.contentEquals(other.scopesSupported)) return false - } else if (other.scopesSupported != null) return false - if (subjectTypesSupported != null) { - if (other.subjectTypesSupported == null) return false - if (!subjectTypesSupported.contentEquals(other.subjectTypesSupported)) return false - } else if (other.subjectTypesSupported != null) return false - if (idTokenSigningAlgorithmsSupported != null) { - if (other.idTokenSigningAlgorithmsSupported == null) return false - if (!idTokenSigningAlgorithmsSupported.contentEquals(other.idTokenSigningAlgorithmsSupported)) return false - } else if (other.idTokenSigningAlgorithmsSupported != null) return false - if (requestObjectSigningAlgorithmsSupported != null) { - if (other.requestObjectSigningAlgorithmsSupported == null) return false - if (!requestObjectSigningAlgorithmsSupported.contentEquals(other.requestObjectSigningAlgorithmsSupported)) return false - } else if (other.requestObjectSigningAlgorithmsSupported != null) return false - if (subjectSyntaxTypesSupported != null) { - if (other.subjectSyntaxTypesSupported == null) return false - if (!subjectSyntaxTypesSupported.contentEquals(other.subjectSyntaxTypesSupported)) return false - } else if (other.subjectSyntaxTypesSupported != null) return false - if (idTokenTypesSupported != null) { - if (other.idTokenTypesSupported == null) return false - if (!idTokenTypesSupported.contentEquals(other.idTokenTypesSupported)) return false - } else if (other.idTokenTypesSupported != null) return false - if (presentationDefinitionUriSupported != other.presentationDefinitionUriSupported) return false - if (vpFormatsSupported != other.vpFormatsSupported) return false - if (clientIdSchemesSupported != null) { - if (other.clientIdSchemesSupported == null) return false - if (!clientIdSchemesSupported.contentEquals(other.clientIdSchemesSupported)) return false - } else if (other.clientIdSchemesSupported != null) return false - - return true - } + fun serialize() = jsonSerializer.encodeToString(this) - override fun hashCode(): Int { - var result = issuer.hashCode() - result = 31 * result + (credentialIssuer?.hashCode() ?: 0) - result = 31 * result + (authorizationServer?.hashCode() ?: 0) - result = 31 * result + (credentialEndpointUrl?.hashCode() ?: 0) - result = 31 * result + (tokenEndpointUrl?.hashCode() ?: 0) - result = 31 * result + (jsonWebKeySetUrl?.hashCode() ?: 0) - result = 31 * result + authorizationEndpointUrl.hashCode() - result = 31 * result + (batchCredentialEndpointUrl?.hashCode() ?: 0) - result = 31 * result + (supportedCredentialFormat?.contentHashCode() ?: 0) - result = 31 * result + (displayProperties?.contentHashCode() ?: 0) - result = 31 * result + (responseTypesSupported?.contentHashCode() ?: 0) - result = 31 * result + (scopesSupported?.contentHashCode() ?: 0) - result = 31 * result + (subjectTypesSupported?.contentHashCode() ?: 0) - result = 31 * result + (idTokenSigningAlgorithmsSupported?.contentHashCode() ?: 0) - result = 31 * result + (requestObjectSigningAlgorithmsSupported?.contentHashCode() ?: 0) - result = 31 * result + (subjectSyntaxTypesSupported?.contentHashCode() ?: 0) - result = 31 * result + (idTokenTypesSupported?.contentHashCode() ?: 0) - result = 31 * result + presentationDefinitionUriSupported.hashCode() - result = 31 * result + (vpFormatsSupported?.hashCode() ?: 0) - result = 31 * result + (clientIdSchemesSupported?.contentHashCode() ?: 0) - return result + companion object { + fun deserialize(input: String): KmmResult = + runCatching { jsonSerializer.decodeFromString(input) }.wrap() } } \ No newline at end of file diff --git a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/IssuerService.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/IssuerService.kt index 496a7b002..940c436a4 100644 --- a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/IssuerService.kt +++ b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/IssuerService.kt @@ -2,7 +2,6 @@ package at.asitplus.wallet.lib.oidvci import at.asitplus.crypto.datatypes.io.Base64UrlStrict import at.asitplus.crypto.datatypes.jws.JsonWebToken -import at.asitplus.crypto.datatypes.jws.JwsAlgorithm import at.asitplus.crypto.datatypes.jws.JwsSigned import at.asitplus.crypto.datatypes.jws.toJwsAlgorithm import at.asitplus.wallet.lib.agent.Issuer @@ -80,48 +79,49 @@ class IssuerService( IssuerMetadata( issuer = publicContext, credentialIssuer = publicContext, - authorizationServer = authorizationServer, + authorizationServers = authorizationServer?.let { listOf(it) }, authorizationEndpointUrl = "$publicContext$authorizationEndpointPath", tokenEndpointUrl = "$publicContext$tokenEndpointPath", credentialEndpointUrl = "$publicContext$credentialEndpointPath", - supportedCredentialFormat = credentialSchemes.flatMap { it.toSupportedCredentialFormat() }.toTypedArray(), - displayProperties = credentialSchemes - .map { DisplayProperties(it.vcType, "en") } - .toTypedArray() + supportedCredentialConfigurations = mutableMapOf().apply { + credentialSchemes.forEach { putAll(it.toSupportedCredentialFormat()) } + }, + displayProperties = credentialSchemes.map { DisplayProperties(it.vcType, "en") } ) } - private fun ConstantIndex.CredentialScheme.toSupportedCredentialFormat() = listOf( - SupportedCredentialFormat( + private fun ConstantIndex.CredentialScheme.toSupportedCredentialFormat() = mapOf( + this.isoNamespace to SupportedCredentialFormat( format = CredentialFormatEnum.MSO_MDOC, - id = vcType, - types = arrayOf(vcType), docType = isoDocType, - claims = buildIsoClaims(), - supportedBindingMethods = arrayOf(BINDING_METHOD_COSE_KEY), - supportedCryptographicSuites = issuer.cryptoAlgorithms.map { it.toJwsAlgorithm().identifier }.toTypedArray(), + claims = mapOf( + isoNamespace to claimNames + .associateWith { RequestedCredentialClaimSpecification() } + ), + supportedBindingMethods = listOf(BINDING_METHOD_COSE_KEY), + supportedSigningAlgorithms = issuer.cryptoAlgorithms.map { it.toJwsAlgorithm().identifier }, ), - SupportedCredentialFormat( + this.vcType to SupportedCredentialFormat( format = CredentialFormatEnum.JWT_VC, - id = vcType, - types = arrayOf(VERIFIABLE_CREDENTIAL, vcType), - supportedBindingMethods = arrayOf(PREFIX_DID_KEY, URN_TYPE_JWK_THUMBPRINT), - supportedCryptographicSuites = issuer.cryptoAlgorithms.map { it.toJwsAlgorithm().identifier }.toTypedArray(), + credentialDefinition = SupportedCredentialFormatDefinition( + types = listOf(VERIFIABLE_CREDENTIAL, vcType), + credentialSubject = this.claimNames.associateWith { CredentialSubjectMetadataSingle() } + ), + supportedBindingMethods = listOf(PREFIX_DID_KEY, URN_TYPE_JWK_THUMBPRINT), + supportedSigningAlgorithms = issuer.cryptoAlgorithms.map { it.toJwsAlgorithm().identifier }, ), - SupportedCredentialFormat( - format = CredentialFormatEnum.JWT_VC_SD, - id = vcType, - types = arrayOf(VERIFIABLE_CREDENTIAL, vcType), - supportedBindingMethods = arrayOf(PREFIX_DID_KEY, URN_TYPE_JWK_THUMBPRINT), - supportedCryptographicSuites = issuer.cryptoAlgorithms.map { it.toJwsAlgorithm().identifier }.toTypedArray(), + this.vcType to SupportedCredentialFormat( + format = CredentialFormatEnum.VC_SD_JWT, + sdJwtVcType = vcType, + claims = mapOf( + isoNamespace to claimNames + .associateWith { RequestedCredentialClaimSpecification() } + ), + supportedBindingMethods = listOf(PREFIX_DID_KEY, URN_TYPE_JWK_THUMBPRINT), + supportedSigningAlgorithms = issuer.cryptoAlgorithms.map { it.toJwsAlgorithm().identifier }, ) ) - private fun ConstantIndex.CredentialScheme.buildIsoClaims() = mapOf( - isoNamespace to ConstantIndex.MobileDrivingLicence2023.claimNames - .associateWith { RequestedCredentialClaimSpecification() } - ) - /** * Send this result as HTTP Header `Location` in a 302 response to the client. * @return URL build from client's `redirect_uri` with a `code` query parameter containing a fresh authorization @@ -169,7 +169,7 @@ class IssuerService( ?: throw OAuth2Exception(Errors.INVALID_REQUEST) if (proof.proofType != ProofTypes.JWT) throw OAuth2Exception(Errors.INVALID_PROOF) - val jwsSigned = JwsSigned.parse(proof.jwt) + val jwsSigned = JwsSigned.parse(proof.proof) ?: throw OAuth2Exception(Errors.INVALID_PROOF) val jwt = JsonWebToken.deserialize(jwsSigned.payload.decodeToString()).getOrNull() ?: throw OAuth2Exception(Errors.INVALID_PROOF) @@ -204,7 +204,7 @@ class IssuerService( ) is Issuer.IssuedCredential.VcSdJwt -> CredentialResponseParameters( - format = CredentialFormatEnum.JWT_VC_SD, + format = CredentialFormatEnum.VC_SD_JWT, credential = vcSdJwt, ) } @@ -212,7 +212,8 @@ class IssuerService( } private fun CredentialFormatEnum.toRepresentation() = when (this) { - CredentialFormatEnum.JWT_VC_SD -> ConstantIndex.CredentialRepresentation.SD_JWT + CredentialFormatEnum.JWT_VC_SD_UNOFFICIAL -> ConstantIndex.CredentialRepresentation.SD_JWT + CredentialFormatEnum.VC_SD_JWT -> ConstantIndex.CredentialRepresentation.SD_JWT CredentialFormatEnum.MSO_MDOC -> ConstantIndex.CredentialRepresentation.ISO_MDOC else -> ConstantIndex.CredentialRepresentation.PLAIN_JWT } diff --git a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/SupportedAlgorithmsContainer.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/SupportedAlgorithmsContainer.kt new file mode 100644 index 000000000..f32bc5c90 --- /dev/null +++ b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/SupportedAlgorithmsContainer.kt @@ -0,0 +1,36 @@ +package at.asitplus.wallet.lib.oidvci + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class SupportedAlgorithmsContainer( + /** + * OID4VP: An object where the value is an array of case sensitive strings that identify the cryptographic suites + * that are supported. Parties will need to agree upon the meanings of the values used, which may be + * context-specific, e.g. `EdDSA` and `ES256`. + * + * OID4VCI: REQUIRED. Array containing a list of the JWE (RFC7516) encryption algorithms (alg values) (RFC7518) + * supported by the Credential and Batch Credential Endpoint to encode the Credential or Batch Credential Response + * in a JWT (RFC7519). + */ + @SerialName("alg_values_supported") + val supportedAlgorithms: Collection, + + /** + * OID4VCI: REQUIRED. Array containing a list of the JWE (RFC7516) encryption algorithms (enc values) (RFC7518) + * supported by the Credential and Batch Credential Endpoint to encode the Credential or Batch Credential Response + * in a JWT (RFC7519). + */ + @SerialName("enc_values_supported") + val supportedEncryptionAlgorithms: Collection? = null, + + /** + * OID4VCI: REQUIRED. Boolean value specifying whether the Credential Issuer requires the additional encryption + * on top of TLS for the Credential Response. If the value is `true`, the Credential Issuer requires encryption for + * every Credential Response and therefore the Wallet MUST provide encryption keys in the Credential Request. + * If the value is `false`, the Wallet MAY choose whether it provides encryption keys or not. + */ + @SerialName("encryption_required") + val encryptionRequired: Boolean? = null, +) \ No newline at end of file diff --git a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/SupportedCredentialFormat.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/SupportedCredentialFormat.kt index 5cf66eebd..c42c61ca7 100644 --- a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/SupportedCredentialFormat.kt +++ b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/SupportedCredentialFormat.kt @@ -1,13 +1,15 @@ package at.asitplus.wallet.lib.oidvci +import at.asitplus.wallet.lib.data.dif.CredentialDefinition import at.asitplus.wallet.lib.oidvci.mdl.RequestedCredentialClaimSpecification import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable /** - * OID4VCI: A JSON array containing a list of JSON objects, each of them representing metadata about a - * separate credential type that the Credential Issuer can issue. The JSON objects in the array MUST conform to the - * structure of the Section 10.2.3.1. + * OID4VCI: Object that describes specifics of the Credential that the Credential Issuer supports issuance of. + * This object contains a list of name/value pairs, where each name is a unique identifier of the supported Credential + * being described. This identifier is used in the Credential Offer to communicate to the Wallet which Credential is + * being offered. */ @Serializable data class SupportedCredentialFormat( @@ -20,56 +22,69 @@ data class SupportedCredentialFormat( val format: CredentialFormatEnum, /** - * OID4VCI: OPTIONAL. A JSON string identifying the respective object. The value MUST be unique across all - * `credentials_supported` entries in the Credential Issuer Metadata. + * OID4VCI: OPTIONAL. A JSON string identifying the scope value that this Credential Issuer supports for this + * particular Credential. The value can be the same across multiple `credential_configurations_supported` objects. + * The Authorization Server MUST be able to uniquely identify the Credential Issuer based on the `scope` value. + * The Wallet can use this value in the Authorization Request. Scope values in this Credential Issuer metadata MAY + * duplicate those in the `scopes_supported` parameter of the Authorization Server. */ - @SerialName("id") - val id: String? = null, + @SerialName("scope") + val scope: String? = null, /** - * OID4VCI: - * ISO mDL: N/A. - * W3C Verifiable Credentials: OPTIONAL. - * - * JSON array designating the types a certain credential type supports according to (VC_DATA), - * Section 4.3. - * e.g. `VerifiableCredential`, `UniversityDegreeCredential` + * OID4VCI: OPTIONAL. Array of case-sensitive strings that identify how the Credential is bound to the identifier of + * the End-User who possesses the Credential as defined in Section 7.1. Support for keys in JWK format (RFC7517) is + * indicated by the value `jwk`. Support for keys expressed as a COSE Key object (RFC8152) (for example, used in + * ISO.18013-5) is indicated by the value `cose_key`. When Cryptographic Binding Method is a DID, valid values MUST + * be a `did:` prefix followed by a method-name using a syntax as defined in Section 3.1 of [DID-Core], but without + * a `:` and method-specific-id. For example, support for the DID method with a method-name "example" would be + * represented by `did:example`. */ - @SerialName("types") - val types: Array, + @SerialName("cryptographic_binding_methods_supported") + val supportedBindingMethods: Collection? = null, /** - * OID4VCI: - * ISO mDL: N/A. - * W3C Verifiable Credentials: OPTIONAL. - * - * A JSON object containing a list of key value pairs, where the key identifies the claim offered - * in the Credential. The value MAY be a dictionary, which allows to represent the full (potentially deeply nested) - * structure of the verifiable credential to be issued. The value is a JSON object detailing the specifics about the - * support for the claim. + * OID4VCI: OPTIONAL. Array of case sensitive strings that identify the algorithms that the Issuer uses to sign the + * issued Credential. Algorithm names used are determined by the Credential format and are defined in Appendix A. + */ + @SerialName("credential_signing_alg_values_supported") + val supportedSigningAlgorithms: Collection? = null, + + /** + * OID4VCI: OPTIONAL. Object that describes specifics of the key proof(s) that the Credential Issuer supports. + * This object contains a list of name/value pairs, where each name is a unique identifier of the supported + * proof type(s). + */ + @SerialName("proof_types_supported") + val supportedProofTypes: Map? = null, + + /** + * OID4VCI: W3C VC: REQUIRED. + */ + @SerialName("credential_definition") + val credentialDefinition: SupportedCredentialFormatDefinition? = null, + + /** + * OID4VCI: IETF SD-JWT VC: REQUIRED. String designating the type of a Credential, as defined in + * (I-D.ietf-oauth-sd-jwt-vc). */ - @SerialName("credentialSubject") - val credentialSubject: Map? = null, + @SerialName("vct") + val sdJwtVcType: String? = null, /** * OID4VCI: - * ISO mDL: OPTIONAL. - * W3C Verifiable Credentials: N/A. - * - * JSON string identifying the credential type. + * ISO mDL: REQUIRED. String identifying the Credential type, as defined in (ISO.18013-5). */ @SerialName("doctype") val docType: String? = null, + // TODO For IETF SD-JWT VC this may be nested differently ... see OID4VCI Draft 13. /** * OID4VCI: - * ISO mDL: OPTIONAL. - * W3C Verifiable Credentials: N/A. - * - * A JSON object containing a list of key value pairs, - * where the key is a certain namespace as defined in [ISO.18013-5] (or any profile of it), - * and the value is a JSON object. This object also contains a list of key value pairs, - * where the key is a claim that is defined in the respective namespace and is offered in the Credential. + * ISO mDL: OPTIONAL. Object containing a list of name/value pairs, where the name is a certain namespace as + * defined in (ISO.18013-5) (or any profile of it), and the value is an object. This object also contains a list + * of name/value pairs, where the name is a claim name value that is defined in the respective namespace and is + * offered in the Credential. */ @SerialName("claims") val claims: Map>? = null, @@ -77,74 +92,17 @@ data class SupportedCredentialFormat( /** * OID4VCI: * ISO mDL: OPTIONAL. - * W3C Verifiable Credentials: OPTIONAL. + * W3C VC: OPTIONAL. * - * An array of claims.display.name values that lists them in the order they should be displayed by the Wallet. + * An array of `claims.display.name` values that lists them in the order they should be displayed by the Wallet. */ @SerialName("order") - val order: Array? = null, + val order: Collection? = null, /** - * OID4VCI: OPTIONAL. Array of case-sensitive strings that identify how the Credential is bound to the identifier of - * the End-User who possesses the Credential as defined in Section 7.1. Support for keys in JWK format (RFC7517) is - * indicated by the value `jwk`. Support for keys expressed as a COSE Key object (RFC8152) (for example, used in - * ISO.18013-5) is indicated by the value `cose_key`. When Cryptographic Binding Method is a DID, valid values MUST - * be a `did:` prefix followed by a method-name using a syntax as defined in Section 3.1 of [DID-Core], but without - * a `:` and method-specific-id. For example, support for the DID method with a method-name "example" would be - * represented by `did:example`. Support for all DID methods listed in Section 13 of (DID_Specification_Registries) - * is indicated by sending a DID without any method-name. - */ - @SerialName("cryptographic_binding_methods_supported") - val supportedBindingMethods: Array, - - /** - * OID4VCI: OPTIONAL. Array of case-sensitive strings that identify the cryptographic suites that are supported for - * the `cryptographic_binding_methods_supported`. Cryptosuites for Credentials in `jwt_vc` format should use - * algorithm names defined in IANA JOSE Algorithms Registry. Cryptosuites for Credentials in `ldp_vc` format should - * use signature suites names defined in Linked Data Cryptographic Suite Registry. + * OID4VCI: OPTIONAL. Array of objects, where each object contains the display properties of the supported + * Credential for a certain language. Below is a non-exhaustive list of parameters that MAY be included. */ - @SerialName("cryptographic_suites_supported") - val supportedCryptographicSuites: Array, - - // // TODO - // /** - // * OID4VCI: - // * OPTIONAL. An array of objects, where each object contains the display properties of the supported credential for a certain language. - // * Note that the display name of the supported credential is obtained from display.name and individual claim names from claims.display.name values. - // */ - // @SerialName("display") - // val display: Array<>? = null, -) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other == null || this::class != other::class) return false - - other as SupportedCredentialFormat - - if (format != other.format) return false - if (id != other.id) return false - if (!types.contentEquals(other.types)) return false - if (credentialSubject != other.credentialSubject) return false - if (docType != other.docType) return false - if (claims != other.claims) return false - if (order != null) { - if (other.order == null) return false - if (!order.contentEquals(other.order)) return false - } else if (other.order != null) return false - if (!supportedBindingMethods.contentEquals(other.supportedBindingMethods)) return false - return supportedCryptographicSuites.contentEquals(other.supportedCryptographicSuites) - } - - override fun hashCode(): Int { - var result = format.hashCode() - result = 31 * result + (id?.hashCode() ?: 0) - result = 31 * result + types.contentHashCode() - result = 31 * result + (credentialSubject?.hashCode() ?: 0) - result = 31 * result + (docType?.hashCode() ?: 0) - result = 31 * result + (claims?.hashCode() ?: 0) - result = 31 * result + (order?.contentHashCode() ?: 0) - result = 31 * result + supportedBindingMethods.contentHashCode() - result = 31 * result + supportedCryptographicSuites.contentHashCode() - return result - } -} \ No newline at end of file + @SerialName("display") + val display: Collection? = null, +) \ No newline at end of file diff --git a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/SupportedCredentialFormatDefinition.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/SupportedCredentialFormatDefinition.kt new file mode 100644 index 000000000..b679e3b60 --- /dev/null +++ b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/SupportedCredentialFormatDefinition.kt @@ -0,0 +1,33 @@ +package at.asitplus.wallet.lib.oidvci + +import at.asitplus.wallet.lib.oidvci.mdl.RequestedCredentialClaimSpecification +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * OID4VCI: W3C VC: REQUIRED. Object containing the detailed description of the Credential type. + * It consists of at least the following two parameters: `type, `credentialSubject`. + */ +@Serializable +data class SupportedCredentialFormatDefinition( + + /** + * OID4VCI: W3C VC: REQUIRED. JSON array designating the types a certain credential type supports + * according to (VC_DATA), Section 4.3, e.g. `VerifiableCredential`, `UniversityDegreeCredential` + */ + @SerialName("type") + val types: Collection? = null, + + /** + * OID4VCI: + * W3C VC: OPTIONAL. Object containing a list of name/value pairs, where each name identifies + * a claim offered in the Credential. The value can be another such object (nested data structures), or an array + * of such objects + */ + @SerialName("credentialSubject") + val credentialSubject: Map? = null, + + // TODO is present in EUDIW issuer ... but is this really valid? + @SerialName("claims") + val claims: Map? = null, +) \ No newline at end of file diff --git a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/VpFormatsSupported.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/VpFormatsSupported.kt index 4d32d7148..d1a0a60c2 100644 --- a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/VpFormatsSupported.kt +++ b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/VpFormatsSupported.kt @@ -29,26 +29,3 @@ data class VpFormatsSupported( val jsonLinkedData: SupportedAlgorithmsContainer? = null, ) -@Serializable -data class SupportedAlgorithmsContainer( - /** - * OID4VP: An object where the value is an array of case sensitive strings that identify the cryptographic suites - * that are supported. Parties will need to agree upon the meanings of the values used, which may be - * context-specific, e.g. `EdDSA` and `ES256`. - */ - @SerialName("alg_values_supported") - val supportedAlgorithms: Array -) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other == null || this::class != other::class) return false - - other as SupportedAlgorithmsContainer - - return supportedAlgorithms.contentEquals(other.supportedAlgorithms) - } - - override fun hashCode(): Int { - return supportedAlgorithms.contentHashCode() - } -} \ No newline at end of file diff --git a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/WalletService.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/WalletService.kt index a21782b22..016b754dc 100644 --- a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/WalletService.kt +++ b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/WalletService.kt @@ -59,15 +59,28 @@ class WalletService( * Send the result as parameters (either POST or GET) to the server at `/authorize` (or more specific * [IssuerMetadata.authorizationEndpointUrl]) */ - fun createAuthRequest() = AuthenticationRequestParameters( + fun createAuthRequest(credentialIssuer: String? = null) = AuthenticationRequestParameters( responseType = GRANT_TYPE_CODE, clientId = clientId, authorizationDetails = credentialRepresentation.toAuthorizationDetails(), + resource = credentialIssuer, redirectUrl = redirectUrl, ) /** - * Send the result as POST parameters (form-encoded)to the server at `/token` (or more specific + * Send the result as parameters (either POST or GET) to the server at `/authorize` (or more specific + * [IssuerMetadata.authorizationEndpointUrl]) + */ + fun createAuthRequest(scope: String, credentialIssuer: String? = null) = AuthenticationRequestParameters( + responseType = GRANT_TYPE_CODE, + clientId = clientId, + scope = scope, + resource = credentialIssuer, + redirectUrl = redirectUrl, + ) + + /** + * Send the result as POST parameters (form-encoded) to the server at `/token` (or more specific * [IssuerMetadata.tokenEndpointUrl]) */ fun createTokenRequestParameters(code: String) = TokenRequestParameters( @@ -106,7 +119,7 @@ class WalletService( } val proof = CredentialRequestProof( proofType = OpenIdConstants.ProofTypes.JWT, - jwt = proofPayload.serialize() + proof = proofPayload.serialize() ) return KmmResult.success(credentialRepresentation.toCredentialRequestParameters(proof)) } @@ -116,7 +129,9 @@ class WalletService( ConstantIndex.CredentialRepresentation.SD_JWT -> AuthorizationDetails( type = CREDENTIAL_TYPE_OPENID, format = toFormat(), - types = arrayOf(VERIFIABLE_CREDENTIAL) + credentialScheme.vcType, + credentialDefinition = SupportedCredentialFormatDefinition( + types = listOf(VERIFIABLE_CREDENTIAL, credentialScheme.vcType), + ), claims = requestedAttributes?.toRequestedClaims(), ) @@ -124,7 +139,6 @@ class WalletService( type = CREDENTIAL_TYPE_OPENID, format = toFormat(), docType = credentialScheme.isoDocType, - types = arrayOf(credentialScheme.vcType), claims = requestedAttributes?.toRequestedClaims() ) } @@ -155,6 +169,6 @@ class WalletService( private fun ConstantIndex.CredentialRepresentation.toFormat() = when (this) { ConstantIndex.CredentialRepresentation.PLAIN_JWT -> CredentialFormatEnum.JWT_VC - ConstantIndex.CredentialRepresentation.SD_JWT -> CredentialFormatEnum.JWT_VC_SD + ConstantIndex.CredentialRepresentation.SD_JWT -> CredentialFormatEnum.VC_SD_JWT ConstantIndex.CredentialRepresentation.ISO_MDOC -> CredentialFormatEnum.MSO_MDOC } diff --git a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/mdl/ClaimDisplayProperties.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/mdl/ClaimDisplayProperties.kt deleted file mode 100644 index a766db37e..000000000 --- a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/mdl/ClaimDisplayProperties.kt +++ /dev/null @@ -1,20 +0,0 @@ -package at.asitplus.wallet.lib.oidvci.mdl - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -@Serializable -data class ClaimDisplayProperties( - /** - * OID4VCI: OPTIONAL. String value of a display name for the claim. - */ - @SerialName("name") - val name: String? = null, - - /** - * OID4VCI: OPTIONAL. String value that identifies language of this object represented as language tag values - * defined in BCP47 (RFC5646). - */ - @SerialName("locale") - val locale: String? = null, -) \ No newline at end of file diff --git a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/mdl/RequestedCredentialClaimSpecification.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/mdl/RequestedCredentialClaimSpecification.kt index ad5a83165..f6d961d69 100644 --- a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/mdl/RequestedCredentialClaimSpecification.kt +++ b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/mdl/RequestedCredentialClaimSpecification.kt @@ -1,5 +1,6 @@ package at.asitplus.wallet.lib.oidvci.mdl +import at.asitplus.wallet.lib.oidvci.DisplayProperties import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -32,5 +33,5 @@ data class RequestedCredentialClaimSpecification( * There MUST be only one object with the same language identifier. */ @SerialName("display") - val display: ClaimDisplayProperties? = null, + val display: Collection? = null, ) \ No newline at end of file diff --git a/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidvciProcessTest.kt b/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidvciProcessTest.kt index 0f5619246..757b15994 100644 --- a/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidvciProcessTest.kt +++ b/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidvciProcessTest.kt @@ -52,7 +52,7 @@ class OidvciProcessTest : FunSpec({ credentialRepresentation = ConstantIndex.CredentialRepresentation.SD_JWT, ) val credential = runProcess(issuer, client) - credential.format shouldBe CredentialFormatEnum.JWT_VC_SD + credential.format shouldBe CredentialFormatEnum.VC_SD_JWT val serializedCredential = credential.credential serializedCredential.shouldNotBeNull().also { println(it) } @@ -70,7 +70,7 @@ class OidvciProcessTest : FunSpec({ requestedAttributes = listOf("family-name") ) val credential = runProcess(issuer, client) - credential.format shouldBe CredentialFormatEnum.JWT_VC_SD + credential.format shouldBe CredentialFormatEnum.VC_SD_JWT val serializedCredential = credential.credential serializedCredential.shouldNotBeNull().also { println(it) } diff --git a/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/SerializationTest.kt b/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/SerializationTest.kt index 20d771497..fcb287f21 100644 --- a/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/SerializationTest.kt +++ b/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/SerializationTest.kt @@ -8,7 +8,6 @@ import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import io.kotest.matchers.string.shouldContain import io.ktor.http.encodeURLParameter -import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import kotlin.random.Random @@ -20,7 +19,9 @@ class SerializationTest : FunSpec({ authorizationDetails = AuthorizationDetails( type = randomString(), format = CredentialFormatEnum.JWT_VC, - types = arrayOf(VERIFIABLE_CREDENTIAL, randomString()), + credentialDefinition = SupportedCredentialFormatDefinition( + types = listOf(VERIFIABLE_CREDENTIAL, randomString()), + ) ), redirectUrl = randomString(), scope = randomString(), @@ -56,7 +57,7 @@ class SerializationTest : FunSpec({ types = arrayOf(randomString(), randomString()), proof = CredentialRequestProof( proofType = randomString(), - jwt = randomString() + proof = randomString() ) ) diff --git a/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/mdl/SerializationTest.kt b/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/mdl/SerializationTest.kt deleted file mode 100644 index 51ec3faee..000000000 --- a/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/mdl/SerializationTest.kt +++ /dev/null @@ -1,31 +0,0 @@ -package at.asitplus.wallet.lib.oidvci.mdl - -import io.kotest.core.spec.style.FunSpec -import io.kotest.matchers.shouldBe -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json - -class SerializationTest : FunSpec({ - - fun createClaimDisplayProperties() = ClaimDisplayProperties( - name = "Given Name", - locale = "de-AT", - ) - - fun createRequestedCredentialClaimSpecification() = RequestedCredentialClaimSpecification( - valueType = "string", - display = createClaimDisplayProperties(), - ) - - test("createAuthorizationRequest as GET") { - val claimDisplayProperties = createClaimDisplayProperties() - - val serializedClaim = Json.encodeToString(claimDisplayProperties) - - serializedClaim.filter { - !it.isWhitespace() - } shouldBe "{ \"name\": \"Given Name\", \"locale\": \"de-AT\" }".filter { - !it.isWhitespace() - } - } -}) \ No newline at end of file From f9a19483649edf5595fbf4211897a1d985a8157a Mon Sep 17 00:00:00 2001 From: Christian Kollmann Date: Tue, 16 Apr 2024 08:49:47 +0200 Subject: [PATCH 03/18] Implement data classes for OID4VCI draft 13 --- CHANGELOG.md | 1 + .../wallet/lib/oidc/OpenIdConstants.kt | 12 +++ .../wallet/lib/oidvci/AuthorizationDetails.kt | 13 +++ .../lib/oidvci/CredentialRequestParameters.kt | 93 ++++++++-------- .../lib/oidvci/CredentialRequestProof.kt | 14 ++- .../wallet/lib/oidvci/IssuerMetadata.kt | 2 +- .../wallet/lib/oidvci/IssuerService.kt | 101 +++++++++++++++--- .../lib/oidvci/TokenRequestParameters.kt | 38 ++++--- .../lib/oidvci/TokenResponseParameters.kt | 9 +- .../wallet/lib/oidvci/WalletService.kt | 41 ++++++- .../wallet/lib/oidvci/SerializationTest.kt | 3 +- 11 files changed, 235 insertions(+), 92 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ea796da6..b3e490280 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ Release 3.6.0: - Ktor 2.3.10 - Auto-publish version catalogs - Implement OpenID for Verifiable Credential Issuance draft 13, from 2024-02-08 + - TODO document changes in `IssuerService` and `WalletService` Release 3.5.0: - Kotlin 1.9.23 diff --git a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/OpenIdConstants.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/OpenIdConstants.kt index 1ae140d9f..0cd9ad05f 100644 --- a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/OpenIdConstants.kt +++ b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/OpenIdConstants.kt @@ -8,6 +8,8 @@ object OpenIdConstants { const val GRANT_TYPE_CODE = "code" + const val GRANT_TYPE_PRE_AUTHORIZED_CODE = "urn:ietf:params:oauth:grant-type:pre-authorized_code" + const val TOKEN_PREFIX_BEARER = "Bearer " const val TOKEN_TYPE_BEARER = "bearer" @@ -36,6 +38,11 @@ object OpenIdConstants { */ const val JWT = "jwt" + /** + * Proof type in [at.asitplus.wallet.lib.oidvci.CredentialRequestProof] + */ + const val CWT = "cwt" + const val JWT_HEADER_TYPE = "openid4vci-proof+jwt" } @@ -145,6 +152,11 @@ object OpenIdConstants { */ const val INVALID_REQUEST = "invalid_request" + /** + * Invalid grant: `invalid_grant` + */ + const val INVALID_GRANT = "invalid_grant" + /** * Invalid or missing proofs in OpenId4VCI: `invalid_or_missing_proof` */ diff --git a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/AuthorizationDetails.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/AuthorizationDetails.kt index 8da516d3c..fc693b274 100644 --- a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/AuthorizationDetails.kt +++ b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/AuthorizationDetails.kt @@ -69,4 +69,17 @@ data class AuthorizationDetails( */ @SerialName("locations") val locations: Collection? = null, + + /** + * OID4VCI: OPTIONAL. Array of strings, each uniquely identifying a Credential that can be issued using the Access + * Token returned in this response. Each of these Credentials corresponds to the same entry in the + * [IssuerMetadata.supportedCredentialConfigurations] but can contain different claim values or a + * different subset of claims within the claims set identified by that Credential type. + * This parameter can be used to simplify the Credential Request, as defined in Section 7.2, where the + * `credential_identifier` parameter replaces the format parameter and any other Credential format-specific + * parameters in the Credential Request. When received, the Wallet MUST use these values together with an Access + * Token in subsequent Credential Requests. + */ + @SerialName("credential_identifiers") + val credentialIdentifiers: Collection? = null, ) \ No newline at end of file diff --git a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CredentialRequestParameters.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CredentialRequestParameters.kt index 448be679f..6dc4e0aab 100644 --- a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CredentialRequestParameters.kt +++ b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CredentialRequestParameters.kt @@ -7,74 +7,69 @@ import kotlinx.serialization.Serializable @Serializable data class CredentialRequestParameters( /** - * OID4VCI: + * OID4VCI: REQUIRED when the `credential_identifiers` parameter was not returned from the Token Response. + * It MUST NOT be used otherwise. It is a String that determines the format of the Credential to be issued, + * which may determine the type and any other information related to the Credential to be issued. + * Credential Format Profiles consist of the Credential format specific parameters that are defined in Appendix A. + * When this parameter is used, the [credentialIdentifier] Credential Request parameter MUST NOT be present. * REQUIRED. Format of the Credential to be issued. This Credential format identifier determines further parameters * required to determine the type and (optionally) the content of the credential to be issued. */ @SerialName("format") - val format: CredentialFormatEnum, + val format: CredentialFormatEnum? = null, /** - * OID4VCI: - * W3C VC: REQUIRED. - * - * JSON array designating the types a certain credential type supports according to (VC_DATA), - * Section 4.3. - * e.g. `VerifiableCredential`, `UniversityDegreeCredential`. + * OID4VCI: REQUIRED when `credential_identifiers` parameter was returned from the Token Response. + * It MUST NOT be used otherwise. It is a String that identifies a Credential that is being requested to be issued. + * When this parameter is used, the [format] parameter and any other Credential format specific parameters such + * as those defined in Appendix A MUST NOT be present. */ - @SerialName("types") - val types: Array = arrayOf(), + @SerialName("credential_identifier") + val credentialIdentifier: String? = null, /** - * OID4VCI: - * ISO mDL: REQUIRED. - * - * JSON string identifying the credential type. + * OID4VCI: OPTIONAL. Object containing information for encrypting the Credential Response. If this request element + * is not present, the corresponding credential response returned is not encrypted. + */ + @SerialName("credential_response_encryption") + val credentialResponseEncryption: SupportedAlgorithmsContainer? = null, + + /** + * OID4VCI: ISO mDL: OPTIONAL. This claim contains the type value the Wallet requests authorization for at the + * Credential Issuer. It MUST only be present if the [format] claim is present. It MUST not be present otherwise. */ @SerialName("doctype") val docType: String? = null, /** - * OID4VCI: - * ISO mDL: OPTIONAL. - * - * A JSON object containing a list of key value pairs, - * where the key is a certain namespace as defined in [ISO.18013-5] (or any profile of it), - * and the value is a JSON object. This object also contains a list of key value pairs, - * where the key is a claim that is defined in the respective namespace and is offered in the Credential. + * OID4VCI: ISO mDL: OPTIONAL. Object as defined in Appendix A.3.2 excluding the `display` and `value_type` + * parameters. The `mandatory` parameter here is used by the Wallet to indicate to the Issuer that it only accepts + * Credential(s) issued with those claim(s). */ @SerialName("claims") val claims: Map>? = null, /** - * OID4VCI: - * OPTIONAL. JSON object containing proof of possession of the key material the issued Credential shall be bound to. - * The specification envisions use of different types of proofs for different cryptographic schemes. The proof - * object MUST contain a proof_type claim of type JSON string denoting the concrete proof type. This type determines - * the further claims in the proof object and its respective processing rules. + * OID4VCI: W3C VC: OPTIONAL. Object containing a detailed description of the Credential consisting of the + * following parameters. see [SupportedCredentialFormatDefinition]. */ - @SerialName("proof") - val proof: CredentialRequestProof? = null, -) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other == null || this::class != other::class) return false - - other as CredentialRequestParameters + @SerialName("credential_definition") + val credentialDefinition: SupportedCredentialFormatDefinition? = null, - if (format != other.format) return false - if (!types.contentEquals(other.types)) return false - if (docType != other.docType) return false - if (claims != other.claims) return false - return proof == other.proof - } + /** + * OID4VCI: IETF SD-JWT VC: REQUIRED. String as defined in Appendix A.3.2. This claim contains the type values + * the Wallet requests authorization for at the Credential Issuer. + * It MUST only be present if the [format] claim is present. It MUST not be present otherwise. + */ + @SerialName("vct") + val sdJwtVcType: String? = null, - override fun hashCode(): Int { - var result = format.hashCode() - result = 31 * result + types.contentHashCode() - result = 31 * result + (docType?.hashCode() ?: 0) - result = 31 * result + (claims?.hashCode() ?: 0) - result = 31 * result + (proof?.hashCode() ?: 0) - return result - } -} \ No newline at end of file + /** + * OID4VCI: OPTIONAL. Object containing the proof of possession of the cryptographic key material the issued + * Credential would be bound to. The proof object is REQUIRED if the [SupportedCredentialFormat.supportedProofTypes] + * parameter is non-empty and present in the [IssuerMetadata.supportedCredentialConfigurations] for the requested + * Credential. + */ + @SerialName("proof") + val proof: CredentialRequestProof? = null, +) \ No newline at end of file diff --git a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CredentialRequestProof.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CredentialRequestProof.kt index 8132c4114..a0c954f74 100644 --- a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CredentialRequestProof.kt +++ b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CredentialRequestProof.kt @@ -12,8 +12,16 @@ data class CredentialRequestProof( val proofType: String, /** - * See OID4VCI Proof Types for contents. + * OID4VCI: A JWT (RFC7519) is used as proof of possession. When [proofType] is `jwt`, a proof object MUST include + * a `jwt` claim containing a JWT defined in Section 7.2.1.1. */ - @SerialName("proof") - val proof: String, + @SerialName("jwt") + val jwt: String? = null, + + /** + * OID4VCI: A CWT (RFC8392) is used as proof of possession. When [proofType] is `cwt`, a proof object MUST include + * a `cwt` claim containing a CWT defined in Section 7.2.1.3. + */ + @SerialName("cwt") + val cwt: String? = null, ) \ No newline at end of file diff --git a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/IssuerMetadata.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/IssuerMetadata.kt index 65c186d79..50f2c5bf3 100644 --- a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/IssuerMetadata.kt +++ b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/IssuerMetadata.kt @@ -104,7 +104,7 @@ data class IssuerMetadata( /** * OID4VCI: OPTIONAL. Boolean value specifying whether the Credential Issuer supports returning - * `credential_identifiers` parameter in the `authorization_details` Token Response parameter, with `true` + * [AuthorizationDetails.credentialIdentifiers] in the Token Response parameter, with `true` * indicating support. If omitted, the default value is `false`. */ @SerialName("credential_identifiers_supported") diff --git a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/IssuerService.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/IssuerService.kt index 940c436a4..d86d25f8b 100644 --- a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/IssuerService.kt +++ b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/IssuerService.kt @@ -11,12 +11,15 @@ import at.asitplus.wallet.lib.oidc.AuthenticationRequestParameters import at.asitplus.wallet.lib.oidc.OpenIdConstants import at.asitplus.wallet.lib.oidc.OpenIdConstants.BINDING_METHOD_COSE_KEY import at.asitplus.wallet.lib.oidc.OpenIdConstants.Errors +import at.asitplus.wallet.lib.oidc.OpenIdConstants.GRANT_TYPE_CODE +import at.asitplus.wallet.lib.oidc.OpenIdConstants.GRANT_TYPE_PRE_AUTHORIZED_CODE import at.asitplus.wallet.lib.oidc.OpenIdConstants.PREFIX_DID_KEY import at.asitplus.wallet.lib.oidc.OpenIdConstants.ProofTypes import at.asitplus.wallet.lib.oidc.OpenIdConstants.TOKEN_PREFIX_BEARER import at.asitplus.wallet.lib.oidc.OpenIdConstants.TOKEN_TYPE_BEARER import at.asitplus.wallet.lib.oidc.OpenIdConstants.URN_TYPE_JWK_THUMBPRINT import at.asitplus.wallet.lib.oidvci.mdl.RequestedCredentialClaimSpecification +import com.benasher44.uuid.uuid4 import io.ktor.http.* import io.matthewnelson.encoding.core.Encoder.Companion.encodeToString import kotlin.coroutines.cancellation.CancellationException @@ -48,7 +51,7 @@ class IssuerService( */ private val clientNonceService: NonceService = DefaultNonceService(), /** - * Used as [IssuerMetadata.authorizationServer]. + * Used as [IssuerMetadata.authorizationServers]. */ private val authorizationServer: String? = null, /** @@ -86,6 +89,7 @@ class IssuerService( supportedCredentialConfigurations = mutableMapOf().apply { credentialSchemes.forEach { putAll(it.toSupportedCredentialFormat()) } }, + supportsCredentialIdentifiers = true, displayProperties = credentialSchemes.map { DisplayProperties(it.vcType, "en") } ) } @@ -101,7 +105,7 @@ class IssuerService( supportedBindingMethods = listOf(BINDING_METHOD_COSE_KEY), supportedSigningAlgorithms = issuer.cryptoAlgorithms.map { it.toJwsAlgorithm().identifier }, ), - this.vcType to SupportedCredentialFormat( + "$vcType-${CredentialFormatEnum.JWT_VC.text}" to SupportedCredentialFormat( format = CredentialFormatEnum.JWT_VC, credentialDefinition = SupportedCredentialFormatDefinition( types = listOf(VERIFIABLE_CREDENTIAL, vcType), @@ -110,7 +114,7 @@ class IssuerService( supportedBindingMethods = listOf(PREFIX_DID_KEY, URN_TYPE_JWK_THUMBPRINT), supportedSigningAlgorithms = issuer.cryptoAlgorithms.map { it.toJwsAlgorithm().identifier }, ), - this.vcType to SupportedCredentialFormat( + "$vcType-${CredentialFormatEnum.VC_SD_JWT.text}" to SupportedCredentialFormat( format = CredentialFormatEnum.VC_SD_JWT, sdJwtVcType = vcType, claims = mapOf( @@ -122,12 +126,37 @@ class IssuerService( ) ) + /** + * Offer all [credentialSchemes] to clients. + * Callers may need to transport this in [CredentialOfferUrlParameters] to (HTTPS) clients. + */ + fun credentialOffer(): CredentialOffer = CredentialOffer( + credentialIssuer = publicContext, + configurationIds = credentialSchemes.map { it.vcType }, + grants = CredentialOfferGrants( + authorizationCode = CredentialOfferGrantsAuthCode( + issuerState = uuid4().toString(), // TODO remember this state, for subsequent requests from the Wallet + authorizationServer = publicContext // may need to support external AS? + ), + preAuthorizedCode = CredentialOfferGrantsPreAuthCode( + preAuthorizedCode = codeService.provideCode(), + transactionCode = CredentialOfferGrantsPreAuthCodeTransactionCode( + inputMode = "numeric", + length = 16, + ), + authorizationServer = publicContext // may need to support external AS?, + ) + ) + ) + /** * Send this result as HTTP Header `Location` in a 302 response to the client. * @return URL build from client's `redirect_uri` with a `code` query parameter containing a fresh authorization * code from [codeService]. */ fun authorize(params: AuthenticationRequestParameters): String? { + // TODO return parameters here directly, callers need to build the URL + // TODO also need to store the `scope` or `authorization_details`, i.e. may respond with `invalid_scope` here! val builder = URLBuilder(params.redirectUrl ?: return null) builder.parameters.append(OpenIdConstants.GRANT_TYPE_CODE, codeService.provideCode()) return builder.buildString() @@ -139,13 +168,35 @@ class IssuerService( */ @Throws(OAuth2Exception::class) fun token(params: TokenRequestParameters): TokenResponseParameters { - if (!codeService.verifyCode(params.code)) - throw OAuth2Exception(Errors.INVALID_CODE) + // TODO This is part of the Authorization Server + when (params.grantType) { + GRANT_TYPE_CODE -> if (params.code == null || !codeService.verifyCode(params.code)) + throw OAuth2Exception(Errors.INVALID_CODE) + + GRANT_TYPE_PRE_AUTHORIZED_CODE -> if (params.preAuthorizedCode == null || !codeService.verifyCode(params.preAuthorizedCode)) + throw OAuth2Exception(Errors.INVALID_GRANT) + + else -> + throw OAuth2Exception("No valid grant_type: ${params.grantType}") + } + if (params.authorizationDetails != null) { + // TODO verify + // params.authorizationDetails.claims + params.authorizationDetails.credentialIdentifiers?.forEach { + if (!credentialSchemes.map { it.vcType }.contains(it)) { + throw OAuth2Exception(Errors.INVALID_GRANT) + } + } + } return TokenResponseParameters( accessToken = tokenService.provideToken(), tokenType = TOKEN_TYPE_BEARER, expires = 3600, - clientNonce = clientNonceService.provideNonce() + clientNonce = clientNonceService.provideNonce(), + authorizationDetails = params.authorizationDetails?.let { + // TODO supported credential identifiers! + listOf(it) + } ) } @@ -160,19 +211,21 @@ class IssuerService( */ @Throws(OAuth2Exception::class, CancellationException::class) suspend fun credential( - authorizationHeader: String, + authorizationHeader: String, // TODO Change interface to only contain access token, nothing else params: CredentialRequestParameters ): CredentialResponseParameters { if (!tokenService.verifyToken(authorizationHeader.removePrefix(TOKEN_PREFIX_BEARER))) throw OAuth2Exception(Errors.INVALID_TOKEN) val proof = params.proof ?: throw OAuth2Exception(Errors.INVALID_REQUEST) - if (proof.proofType != ProofTypes.JWT) + // TODO also support `cwt` as proof + if (proof.proofType != ProofTypes.JWT || proof.jwt == null) throw OAuth2Exception(Errors.INVALID_PROOF) - val jwsSigned = JwsSigned.parse(proof.proof) + val jwsSigned = JwsSigned.parse(proof.jwt) ?: throw OAuth2Exception(Errors.INVALID_PROOF) val jwt = JsonWebToken.deserialize(jwsSigned.payload.decodeToString()).getOrNull() ?: throw OAuth2Exception(Errors.INVALID_PROOF) + // TODO verify required claims in OID4VCI 7.2.1.1 if (jwt.nonce == null || !clientNonceService.verifyAndRemoveNonce(jwt.nonce!!)) throw OAuth2Exception(Errors.INVALID_PROOF) if (jwsSigned.header.type != ProofTypes.JWT_HEADER_TYPE) @@ -180,15 +233,33 @@ class IssuerService( val subjectPublicKey = jwsSigned.header.publicKey ?: throw OAuth2Exception(Errors.INVALID_PROOF) - val issuedCredentialResult = issuer.issueCredential( - subjectPublicKey = subjectPublicKey, - attributeTypes = params.types.toList(), - representation = params.format.toRepresentation(), - claimNames = params.claims?.map { it.value.keys }?.flatten()?.ifEmpty { null } - ) + val issuedCredentialResult = if (params.format != null) { + issuer.issueCredential( + subjectPublicKey = subjectPublicKey, + attributeTypes = listOfNotNull(params.sdJwtVcType, params.docType) + + (params.credentialDefinition?.types?.toList() ?: listOf()), + representation = params.format.toRepresentation(), + claimNames = params.claims?.map { it.value.keys }?.flatten()?.ifEmpty { null } + ) + } else if (params.credentialIdentifier != null) { + // TODO this delimiter is probably not safe + val representation = CredentialFormatEnum.parse(params.credentialIdentifier.substringAfterLast("-")) + ?: throw OAuth2Exception(Errors.INVALID_REQUEST) + // TODO what to do in case of ISO, look at string constants from EUDIW + val vcType = params.credentialIdentifier.substringBeforeLast("-") + issuer.issueCredential( + subjectPublicKey = subjectPublicKey, + attributeTypes = listOf(vcType), + representation = representation.toRepresentation(), + claimNames = params.claims?.map { it.value.keys }?.flatten()?.ifEmpty { null } + ) + } else { + throw OAuth2Exception(Errors.INVALID_REQUEST) + } if (issuedCredentialResult.successful.isEmpty()) { throw OAuth2Exception(Errors.INVALID_REQUEST) } + // TODO Implement Batch Credential Endpoint for more than one credential response return issuedCredentialResult.successful.first().toCredentialResponseParameters() } diff --git a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/TokenRequestParameters.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/TokenRequestParameters.kt index b65425220..b2fd4abd7 100644 --- a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/TokenRequestParameters.kt +++ b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/TokenRequestParameters.kt @@ -6,18 +6,18 @@ import kotlinx.serialization.Serializable @Serializable data class TokenRequestParameters( /** - * RFC6749: - * REQUIRED. Value MUST be set to "authorization_code". + * RFC6749: REQUIRED. Value MUST be set to `authorization_code`. + * + * OID4VCI: May be `urn:ietf:params:oauth:grant-type:pre-authorized_code`. */ @SerialName("grant_type") val grantType: String, /** - * RFC6749: - * REQUIRED. The authorization code received from the authorization server. + * RFC6749: REQUIRED. The authorization code received from the authorization server. */ @SerialName("code") - val code: String, + val code: String? = null, /** * RFC6749: @@ -35,24 +35,30 @@ data class TokenRequestParameters( val clientId: String, /** - * OID4VCI: - * CONDITIONAL. The code representing the authorization to obtain Credentials of a certain type. + * OID4VP: TODO Definition + * RFC9396 + */ + @SerialName("authorization_details") + val authorizationDetails: AuthorizationDetails? = null, + + /** + * OID4VCI: The code representing the authorization to obtain Credentials of a certain type. + * This parameter MUST be present if [grantType] is `urn:ietf:params:oauth:grant-type:pre-authorized_code`. */ @SerialName("pre-authorized_code") val preAuthorizedCode: String? = null, /** - * TODO + * OID4VCI: OPTIONAL. String value containing a Transaction Code. This value MUST be present if a `tx_code` object + * was present in the Credential Offer (including if the object was empty). + * This parameter MUST only be used if the [grantType] is `urn:ietf:params:oauth:grant-type:pre-authorized_code`. */ - @SerialName("code_verifier") - val codeVerifier: String? = null, + @SerialName("tx_code") + val transactionCode: CredentialOfferGrantsPreAuthCodeTransactionCode? = null, /** - * OID4VCI: - * OPTIONAL. String value containing a user PIN. This value MUST be present if user_pin_required was set to true in - * the Credential Offer. The string value MUST consist of maximum 8 numeric characters (the numbers 0 - 9). - * This parameter MUST only be used, if the grant_type is urn:ietf:params:oauth:grant-type:pre-authorized_code. + * TODO implement, for RFC 7636 Proof Key for Code Exchange! */ - @SerialName("user_pin") - val userPin: String? = null, + @SerialName("code_verifier") + val codeVerifier: String? = null, ) \ No newline at end of file diff --git a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/TokenResponseParameters.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/TokenResponseParameters.kt index 3174fc2fd..3c3a1523e 100644 --- a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/TokenResponseParameters.kt +++ b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/TokenResponseParameters.kt @@ -53,7 +53,7 @@ data class TokenResponseParameters( /** * OID4VCI: - * OPTIONAL JSON integer denoting the lifetime in seconds of the c_nonce. + * OPTIONAL JSON integer denoting the lifetime in seconds of the [clientNonce]. */ @SerialName("c_nonce_expires_in") val clientNonceExpiresIn: Int? = null, // TODO Duration @@ -74,4 +74,11 @@ data class TokenResponseParameters( */ @SerialName("interval") val interval: Int? = null, // TODO Duration + + /** + * OID4VP: REQUIRED when `authorization_details` parameter is used to request issuance of a certain Credential type. + * It MUST NOT be used otherwise. + */ + @SerialName("authorization_details") + val authorizationDetails: Collection? = null, ) \ No newline at end of file diff --git a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/WalletService.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/WalletService.kt index 016b754dc..97a07de78 100644 --- a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/WalletService.kt +++ b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/WalletService.kt @@ -14,7 +14,9 @@ import at.asitplus.wallet.lib.oidc.AuthenticationRequestParameters import at.asitplus.wallet.lib.oidc.OpenIdConstants import at.asitplus.wallet.lib.oidc.OpenIdConstants.CREDENTIAL_TYPE_OPENID import at.asitplus.wallet.lib.oidc.OpenIdConstants.GRANT_TYPE_CODE +import at.asitplus.wallet.lib.oidc.OpenIdConstants.GRANT_TYPE_PRE_AUTHORIZED_CODE import at.asitplus.wallet.lib.oidvci.mdl.RequestedCredentialClaimSpecification +import com.benasher44.uuid.uuid4 import kotlinx.datetime.Clock /** @@ -59,8 +61,12 @@ class WalletService( * Send the result as parameters (either POST or GET) to the server at `/authorize` (or more specific * [IssuerMetadata.authorizationEndpointUrl]) */ - fun createAuthRequest(credentialIssuer: String? = null) = AuthenticationRequestParameters( + fun createAuthRequest( + credentialIssuer: String? = null, + state: String? = uuid4().toString() + ) = AuthenticationRequestParameters( responseType = GRANT_TYPE_CODE, + state = state, clientId = clientId, authorizationDetails = credentialRepresentation.toAuthorizationDetails(), resource = credentialIssuer, @@ -71,12 +77,18 @@ class WalletService( * Send the result as parameters (either POST or GET) to the server at `/authorize` (or more specific * [IssuerMetadata.authorizationEndpointUrl]) */ - fun createAuthRequest(scope: String, credentialIssuer: String? = null) = AuthenticationRequestParameters( + fun createAuthRequest( + scope: String, + credentialIssuer: String? = null, + state: String? = uuid4().toString() + ) = AuthenticationRequestParameters( responseType = GRANT_TYPE_CODE, + state = state, clientId = clientId, scope = scope, resource = credentialIssuer, redirectUrl = redirectUrl, + // TODO also code_challenge and code_challenge_method ) /** @@ -88,6 +100,22 @@ class WalletService( code = code, redirectUrl = redirectUrl, clientId = clientId, + authorizationDetails = credentialRepresentation.toAuthorizationDetails(), + + ) + + /** + * Send the result as POST parameters (form-encoded) to the server at `/token` (or more specific + * [IssuerMetadata.tokenEndpointUrl]) + */ + fun createTokenRequestParameters(credentialOffer: CredentialOffer) = TokenRequestParameters( + grantType = GRANT_TYPE_PRE_AUTHORIZED_CODE, + // TODO Verify if `redirect_uri` and `client_id` are even needed + redirectUrl = redirectUrl, + clientId = clientId, + authorizationDetails = credentialRepresentation.toAuthorizationDetails(), + transactionCode = credentialOffer.grants?.preAuthorizedCode?.transactionCode, + preAuthorizedCode = credentialOffer.grants?.preAuthorizedCode?.preAuthorizedCode, ) /** @@ -107,6 +135,7 @@ class WalletService( type = OpenIdConstants.ProofTypes.JWT_HEADER_TYPE, ), payload = JsonWebToken( + // TODO Set correct parameters issuer = clientId, audience = issuerMetadata.credentialIssuer, issuedAt = Clock.System.now(), @@ -119,7 +148,8 @@ class WalletService( } val proof = CredentialRequestProof( proofType = OpenIdConstants.ProofTypes.JWT, - proof = proofPayload.serialize() + // TODO support "cwt" + jwt = proofPayload.serialize() ) return KmmResult.success(credentialRepresentation.toCredentialRequestParameters(proof)) } @@ -149,7 +179,9 @@ class WalletService( ConstantIndex.CredentialRepresentation.SD_JWT -> CredentialRequestParameters( format = toFormat(), claims = requestedAttributes?.toRequestedClaims(), - types = arrayOf(VERIFIABLE_CREDENTIAL) + credentialScheme.vcType, + credentialDefinition = SupportedCredentialFormatDefinition( + types = listOf(VERIFIABLE_CREDENTIAL) + credentialScheme.vcType, + ), proof = proof ) @@ -157,7 +189,6 @@ class WalletService( format = toFormat(), docType = credentialScheme.isoDocType, claims = requestedAttributes?.toRequestedClaims(), - types = arrayOf(credentialScheme.vcType), proof = proof ) } diff --git a/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/SerializationTest.kt b/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/SerializationTest.kt index fcb287f21..55efcf602 100644 --- a/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/SerializationTest.kt +++ b/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/SerializationTest.kt @@ -37,7 +37,6 @@ class SerializationTest : FunSpec({ clientId = randomString(), preAuthorizedCode = randomString(), codeVerifier = randomString(), - userPin = randomString(), ) fun createTokenResponse() = TokenResponseParameters( @@ -57,7 +56,7 @@ class SerializationTest : FunSpec({ types = arrayOf(randomString(), randomString()), proof = CredentialRequestProof( proofType = randomString(), - proof = randomString() + jwt = randomString() ) ) From 69441407cdbf6c6c37f7273077fd05be49fa9a36 Mon Sep 17 00:00:00 2001 From: Christian Kollmann Date: Tue, 16 Apr 2024 10:08:22 +0200 Subject: [PATCH 04/18] OID4VCI: Implement RFC 7636 Proof Key for Code Exchange --- CHANGELOG.md | 3 ++ .../oidc/AuthenticationRequestParameters.kt | 13 +++++ .../lib/oidc/AuthenticationResponseResult.kt | 19 +++++++ .../wallet/lib/oidc/OidcSiopWallet.kt | 22 ++------ .../wallet/lib/oidc/OpenIdConstants.kt | 2 + .../lib/oidvci/CredentialRequestProof.kt | 2 +- .../wallet/lib/oidvci/IssuerService.kt | 46 +++++++++++++---- .../lib/oidvci/TokenRequestParameters.kt | 3 +- .../wallet/lib/oidvci/WalletService.kt | 51 +++++++++++++++---- .../wallet/lib/oidc/OidcSiopInteropTest.kt | 7 +-- .../lib/oidc/OidcSiopIsoProtocolTest.kt | 2 +- .../wallet/lib/oidc/OidcSiopProtocolTest.kt | 22 ++++---- .../lib/oidc/OidcSiopSdJwtProtocolTest.kt | 6 +-- .../wallet/lib/oidvci/OidvciProcessTest.kt | 11 ++-- .../wallet/lib/oidvci/SerializationTest.kt | 10 ++-- 15 files changed, 149 insertions(+), 70 deletions(-) create mode 100644 vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/AuthenticationResponseResult.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index b3e490280..07f8555d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,9 @@ Release 3.6.0: - Ktor 2.3.10 - Auto-publish version catalogs - Implement OpenID for Verifiable Credential Issuance draft 13, from 2024-02-08 + - Implement RFC 7636 Proof Key for Code Exchange for OpenID for Verifiable Credential Issuance implementations, i.e. `IssuerService` and `WalletService` + - `IssuerService`: Make public API functions suspending + - `WalletService`: Make public API functions suspending - TODO document changes in `IssuerService` and `WalletService` Release 3.5.0: diff --git a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/AuthenticationRequestParameters.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/AuthenticationRequestParameters.kt index 99d947f25..8faafd18c 100644 --- a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/AuthenticationRequestParameters.kt +++ b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/AuthenticationRequestParameters.kt @@ -235,6 +235,19 @@ data class AuthenticationRequestParameters( */ @SerialName("resource") val resource: String? = null, + + /** + * RFC7636: A challenge derived from the code verifier that is sent in the authorization request, to be verified + * against later. + */ + @SerialName("code_challenge") + val codeChallenge: String? = null, + + /** + * RFC7636: A method that was used to derive code challenge. + */ + @SerialName("code_challenge_method") + val codeChallengeMethod: String? = null, ) { fun serialize() = jsonSerializer.encodeToString(this) diff --git a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/AuthenticationResponseResult.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/AuthenticationResponseResult.kt new file mode 100644 index 000000000..64d6ab4f8 --- /dev/null +++ b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/AuthenticationResponseResult.kt @@ -0,0 +1,19 @@ +package at.asitplus.wallet.lib.oidc + +/** + * Possible outcomes of creating the OIDC Authentication Response + */ +sealed class AuthenticationResponseResult { + /** + * Wallet returns the [AuthenticationResponseParameters] as form parameters, which shall be posted to + * `redirect_uri` of the Relying Party, i.e. clients should execute that POST with [params] to [url]. + */ + data class Post(val url: String, val params: Map) : AuthenticationResponseResult() + + /** + * Wallet returns the [AuthenticationResponseParameters] as fragment parameters appended to the + * `redirect_uri` of the Relying Party, i.e. clients should simply open the [url]. The [params] are also included + * for further use. + */ + data class Redirect(val url: String, val params: AuthenticationResponseParameters) : AuthenticationResponseResult() +} \ No newline at end of file diff --git a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopWallet.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopWallet.kt index 74b30920b..e3571abbb 100644 --- a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopWallet.kt +++ b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopWallet.kt @@ -85,23 +85,6 @@ class OidcSiopWallet( ) } - /** - * Possible outcomes of creating the OIDC Authentication Response - */ - sealed class AuthenticationResponseResult { - /** - * Wallet returns the [AuthenticationResponseParameters] as form parameters, which shall be posted to - * `redirect_uri` of the Relying Party, i.e. clients should execute that POST with [params] to [url]. - */ - data class Post(val url: String, val params: Map) : AuthenticationResponseResult() - - /** - * Wallet returns the [AuthenticationResponseParameters] as fragment parameters appended to the - * `redirect_uri` of the Relying Party, i.e. clients should simply open the [url]. - */ - data class Redirect(val url: String) : AuthenticationResponseResult() - } - val metadata: IssuerMetadata by lazy { IssuerMetadata( issuer = clientId, @@ -246,7 +229,7 @@ class OidcSiopWallet( } } .buildString() - KmmResult.success(AuthenticationResponseResult.Redirect(url)) + KmmResult.success(AuthenticationResponseResult.Redirect(url, responseParams)) } else -> { @@ -256,7 +239,7 @@ class OidcSiopWallet( val url = URLBuilder(request.redirectUrl) .apply { encodedFragment = responseParams.encodeToParameters().formUrlEncode() } .buildString() - KmmResult.success(AuthenticationResponseResult.Redirect(url)) + KmmResult.success(AuthenticationResponseResult.Redirect(url, responseParams)) } } }, @@ -277,6 +260,7 @@ class OidcSiopWallet( return KmmResult.failure(OAuth2Exception(Errors.INVALID_REQUEST)) .also { Napier.w("client_id_scheme is redirect_uri, but metadata is not set") } } + // TODO implement x509_san_dns, x509_san_uri, as implemented by EUDI verifier val clientMetadata = params.clientMetadata ?: params.clientMetadataUri?.let { uri -> remoteResourceRetriever.invoke(uri)?.let { RelyingPartyMetadata.deserialize(it) } diff --git a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/OpenIdConstants.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/OpenIdConstants.kt index 0cd9ad05f..d36ba2ff9 100644 --- a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/OpenIdConstants.kt +++ b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/OpenIdConstants.kt @@ -26,6 +26,8 @@ object OpenIdConstants { const val SCOPE_PROFILE = "profile" + const val CODE_CHALLENGE_METHOD_SHA256 = "S256" + /** * To be used in [at.asitplus.wallet.lib.oidvci.AuthorizationDetails.type] */ diff --git a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CredentialRequestProof.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CredentialRequestProof.kt index a0c954f74..f87cd3724 100644 --- a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CredentialRequestProof.kt +++ b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CredentialRequestProof.kt @@ -6,7 +6,7 @@ import kotlinx.serialization.Serializable @Serializable data class CredentialRequestProof( /** - * OID4VCI: e.g. `jwt`, or `cwt`, or `ldp_vp`. + * OID4VCI: e.g. `jwt`, or `cwt`, or `ldp_vp`. See [at.asitplus.wallet.lib.oidc.OpenIdConstants.ProofTypes]. */ @SerialName("proof_type") val proofType: String, diff --git a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/IssuerService.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/IssuerService.kt index d86d25f8b..8d120a724 100644 --- a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/IssuerService.kt +++ b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/IssuerService.kt @@ -7,8 +7,10 @@ import at.asitplus.crypto.datatypes.jws.toJwsAlgorithm import at.asitplus.wallet.lib.agent.Issuer import at.asitplus.wallet.lib.data.ConstantIndex import at.asitplus.wallet.lib.data.VcDataModelConstants.VERIFIABLE_CREDENTIAL +import at.asitplus.wallet.lib.iso.sha256 import at.asitplus.wallet.lib.oidc.AuthenticationRequestParameters -import at.asitplus.wallet.lib.oidc.OpenIdConstants +import at.asitplus.wallet.lib.oidc.AuthenticationResponseParameters +import at.asitplus.wallet.lib.oidc.AuthenticationResponseResult import at.asitplus.wallet.lib.oidc.OpenIdConstants.BINDING_METHOD_COSE_KEY import at.asitplus.wallet.lib.oidc.OpenIdConstants.Errors import at.asitplus.wallet.lib.oidc.OpenIdConstants.GRANT_TYPE_CODE @@ -22,6 +24,8 @@ import at.asitplus.wallet.lib.oidvci.mdl.RequestedCredentialClaimSpecification import com.benasher44.uuid.uuid4 import io.ktor.http.* import io.matthewnelson.encoding.core.Encoder.Companion.encodeToString +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlin.coroutines.cancellation.CancellationException /** @@ -75,6 +79,9 @@ class IssuerService( private val credentialEndpointPath: String = "/credential", ) { + private val codeToCodeChallengeMap = mutableMapOf() + private val codeChallengeMutex = Mutex() + /** * Serve this result JSON-serialized under `/.well-known/openid-credential-issuer` */ @@ -150,24 +157,37 @@ class IssuerService( ) /** + * Builds the authentication response. * Send this result as HTTP Header `Location` in a 302 response to the client. * @return URL build from client's `redirect_uri` with a `code` query parameter containing a fresh authorization * code from [codeService]. */ - fun authorize(params: AuthenticationRequestParameters): String? { - // TODO return parameters here directly, callers need to build the URL - // TODO also need to store the `scope` or `authorization_details`, i.e. may respond with `invalid_scope` here! - val builder = URLBuilder(params.redirectUrl ?: return null) - builder.parameters.append(OpenIdConstants.GRANT_TYPE_CODE, codeService.provideCode()) - return builder.buildString() + suspend fun authorize(request: AuthenticationRequestParameters): AuthenticationResponseResult { + // TODO Need to store the `scope` or `authorization_details`, i.e. may respond with `invalid_scope` here! + if (request.redirectUrl == null) + throw OAuth2Exception(Errors.INVALID_REQUEST, "redirect_uri not set") + val code = codeService.provideCode() + val responseParams = AuthenticationResponseParameters( + code = code, + state = request.state, + ) + if (request.codeChallenge != null) { + codeChallengeMutex.withLock { + codeToCodeChallengeMap[code] = request.codeChallenge + } + } + // TODO Also implement POST? + val url = URLBuilder(request.redirectUrl) + .apply { responseParams.encodeToParameters().forEach { this.parameters.append(it.key, it.value) } } + .buildString() + return AuthenticationResponseResult.Redirect(url, responseParams) } /** * Verifies the authorization code sent by the client and issues an access token. * Send this value JSON-serialized back to the client. */ - @Throws(OAuth2Exception::class) - fun token(params: TokenRequestParameters): TokenResponseParameters { + suspend fun token(params: TokenRequestParameters): TokenResponseParameters { // TODO This is part of the Authorization Server when (params.grantType) { GRANT_TYPE_CODE -> if (params.code == null || !codeService.verifyCode(params.code)) @@ -188,6 +208,14 @@ class IssuerService( } } } + if (params.codeVerifier != null) { + val codeChallenge = codeChallengeMutex.withLock { codeToCodeChallengeMap.remove(params.code) } + val codeChallengeCalculated = params.codeVerifier.encodeToByteArray().sha256() + .encodeToString(Base64UrlStrict) + if (codeChallenge != codeChallengeCalculated) { + throw OAuth2Exception(Errors.INVALID_GRANT) + } + } return TokenResponseParameters( accessToken = tokenService.provideToken(), tokenType = TOKEN_TYPE_BEARER, diff --git a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/TokenRequestParameters.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/TokenRequestParameters.kt index b2fd4abd7..87c11a36d 100644 --- a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/TokenRequestParameters.kt +++ b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/TokenRequestParameters.kt @@ -57,7 +57,8 @@ data class TokenRequestParameters( val transactionCode: CredentialOfferGrantsPreAuthCodeTransactionCode? = null, /** - * TODO implement, for RFC 7636 Proof Key for Code Exchange! + * RFC7636: A cryptographically random string that is used to correlate the authorization request to the token + * request. */ @SerialName("code_verifier") val codeVerifier: String? = null, diff --git a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/WalletService.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/WalletService.kt index 97a07de78..189ac68ae 100644 --- a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/WalletService.kt +++ b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/WalletService.kt @@ -1,6 +1,7 @@ package at.asitplus.wallet.lib.oidvci import at.asitplus.KmmResult +import at.asitplus.crypto.datatypes.io.Base64UrlStrict import at.asitplus.crypto.datatypes.jws.JsonWebToken import at.asitplus.crypto.datatypes.jws.JwsHeader import at.asitplus.crypto.datatypes.jws.toJwsAlgorithm @@ -8,16 +9,23 @@ import at.asitplus.wallet.lib.agent.CryptoService import at.asitplus.wallet.lib.agent.DefaultCryptoService import at.asitplus.wallet.lib.data.ConstantIndex import at.asitplus.wallet.lib.data.VcDataModelConstants.VERIFIABLE_CREDENTIAL +import at.asitplus.wallet.lib.iso.sha256 import at.asitplus.wallet.lib.jws.DefaultJwsService import at.asitplus.wallet.lib.jws.JwsService import at.asitplus.wallet.lib.oidc.AuthenticationRequestParameters +import at.asitplus.wallet.lib.oidc.AuthenticationResponseParameters import at.asitplus.wallet.lib.oidc.OpenIdConstants +import at.asitplus.wallet.lib.oidc.OpenIdConstants.CODE_CHALLENGE_METHOD_SHA256 import at.asitplus.wallet.lib.oidc.OpenIdConstants.CREDENTIAL_TYPE_OPENID import at.asitplus.wallet.lib.oidc.OpenIdConstants.GRANT_TYPE_CODE import at.asitplus.wallet.lib.oidc.OpenIdConstants.GRANT_TYPE_PRE_AUTHORIZED_CODE import at.asitplus.wallet.lib.oidvci.mdl.RequestedCredentialClaimSpecification import com.benasher44.uuid.uuid4 +import io.matthewnelson.encoding.core.Encoder.Companion.encodeToString +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.datetime.Clock +import kotlin.random.Random /** * Client service to retrieve credentials using @@ -57,13 +65,16 @@ class WalletService( private val jwsService: JwsService = DefaultJwsService(cryptoService), ) { + private val stateToCodeChallengeMap = mutableMapOf() + private val codeChallengeMutex = Mutex() + /** * Send the result as parameters (either POST or GET) to the server at `/authorize` (or more specific * [IssuerMetadata.authorizationEndpointUrl]) */ - fun createAuthRequest( + suspend fun createAuthRequest( credentialIssuer: String? = null, - state: String? = uuid4().toString() + state: String = uuid4().toString() ) = AuthenticationRequestParameters( responseType = GRANT_TYPE_CODE, state = state, @@ -71,16 +82,18 @@ class WalletService( authorizationDetails = credentialRepresentation.toAuthorizationDetails(), resource = credentialIssuer, redirectUrl = redirectUrl, + codeChallenge = generateCodeVerifier(state), + codeChallengeMethod = CODE_CHALLENGE_METHOD_SHA256, ) /** * Send the result as parameters (either POST or GET) to the server at `/authorize` (or more specific * [IssuerMetadata.authorizationEndpointUrl]) */ - fun createAuthRequest( + suspend fun createAuthRequest( scope: String, credentialIssuer: String? = null, - state: String? = uuid4().toString() + state: String = uuid4().toString() ) = AuthenticationRequestParameters( responseType = GRANT_TYPE_CODE, state = state, @@ -88,27 +101,42 @@ class WalletService( scope = scope, resource = credentialIssuer, redirectUrl = redirectUrl, - // TODO also code_challenge and code_challenge_method + codeChallenge = generateCodeVerifier(state), + codeChallengeMethod = CODE_CHALLENGE_METHOD_SHA256, ) + @OptIn(ExperimentalStdlibApi::class) + private suspend fun generateCodeVerifier(state: String): String { + val codeVerifier = Random.nextBytes(32).toHexString(HexFormat.Default) + codeChallengeMutex.withLock { + stateToCodeChallengeMap.put(state, codeVerifier) + } + return codeVerifier.encodeToByteArray().sha256().encodeToString(Base64UrlStrict) + } + /** * Send the result as POST parameters (form-encoded) to the server at `/token` (or more specific * [IssuerMetadata.tokenEndpointUrl]) */ - fun createTokenRequestParameters(code: String) = TokenRequestParameters( + suspend fun createTokenRequestParameters( + params: AuthenticationResponseParameters + ) = TokenRequestParameters( grantType = GRANT_TYPE_CODE, - code = code, + code = params.code, redirectUrl = redirectUrl, clientId = clientId, authorizationDetails = credentialRepresentation.toAuthorizationDetails(), - - ) + codeVerifier = codeChallengeMutex.withLock { stateToCodeChallengeMap.remove(params.state) } + ) /** * Send the result as POST parameters (form-encoded) to the server at `/token` (or more specific * [IssuerMetadata.tokenEndpointUrl]) */ - fun createTokenRequestParameters(credentialOffer: CredentialOffer) = TokenRequestParameters( + suspend fun createTokenRequestParameters( + params: AuthenticationResponseParameters, + credentialOffer: CredentialOffer + ) = TokenRequestParameters( grantType = GRANT_TYPE_PRE_AUTHORIZED_CODE, // TODO Verify if `redirect_uri` and `client_id` are even needed redirectUrl = redirectUrl, @@ -116,6 +144,7 @@ class WalletService( authorizationDetails = credentialRepresentation.toAuthorizationDetails(), transactionCode = credentialOffer.grants?.preAuthorizedCode?.transactionCode, preAuthorizedCode = credentialOffer.grants?.preAuthorizedCode?.preAuthorizedCode, + codeVerifier = codeChallengeMutex.withLock { stateToCodeChallengeMap.remove(params.state) } ) /** @@ -148,7 +177,7 @@ class WalletService( } val proof = CredentialRequestProof( proofType = OpenIdConstants.ProofTypes.JWT, - // TODO support "cwt" + // TODO support "cwt" jwt = proofPayload.serialize() ) return KmmResult.success(credentialRepresentation.toCredentialRequestParameters(proof)) diff --git a/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopInteropTest.kt b/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopInteropTest.kt index aeb5cd8e4..6383ee3dc 100644 --- a/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopInteropTest.kt +++ b/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopInteropTest.kt @@ -4,14 +4,12 @@ import at.asitplus.crypto.datatypes.jws.JweAlgorithm import at.asitplus.crypto.datatypes.jws.JwsAlgorithm import at.asitplus.crypto.datatypes.jws.JwsSigned import at.asitplus.wallet.eupid.EuPidScheme -import at.asitplus.wallet.lib.LibraryInitializer import at.asitplus.wallet.lib.agent.CryptoService import at.asitplus.wallet.lib.agent.DefaultCryptoService import at.asitplus.wallet.lib.agent.Holder import at.asitplus.wallet.lib.agent.HolderAgent import at.asitplus.wallet.lib.agent.IssuerAgent import at.asitplus.wallet.lib.data.ConstantIndex -import at.asitplus.wallet.lib.data.CredentialSubject import at.asitplus.wallet.lib.oidvci.decodeFromPostBody import at.asitplus.wallet.lib.oidvci.formUrlEncode import io.kotest.core.spec.style.FreeSpec @@ -22,9 +20,6 @@ import io.kotest.matchers.shouldBe import io.kotest.matchers.types.shouldBeInstanceOf import kotlinx.coroutines.runBlocking import kotlinx.datetime.Instant -import kotlinx.serialization.modules.SerializersModule -import kotlinx.serialization.modules.polymorphic -import kotlinx.serialization.modules.subclass /** * Tests our SIOP implementation against EUDI Ref Impl., @@ -174,7 +169,7 @@ class OidcSiopInteropTest : FreeSpec({ val response = holderSiop.createAuthnResponse(url).getOrThrow() - response.shouldBeInstanceOf() + response.shouldBeInstanceOf() val jarmParams = response.params.formUrlEncode().decodeFromPostBody() val jarm = jarmParams.response jarm.shouldNotBeNull() diff --git a/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopIsoProtocolTest.kt b/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopIsoProtocolTest.kt index cd485846b..1fa8a8156 100644 --- a/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopIsoProtocolTest.kt +++ b/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopIsoProtocolTest.kt @@ -145,7 +145,7 @@ private suspend fun runProcess( ).also { println(it) } val authnResponse = holderSiop.createAuthnResponse(authnRequest).getOrThrow() - authnResponse.shouldBeInstanceOf().also { println(it) } + authnResponse.shouldBeInstanceOf().also { println(it) } val result = verifierSiop.validateAuthnResponse(authnResponse.url) result.shouldBeInstanceOf() diff --git a/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopProtocolTest.kt b/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopProtocolTest.kt index cf597359e..f7f1a497a 100644 --- a/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopProtocolTest.kt +++ b/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopProtocolTest.kt @@ -92,7 +92,7 @@ class OidcSiopProtocolTest : FreeSpec({ .also { println(it) } val authnResponse = holderSiop.createAuthnResponse(authnRequest).getOrThrow() - authnResponse.shouldBeInstanceOf() + authnResponse.shouldBeInstanceOf() .also { println(it) } authnResponse.url.shouldNotContain("?") @@ -127,7 +127,7 @@ class OidcSiopProtocolTest : FreeSpec({ DefaultVerifierJwsService().verifyJwsObject(JwsSigned.parse(jar)!!).shouldBeTrue() val authnResponse = holderSiop.createAuthnResponse(jar).getOrThrow() - authnResponse.shouldBeInstanceOf() + authnResponse.shouldBeInstanceOf() val result = verifierSiop.validateAuthnResponse(authnResponse.url) result.shouldBeInstanceOf() @@ -140,7 +140,7 @@ class OidcSiopProtocolTest : FreeSpec({ ).also { println(it) } val authnResponse = holderSiop.createAuthnResponse(authnRequest).getOrThrow() - authnResponse.shouldBeInstanceOf() + authnResponse.shouldBeInstanceOf() .also { println(it) } authnResponse.url.shouldBe(relyingPartyUrl) @@ -156,7 +156,7 @@ class OidcSiopProtocolTest : FreeSpec({ ).also { println(it) } val authnResponse = holderSiop.createAuthnResponse(authnRequest).getOrThrow() - authnResponse.shouldBeInstanceOf() + authnResponse.shouldBeInstanceOf() .also { println(it) } authnResponse.url.shouldBe(relyingPartyUrl) authnResponse.params.shouldHaveSize(1) @@ -179,7 +179,7 @@ class OidcSiopProtocolTest : FreeSpec({ ).also { println(it) } val authnResponse = holderSiop.createAuthnResponse(authnRequest).getOrThrow() - authnResponse.shouldBeInstanceOf() + authnResponse.shouldBeInstanceOf() .also { println(it) } authnResponse.url.shouldContain("?") @@ -217,7 +217,7 @@ class OidcSiopProtocolTest : FreeSpec({ ).also { println(it) } val authnResponse = holderSiop.createAuthnResponse(authnRequest).getOrThrow() - authnResponse.shouldBeInstanceOf() + authnResponse.shouldBeInstanceOf() .also { println(it) } val result = verifierSiop.validateAuthnResponse(authnResponse.url) @@ -236,7 +236,7 @@ class OidcSiopProtocolTest : FreeSpec({ val authnResponse = holderSiop.createAuthnResponse(authnRequestWithRequestObject).getOrThrow() - authnResponse.shouldBeInstanceOf() + authnResponse.shouldBeInstanceOf() .also { println(it) } val result = verifierSiop.validateAuthnResponse(authnResponse.url) @@ -269,7 +269,7 @@ class OidcSiopProtocolTest : FreeSpec({ ) val authnResponse = holderSiop.createAuthnResponse(authnRequestWithRequestObject).getOrThrow() - authnResponse.shouldBeInstanceOf() + authnResponse.shouldBeInstanceOf() .also { println(it) } val result = verifierSiop.validateAuthnResponse(authnResponse.url) @@ -327,7 +327,7 @@ class OidcSiopProtocolTest : FreeSpec({ ) val authnResponse = holderSiop.createAuthnResponse(authRequestUrlWithRequestUri).getOrThrow() - authnResponse.shouldBeInstanceOf() + authnResponse.shouldBeInstanceOf() .also { println(it) } val result = verifierSiop.validateAuthnResponse(authnResponse.url) @@ -358,7 +358,7 @@ class OidcSiopProtocolTest : FreeSpec({ ) val authnResponse = holderSiop.createAuthnResponse(authRequestUrlWithRequestUri).getOrThrow() - authnResponse.shouldBeInstanceOf() + authnResponse.shouldBeInstanceOf() .also { println(it) } val result = verifierSiop.validateAuthnResponse(authnResponse.url) @@ -434,7 +434,7 @@ private suspend fun verifySecondProtocolRun( val authnRequestUrl = verifierSiop.createAuthnRequestUrl(walletUrl = walletUrl) val authnResponse = holderSiop.createAuthnResponse(authnRequestUrl) val validation = verifierSiop.validateAuthnResponse( - (authnResponse.getOrThrow() as OidcSiopWallet.AuthenticationResponseResult.Redirect).url + (authnResponse.getOrThrow() as AuthenticationResponseResult.Redirect).url ) validation.shouldBeInstanceOf() } diff --git a/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopSdJwtProtocolTest.kt b/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopSdJwtProtocolTest.kt index 7c3547f19..72bbca5b6 100644 --- a/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopSdJwtProtocolTest.kt +++ b/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopSdJwtProtocolTest.kt @@ -72,7 +72,7 @@ class OidcSiopSdJwtProtocolTest : FreeSpec({ authnRequest shouldContain "jwt_sd" val authnResponse = holderSiop.createAuthnResponse(authnRequest).getOrThrow() - authnResponse.shouldBeInstanceOf().also { println(it) } + authnResponse.shouldBeInstanceOf().also { println(it) } val result = verifierSiop.validateAuthnResponse(authnResponse.url) result.shouldBeInstanceOf() @@ -100,7 +100,7 @@ class OidcSiopSdJwtProtocolTest : FreeSpec({ authnRequest shouldContain requestedClaim val authnResponse = holderSiop.createAuthnResponse(authnRequest).getOrThrow() - authnResponse.shouldBeInstanceOf().also { println(it) } + authnResponse.shouldBeInstanceOf().also { println(it) } val result = verifierSiop.validateAuthnResponse(authnResponse.url) result.shouldBeInstanceOf() @@ -124,7 +124,7 @@ private suspend fun assertSecondRun( requestOptions = RequestOptions(representation = ConstantIndex.CredentialRepresentation.SD_JWT) ) val authnResponse = holderSiop.createAuthnResponse(authnRequestUrl) - val url = (authnResponse.getOrThrow() as OidcSiopWallet.AuthenticationResponseResult.Redirect).url + val url = (authnResponse.getOrThrow() as AuthenticationResponseResult.Redirect).url val validation = verifierSiop.validateAuthnResponse(url) validation.shouldBeInstanceOf() } \ No newline at end of file diff --git a/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidvciProcessTest.kt b/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidvciProcessTest.kt index 757b15994..986481ca4 100644 --- a/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidvciProcessTest.kt +++ b/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidvciProcessTest.kt @@ -8,13 +8,16 @@ import at.asitplus.wallet.lib.data.VerifiableCredentialJws import at.asitplus.wallet.lib.data.VerifiableCredentialSdJwt import at.asitplus.wallet.lib.iso.IssuerSigned import at.asitplus.wallet.lib.iso.MobileDrivingLicenceDataElements +import at.asitplus.wallet.lib.oidc.AuthenticationResponseResult import at.asitplus.wallet.lib.oidc.DummyCredentialDataProvider +import at.asitplus.wallet.lib.oidc.OidcSiopVerifier import at.asitplus.wallet.lib.oidc.OpenIdConstants.GRANT_TYPE_CODE import at.asitplus.wallet.lib.oidc.OpenIdConstants.TOKEN_PREFIX_BEARER import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.ints.shouldBeGreaterThan import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf import io.ktor.http.* import io.matthewnelson.encoding.base64.Base64 import io.matthewnelson.encoding.core.Decoder.Companion.decodeToByteArray @@ -138,11 +141,11 @@ private suspend fun runProcess( ): CredentialResponseParameters { val metadata = issuer.metadata val authnRequest = client.createAuthRequest() - val codeUrl = issuer.authorize(authnRequest) - codeUrl.shouldNotBeNull() - val code = Url(codeUrl).parameters[GRANT_TYPE_CODE] + val authnResponse = issuer.authorize(authnRequest) + authnResponse.shouldBeInstanceOf() + val code = authnResponse.params.code code.shouldNotBeNull() - val tokenRequest = client.createTokenRequestParameters(code) + val tokenRequest = client.createTokenRequestParameters(authnResponse.params) val token = issuer.token(tokenRequest) val credentialRequest = client.createCredentialRequest(token, metadata).getOrThrow() return issuer.credential(TOKEN_PREFIX_BEARER + token.accessToken, credentialRequest) diff --git a/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/SerializationTest.kt b/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/SerializationTest.kt index 55efcf602..0c9c0a67c 100644 --- a/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/SerializationTest.kt +++ b/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/SerializationTest.kt @@ -7,7 +7,7 @@ import at.asitplus.wallet.lib.oidc.OpenIdConstants.TOKEN_TYPE_BEARER import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import io.kotest.matchers.string.shouldContain -import io.ktor.http.encodeURLParameter +import io.ktor.http.* import kotlinx.serialization.encodeToString import kotlin.random.Random @@ -53,7 +53,9 @@ class SerializationTest : FunSpec({ fun createCredentialRequest() = CredentialRequestParameters( format = CredentialFormatEnum.JWT_VC, - types = arrayOf(randomString(), randomString()), + credentialDefinition = SupportedCredentialFormatDefinition( + types = listOf(randomString(), randomString()), + ), proof = CredentialRequestProof( proofType = randomString(), jwt = randomString() @@ -125,8 +127,8 @@ class SerializationTest : FunSpec({ val params = createCredentialRequest() val json = at.asitplus.wallet.lib.oidc.jsonSerializer.encodeToString(params) println(json) - json shouldContain "\"types\":[" - json shouldContain "\"${params.types.first()}\"" + json shouldContain "\"type\":[" + json shouldContain "\"${params.credentialDefinition?.types?.first()}\"" val parsed: CredentialRequestParameters = at.asitplus.wallet.lib.oidc.jsonSerializer.decodeFromString(json) parsed shouldBe params From 80a184f14c33af329f2d1b751f88465b27811e06 Mon Sep 17 00:00:00 2001 From: Christian Kollmann Date: Tue, 16 Apr 2024 10:33:16 +0200 Subject: [PATCH 05/18] Return KmmResult in public API to transport exceptions --- CHANGELOG.md | 2 +- .../wallet/lib/oidvci/IssuerService.kt | 134 +++++++++++------- .../wallet/lib/oidvci/OidvciProcessTest.kt | 9 +- 3 files changed, 87 insertions(+), 58 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 07f8555d0..c41b96c5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,7 @@ Release 3.6.0: - Auto-publish version catalogs - Implement OpenID for Verifiable Credential Issuance draft 13, from 2024-02-08 - Implement RFC 7636 Proof Key for Code Exchange for OpenID for Verifiable Credential Issuance implementations, i.e. `IssuerService` and `WalletService` - - `IssuerService`: Make public API functions suspending + - `IssuerService`: Make public API functions suspending, also return `KmmResult` to transport exceptions - `WalletService`: Make public API functions suspending - TODO document changes in `IssuerService` and `WalletService` diff --git a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/IssuerService.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/IssuerService.kt index 8d120a724..27a699fca 100644 --- a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/IssuerService.kt +++ b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/IssuerService.kt @@ -1,5 +1,6 @@ package at.asitplus.wallet.lib.oidvci +import at.asitplus.KmmResult import at.asitplus.crypto.datatypes.io.Base64UrlStrict import at.asitplus.crypto.datatypes.jws.JsonWebToken import at.asitplus.crypto.datatypes.jws.JwsSigned @@ -22,11 +23,11 @@ import at.asitplus.wallet.lib.oidc.OpenIdConstants.TOKEN_TYPE_BEARER import at.asitplus.wallet.lib.oidc.OpenIdConstants.URN_TYPE_JWK_THUMBPRINT import at.asitplus.wallet.lib.oidvci.mdl.RequestedCredentialClaimSpecification import com.benasher44.uuid.uuid4 +import io.github.aakira.napier.Napier import io.ktor.http.* import io.matthewnelson.encoding.core.Encoder.Companion.encodeToString import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -import kotlin.coroutines.cancellation.CancellationException /** * Server implementation to issue credentials using @@ -162,10 +163,12 @@ class IssuerService( * @return URL build from client's `redirect_uri` with a `code` query parameter containing a fresh authorization * code from [codeService]. */ - suspend fun authorize(request: AuthenticationRequestParameters): AuthenticationResponseResult { + suspend fun authorize(request: AuthenticationRequestParameters): KmmResult { // TODO Need to store the `scope` or `authorization_details`, i.e. may respond with `invalid_scope` here! if (request.redirectUrl == null) - throw OAuth2Exception(Errors.INVALID_REQUEST, "redirect_uri not set") + return KmmResult.failure( + OAuth2Exception(Errors.INVALID_REQUEST, "redirect_uri not set") + ).also { Napier.w("authorize: client did not set redirect_uri in $request") } val code = codeService.provideCode() val responseParams = AuthenticationResponseParameters( code = code, @@ -180,43 +183,52 @@ class IssuerService( val url = URLBuilder(request.redirectUrl) .apply { responseParams.encodeToParameters().forEach { this.parameters.append(it.key, it.value) } } .buildString() - return AuthenticationResponseResult.Redirect(url, responseParams) + val result = AuthenticationResponseResult.Redirect(url, responseParams) + Napier.i("authorize returns $result") + return KmmResult.success(result) } /** * Verifies the authorization code sent by the client and issues an access token. * Send this value JSON-serialized back to the client. + * + * @return [KmmResult] may contain a [OAuth2Exception] */ - suspend fun token(params: TokenRequestParameters): TokenResponseParameters { + suspend fun token(params: TokenRequestParameters): KmmResult { // TODO This is part of the Authorization Server when (params.grantType) { GRANT_TYPE_CODE -> if (params.code == null || !codeService.verifyCode(params.code)) - throw OAuth2Exception(Errors.INVALID_CODE) + return KmmResult.failure(OAuth2Exception(Errors.INVALID_CODE)) + .also { Napier.w("token: client did not provide correct code") } GRANT_TYPE_PRE_AUTHORIZED_CODE -> if (params.preAuthorizedCode == null || !codeService.verifyCode(params.preAuthorizedCode)) - throw OAuth2Exception(Errors.INVALID_GRANT) + return KmmResult.failure(OAuth2Exception(Errors.INVALID_GRANT)) + .also { Napier.w("token: client did not provide pre authorized code") } else -> - throw OAuth2Exception("No valid grant_type: ${params.grantType}") + return KmmResult.failure( + OAuth2Exception(Errors.INVALID_REQUEST, "No valid grant_type") + ).also { Napier.w("token: client did not provide valid grant_type: ${params.grantType}") } } if (params.authorizationDetails != null) { - // TODO verify - // params.authorizationDetails.claims - params.authorizationDetails.credentialIdentifiers?.forEach { - if (!credentialSchemes.map { it.vcType }.contains(it)) { - throw OAuth2Exception(Errors.INVALID_GRANT) + // TODO verify params.authorizationDetails.claims and so on + params.authorizationDetails.credentialIdentifiers?.forEach { credentialIdentifier -> + if (!credentialSchemes.map { it.vcType }.contains(credentialIdentifier)) { + return KmmResult.failure(OAuth2Exception(Errors.INVALID_GRANT)) + .also { Napier.w("token: client requested invalid credential identifier: $credentialIdentifier") } } } } - if (params.codeVerifier != null) { + params.codeVerifier?.let { codeVerifier -> val codeChallenge = codeChallengeMutex.withLock { codeToCodeChallengeMap.remove(params.code) } - val codeChallengeCalculated = params.codeVerifier.encodeToByteArray().sha256() + val codeChallengeCalculated = codeVerifier.encodeToByteArray().sha256() .encodeToString(Base64UrlStrict) if (codeChallenge != codeChallengeCalculated) { - throw OAuth2Exception(Errors.INVALID_GRANT) + return KmmResult.failure(OAuth2Exception(Errors.INVALID_GRANT)) + .also { Napier.w("token: client did not provide correct code verifier: $codeVerifier") } } } - return TokenResponseParameters( + val result = TokenResponseParameters( accessToken = tokenService.provideToken(), tokenType = TOKEN_TYPE_BEARER, expires = 3600, @@ -226,6 +238,8 @@ class IssuerService( listOf(it) } ) + Napier.i("token returns $result") + return KmmResult.success(result) } /** @@ -237,58 +251,76 @@ class IssuerService( * @param authorizationHeader The value of HTTP header `Authorization` sent by the client * @param params Parameters the client sent JSON-serialized in the HTTP body */ - @Throws(OAuth2Exception::class, CancellationException::class) suspend fun credential( authorizationHeader: String, // TODO Change interface to only contain access token, nothing else params: CredentialRequestParameters - ): CredentialResponseParameters { + ): KmmResult { if (!tokenService.verifyToken(authorizationHeader.removePrefix(TOKEN_PREFIX_BEARER))) - throw OAuth2Exception(Errors.INVALID_TOKEN) + return KmmResult.failure(OAuth2Exception(Errors.INVALID_TOKEN)) + .also { Napier.w("credential: client did not provide correct token: $authorizationHeader") } val proof = params.proof - ?: throw OAuth2Exception(Errors.INVALID_REQUEST) + ?: return KmmResult.failure(OAuth2Exception(Errors.INVALID_REQUEST)) + .also { Napier.w("credential: client did not provide proof of possession") } // TODO also support `cwt` as proof if (proof.proofType != ProofTypes.JWT || proof.jwt == null) - throw OAuth2Exception(Errors.INVALID_PROOF) + return KmmResult.failure(OAuth2Exception(Errors.INVALID_PROOF)) + .also { Napier.w("credential: client did provide invalid proof: $proof") } val jwsSigned = JwsSigned.parse(proof.jwt) - ?: throw OAuth2Exception(Errors.INVALID_PROOF) + ?: return KmmResult.failure(OAuth2Exception(Errors.INVALID_PROOF)) + .also { Napier.w("credential: client did provide invalid proof: $proof") } val jwt = JsonWebToken.deserialize(jwsSigned.payload.decodeToString()).getOrNull() - ?: throw OAuth2Exception(Errors.INVALID_PROOF) + ?: return KmmResult.failure(OAuth2Exception(Errors.INVALID_PROOF)) + .also { Napier.w("credential: client did provide invalid JWT in proof: $proof") } // TODO verify required claims in OID4VCI 7.2.1.1 if (jwt.nonce == null || !clientNonceService.verifyAndRemoveNonce(jwt.nonce!!)) - throw OAuth2Exception(Errors.INVALID_PROOF) + return KmmResult.failure(OAuth2Exception(Errors.INVALID_PROOF)) + .also { Napier.w("credential: client did provide invalid nonce in JWT in proof: ${jwt.nonce}") } if (jwsSigned.header.type != ProofTypes.JWT_HEADER_TYPE) - throw OAuth2Exception(Errors.INVALID_PROOF) + return KmmResult.failure(OAuth2Exception(Errors.INVALID_PROOF)) + .also { Napier.w("credential: client did provide invalid header type in JWT in proof: ${jwsSigned.header}") } val subjectPublicKey = jwsSigned.header.publicKey - ?: throw OAuth2Exception(Errors.INVALID_PROOF) + ?: return KmmResult.failure(OAuth2Exception(Errors.INVALID_PROOF)) + .also { Napier.w("credential: client did provide no valid key in header in JWT in proof: ${jwsSigned.header}") } - val issuedCredentialResult = if (params.format != null) { - issuer.issueCredential( - subjectPublicKey = subjectPublicKey, - attributeTypes = listOfNotNull(params.sdJwtVcType, params.docType) - + (params.credentialDefinition?.types?.toList() ?: listOf()), - representation = params.format.toRepresentation(), - claimNames = params.claims?.map { it.value.keys }?.flatten()?.ifEmpty { null } - ) - } else if (params.credentialIdentifier != null) { - // TODO this delimiter is probably not safe - val representation = CredentialFormatEnum.parse(params.credentialIdentifier.substringAfterLast("-")) - ?: throw OAuth2Exception(Errors.INVALID_REQUEST) - // TODO what to do in case of ISO, look at string constants from EUDIW - val vcType = params.credentialIdentifier.substringBeforeLast("-") - issuer.issueCredential( - subjectPublicKey = subjectPublicKey, - attributeTypes = listOf(vcType), - representation = representation.toRepresentation(), - claimNames = params.claims?.map { it.value.keys }?.flatten()?.ifEmpty { null } - ) - } else { - throw OAuth2Exception(Errors.INVALID_REQUEST) + val issuedCredentialResult = when { + params.format != null -> { + issuer.issueCredential( + subjectPublicKey = subjectPublicKey, + attributeTypes = listOfNotNull(params.sdJwtVcType, params.docType) + + (params.credentialDefinition?.types?.toList() ?: listOf()), + representation = params.format.toRepresentation(), + claimNames = params.claims?.map { it.value.keys }?.flatten()?.ifEmpty { null } + ) + } + + params.credentialIdentifier != null -> { + // TODO this delimiter is probably not safe + val representation = CredentialFormatEnum.parse(params.credentialIdentifier.substringAfterLast("-")) + ?: return KmmResult.failure(OAuth2Exception(Errors.INVALID_REQUEST)) + .also { Napier.w("credential: client did not provide correct credential identifier: ${params.credentialIdentifier}") } + // TODO what to do in case of ISO, look at string constants from EUDIW + val vcType = params.credentialIdentifier.substringBeforeLast("-") + issuer.issueCredential( + subjectPublicKey = subjectPublicKey, + attributeTypes = listOf(vcType), + representation = representation.toRepresentation(), + claimNames = params.claims?.map { it.value.keys }?.flatten()?.ifEmpty { null } + ) + } + + else -> { + return KmmResult.failure(OAuth2Exception(Errors.INVALID_REQUEST)) + .also { Napier.w("credential: client did not provide format or credential identifier in params: $params") } + } } if (issuedCredentialResult.successful.isEmpty()) { - throw OAuth2Exception(Errors.INVALID_REQUEST) + return KmmResult.failure(OAuth2Exception(Errors.INVALID_REQUEST)) + .also { Napier.w("credential: issuer did not issue credential: $issuedCredentialResult") } } // TODO Implement Batch Credential Endpoint for more than one credential response - return issuedCredentialResult.successful.first().toCredentialResponseParameters() + val result = issuedCredentialResult.successful.first().toCredentialResponseParameters() + Napier.i("credential returns $result") + return KmmResult.success(result) } private fun Issuer.IssuedCredential.toCredentialResponseParameters() = when (this) { diff --git a/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidvciProcessTest.kt b/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidvciProcessTest.kt index 986481ca4..dd75b6b45 100644 --- a/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidvciProcessTest.kt +++ b/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidvciProcessTest.kt @@ -10,15 +10,12 @@ import at.asitplus.wallet.lib.iso.IssuerSigned import at.asitplus.wallet.lib.iso.MobileDrivingLicenceDataElements import at.asitplus.wallet.lib.oidc.AuthenticationResponseResult import at.asitplus.wallet.lib.oidc.DummyCredentialDataProvider -import at.asitplus.wallet.lib.oidc.OidcSiopVerifier -import at.asitplus.wallet.lib.oidc.OpenIdConstants.GRANT_TYPE_CODE import at.asitplus.wallet.lib.oidc.OpenIdConstants.TOKEN_PREFIX_BEARER import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.ints.shouldBeGreaterThan import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.shouldBe import io.kotest.matchers.types.shouldBeInstanceOf -import io.ktor.http.* import io.matthewnelson.encoding.base64.Base64 import io.matthewnelson.encoding.core.Decoder.Companion.decodeToByteArray @@ -141,12 +138,12 @@ private suspend fun runProcess( ): CredentialResponseParameters { val metadata = issuer.metadata val authnRequest = client.createAuthRequest() - val authnResponse = issuer.authorize(authnRequest) + val authnResponse = issuer.authorize(authnRequest).getOrThrow() authnResponse.shouldBeInstanceOf() val code = authnResponse.params.code code.shouldNotBeNull() val tokenRequest = client.createTokenRequestParameters(authnResponse.params) - val token = issuer.token(tokenRequest) + val token = issuer.token(tokenRequest).getOrThrow() val credentialRequest = client.createCredentialRequest(token, metadata).getOrThrow() - return issuer.credential(TOKEN_PREFIX_BEARER + token.accessToken, credentialRequest) + return issuer.credential(TOKEN_PREFIX_BEARER + token.accessToken, credentialRequest).getOrThrow() } From 6152560dff1f9b32c560fca174a9e834df193509 Mon Sep 17 00:00:00 2001 From: Christian Kollmann Date: Tue, 16 Apr 2024 13:24:59 +0200 Subject: [PATCH 06/18] OID4VCI: Implement CWT proofs --- CHANGELOG.md | 3 +- kmp-crypto | 2 +- .../wallet/lib/oidc/OpenIdConstants.kt | 8 + .../wallet/lib/oidvci/IssuerService.kt | 101 ++++++--- .../wallet/lib/oidvci/WalletService.kt | 64 +++++- .../wallet/lib/oidvci/OidvciInteropTest.kt | 196 ++++++++++++++++++ .../wallet/lib/oidvci/OidvciProcessTest.kt | 37 +++- .../asitplus/wallet/lib/agent/IssuerAgent.kt | 3 + 8 files changed, 359 insertions(+), 55 deletions(-) create mode 100644 vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidvciInteropTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index c41b96c5e..ff7b33781 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,8 +18,9 @@ Release 3.6.0: - Implement OpenID for Verifiable Credential Issuance draft 13, from 2024-02-08 - Implement RFC 7636 Proof Key for Code Exchange for OpenID for Verifiable Credential Issuance implementations, i.e. `IssuerService` and `WalletService` - `IssuerService`: Make public API functions suspending, also return `KmmResult` to transport exceptions + - `IssuerService`: Change parameter of `credential()` from `authorizationHeader` to `accessToken`, requiring the plain access token - `WalletService`: Make public API functions suspending - - TODO document changes in `IssuerService` and `WalletService` + - `WalletService`: Implement proving possesion of private key with CBOR Web Tokens Release 3.5.0: - Kotlin 1.9.23 diff --git a/kmp-crypto b/kmp-crypto index ee4a68c6e..15f74ef06 160000 --- a/kmp-crypto +++ b/kmp-crypto @@ -1 +1 @@ -Subproject commit ee4a68c6ef5e4899b3d866825395f299368de607 +Subproject commit 15f74ef06576567b11479c1811d3f6d58bf4035d diff --git a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/OpenIdConstants.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/OpenIdConstants.kt index d36ba2ff9..ebe591e54 100644 --- a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/OpenIdConstants.kt +++ b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/OpenIdConstants.kt @@ -45,7 +45,15 @@ object OpenIdConstants { */ const val CWT = "cwt" + /** + * Constant from OID4VCI + */ const val JWT_HEADER_TYPE = "openid4vci-proof+jwt" + + /** + * Constant from OID4VCI + */ + const val CWT_HEADER_TYPE = "openid4vci-proof+cwt" } /** diff --git a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/IssuerService.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/IssuerService.kt index 27a699fca..7a28ecace 100644 --- a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/IssuerService.kt +++ b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/IssuerService.kt @@ -1,10 +1,13 @@ package at.asitplus.wallet.lib.oidvci import at.asitplus.KmmResult +import at.asitplus.crypto.datatypes.cose.CborWebToken +import at.asitplus.crypto.datatypes.cose.CoseSigned import at.asitplus.crypto.datatypes.io.Base64UrlStrict import at.asitplus.crypto.datatypes.jws.JsonWebToken import at.asitplus.crypto.datatypes.jws.JwsSigned import at.asitplus.crypto.datatypes.jws.toJwsAlgorithm +import at.asitplus.crypto.datatypes.pki.X509Certificate import at.asitplus.wallet.lib.agent.Issuer import at.asitplus.wallet.lib.data.ConstantIndex import at.asitplus.wallet.lib.data.VcDataModelConstants.VERIFIABLE_CREDENTIAL @@ -18,13 +21,13 @@ import at.asitplus.wallet.lib.oidc.OpenIdConstants.GRANT_TYPE_CODE import at.asitplus.wallet.lib.oidc.OpenIdConstants.GRANT_TYPE_PRE_AUTHORIZED_CODE import at.asitplus.wallet.lib.oidc.OpenIdConstants.PREFIX_DID_KEY import at.asitplus.wallet.lib.oidc.OpenIdConstants.ProofTypes -import at.asitplus.wallet.lib.oidc.OpenIdConstants.TOKEN_PREFIX_BEARER import at.asitplus.wallet.lib.oidc.OpenIdConstants.TOKEN_TYPE_BEARER import at.asitplus.wallet.lib.oidc.OpenIdConstants.URN_TYPE_JWK_THUMBPRINT import at.asitplus.wallet.lib.oidvci.mdl.RequestedCredentialClaimSpecification import com.benasher44.uuid.uuid4 import io.github.aakira.napier.Napier import io.ktor.http.* +import io.matthewnelson.encoding.core.Decoder.Companion.decodeToByteArray import io.matthewnelson.encoding.core.Encoder.Companion.encodeToString import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -105,29 +108,30 @@ class IssuerService( private fun ConstantIndex.CredentialScheme.toSupportedCredentialFormat() = mapOf( this.isoNamespace to SupportedCredentialFormat( format = CredentialFormatEnum.MSO_MDOC, + scope = vcType, docType = isoDocType, claims = mapOf( - isoNamespace to claimNames - .associateWith { RequestedCredentialClaimSpecification() } + isoNamespace to claimNames.associateWith { RequestedCredentialClaimSpecification() } ), supportedBindingMethods = listOf(BINDING_METHOD_COSE_KEY), supportedSigningAlgorithms = issuer.cryptoAlgorithms.map { it.toJwsAlgorithm().identifier }, ), "$vcType-${CredentialFormatEnum.JWT_VC.text}" to SupportedCredentialFormat( format = CredentialFormatEnum.JWT_VC, + scope = vcType, credentialDefinition = SupportedCredentialFormatDefinition( types = listOf(VERIFIABLE_CREDENTIAL, vcType), - credentialSubject = this.claimNames.associateWith { CredentialSubjectMetadataSingle() } + credentialSubject = claimNames.associateWith { CredentialSubjectMetadataSingle() } ), supportedBindingMethods = listOf(PREFIX_DID_KEY, URN_TYPE_JWK_THUMBPRINT), supportedSigningAlgorithms = issuer.cryptoAlgorithms.map { it.toJwsAlgorithm().identifier }, ), "$vcType-${CredentialFormatEnum.VC_SD_JWT.text}" to SupportedCredentialFormat( format = CredentialFormatEnum.VC_SD_JWT, + scope = vcType, sdJwtVcType = vcType, claims = mapOf( - isoNamespace to claimNames - .associateWith { RequestedCredentialClaimSpecification() } + isoNamespace to claimNames.associateWith { RequestedCredentialClaimSpecification() } ), supportedBindingMethods = listOf(PREFIX_DID_KEY, URN_TYPE_JWK_THUMBPRINT), supportedSigningAlgorithms = issuer.cryptoAlgorithms.map { it.toJwsAlgorithm().identifier }, @@ -140,7 +144,7 @@ class IssuerService( */ fun credentialOffer(): CredentialOffer = CredentialOffer( credentialIssuer = publicContext, - configurationIds = credentialSchemes.map { it.vcType }, + configurationIds = credentialSchemes.flatMap { it.toSupportedCredentialFormat().keys }, grants = CredentialOfferGrants( authorizationCode = CredentialOfferGrantsAuthCode( issuerState = uuid4().toString(), // TODO remember this state, for subsequent requests from the Wallet @@ -243,44 +247,77 @@ class IssuerService( } /** - * Verifies the [authorizationHeader] to contain a token from [tokenService], + * Verifies the [accessToken] to contain a token from [tokenService], * verifies the proof sent by the client (must contain a nonce from [clientNonceService]), * and issues credentials to the client. * Send the result JSON-serialized back to the client. * - * @param authorizationHeader The value of HTTP header `Authorization` sent by the client + * @param accessToken The value of HTTP header `Authorization` sent by the client, with the prefix `Bearer ` removed, so the plain access token * @param params Parameters the client sent JSON-serialized in the HTTP body */ suspend fun credential( - authorizationHeader: String, // TODO Change interface to only contain access token, nothing else + accessToken: String, params: CredentialRequestParameters ): KmmResult { - if (!tokenService.verifyToken(authorizationHeader.removePrefix(TOKEN_PREFIX_BEARER))) + if (!tokenService.verifyToken(accessToken)) return KmmResult.failure(OAuth2Exception(Errors.INVALID_TOKEN)) - .also { Napier.w("credential: client did not provide correct token: $authorizationHeader") } + .also { Napier.w("credential: client did not provide correct token: $accessToken") } val proof = params.proof ?: return KmmResult.failure(OAuth2Exception(Errors.INVALID_REQUEST)) .also { Napier.w("credential: client did not provide proof of possession") } - // TODO also support `cwt` as proof - if (proof.proofType != ProofTypes.JWT || proof.jwt == null) - return KmmResult.failure(OAuth2Exception(Errors.INVALID_PROOF)) - .also { Napier.w("credential: client did provide invalid proof: $proof") } - val jwsSigned = JwsSigned.parse(proof.jwt) - ?: return KmmResult.failure(OAuth2Exception(Errors.INVALID_PROOF)) - .also { Napier.w("credential: client did provide invalid proof: $proof") } - val jwt = JsonWebToken.deserialize(jwsSigned.payload.decodeToString()).getOrNull() - ?: return KmmResult.failure(OAuth2Exception(Errors.INVALID_PROOF)) - .also { Napier.w("credential: client did provide invalid JWT in proof: $proof") } - // TODO verify required claims in OID4VCI 7.2.1.1 - if (jwt.nonce == null || !clientNonceService.verifyAndRemoveNonce(jwt.nonce!!)) - return KmmResult.failure(OAuth2Exception(Errors.INVALID_PROOF)) - .also { Napier.w("credential: client did provide invalid nonce in JWT in proof: ${jwt.nonce}") } - if (jwsSigned.header.type != ProofTypes.JWT_HEADER_TYPE) - return KmmResult.failure(OAuth2Exception(Errors.INVALID_PROOF)) - .also { Napier.w("credential: client did provide invalid header type in JWT in proof: ${jwsSigned.header}") } - val subjectPublicKey = jwsSigned.header.publicKey - ?: return KmmResult.failure(OAuth2Exception(Errors.INVALID_PROOF)) - .also { Napier.w("credential: client did provide no valid key in header in JWT in proof: ${jwsSigned.header}") } + val subjectPublicKey = when (proof.proofType) { + ProofTypes.JWT -> { + if (proof.jwt == null) + return KmmResult.failure(OAuth2Exception(Errors.INVALID_PROOF)) + .also { Napier.w("credential: client did provide invalid proof: $proof") } + val jwsSigned = JwsSigned.parse(proof.jwt) + ?: return KmmResult.failure(OAuth2Exception(Errors.INVALID_PROOF)) + .also { Napier.w("credential: client did provide invalid proof: $proof") } + val jwt = JsonWebToken.deserialize(jwsSigned.payload.decodeToString()).getOrNull() + ?: return KmmResult.failure(OAuth2Exception(Errors.INVALID_PROOF)) + .also { Napier.w("credential: client did provide invalid JWT in proof: $proof") } + if (jwt.nonce == null || !clientNonceService.verifyAndRemoveNonce(jwt.nonce!!)) + return KmmResult.failure(OAuth2Exception(Errors.INVALID_PROOF)) + .also { Napier.w("credential: client did provide invalid nonce in JWT in proof: ${jwt.nonce}") } + if (jwsSigned.header.type != ProofTypes.JWT_HEADER_TYPE) + return KmmResult.failure(OAuth2Exception(Errors.INVALID_PROOF)) + .also { Napier.w("credential: client did provide invalid header type in JWT in proof: ${jwsSigned.header}") } + if (jwt.audience == null || jwt.audience != publicContext) + return KmmResult.failure(OAuth2Exception(Errors.INVALID_PROOF)) + .also { Napier.w("credential: client did provide invalid audience in JWT in proof: ${jwsSigned.header}") } + jwsSigned.header.publicKey + ?: return KmmResult.failure(OAuth2Exception(Errors.INVALID_PROOF)) + .also { Napier.w("credential: client did provide no valid key in header in JWT in proof: ${jwsSigned.header}") } + } + ProofTypes.CWT -> { + if (proof.cwt == null) + return KmmResult.failure(OAuth2Exception(Errors.INVALID_PROOF)) + .also { Napier.w("credential: client did provide invalid proof: $proof") } + val coseSigned = CoseSigned.deserialize(proof.cwt.decodeToByteArray(Base64UrlStrict)) + ?: return KmmResult.failure(OAuth2Exception(Errors.INVALID_PROOF)) + .also { Napier.w("credential: client did provide invalid proof: $proof") } + val cwt = coseSigned.payload?.let { CborWebToken.deserialize(it).getOrNull() } + ?: return KmmResult.failure(OAuth2Exception(Errors.INVALID_PROOF)) + .also { Napier.w("credential: client did provide invalid CWT in proof: $proof") } + if (cwt.nonce == null || !clientNonceService.verifyAndRemoveNonce(cwt.nonce!!.decodeToString())) + return KmmResult.failure(OAuth2Exception(Errors.INVALID_PROOF)) + .also { Napier.w("credential: client did provide invalid nonce in CWT in proof: ${cwt.nonce}") } + val header = coseSigned.protectedHeader.value + if (header.contentType != ProofTypes.CWT_HEADER_TYPE) + return KmmResult.failure(OAuth2Exception(Errors.INVALID_PROOF)) + .also { Napier.w("credential: client did provide invalid header type in CWT in proof: $header") } + if (cwt.audience == null || cwt.audience != publicContext) + return KmmResult.failure(OAuth2Exception(Errors.INVALID_PROOF)) + .also { Napier.w("credential: client did provide invalid audience in CWT in proof: $header") } + header.certificateChain?.let { X509Certificate.decodeFromByteArray(it)?.publicKey } + ?: return KmmResult.failure(OAuth2Exception(Errors.INVALID_PROOF)) + .also { Napier.w("credential: client did provide no valid key in header in CWT in proof: $header") } + } + else -> { + return KmmResult.failure(OAuth2Exception(Errors.INVALID_PROOF)) + .also { Napier.w("credential: client did provide invalid proof type: ${proof.proofType}") } + } + } val issuedCredentialResult = when { params.format != null -> { diff --git a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/WalletService.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/WalletService.kt index 189ac68ae..aed2917e6 100644 --- a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/WalletService.kt +++ b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/WalletService.kt @@ -1,12 +1,17 @@ package at.asitplus.wallet.lib.oidvci import at.asitplus.KmmResult +import at.asitplus.crypto.datatypes.cose.CborWebToken +import at.asitplus.crypto.datatypes.cose.CoseHeader +import at.asitplus.crypto.datatypes.cose.toCoseAlgorithm import at.asitplus.crypto.datatypes.io.Base64UrlStrict import at.asitplus.crypto.datatypes.jws.JsonWebToken import at.asitplus.crypto.datatypes.jws.JwsHeader import at.asitplus.crypto.datatypes.jws.toJwsAlgorithm import at.asitplus.wallet.lib.agent.CryptoService import at.asitplus.wallet.lib.agent.DefaultCryptoService +import at.asitplus.wallet.lib.cbor.CoseService +import at.asitplus.wallet.lib.cbor.DefaultCoseService import at.asitplus.wallet.lib.data.ConstantIndex import at.asitplus.wallet.lib.data.VcDataModelConstants.VERIFIABLE_CREDENTIAL import at.asitplus.wallet.lib.iso.sha256 @@ -21,6 +26,7 @@ import at.asitplus.wallet.lib.oidc.OpenIdConstants.GRANT_TYPE_CODE import at.asitplus.wallet.lib.oidc.OpenIdConstants.GRANT_TYPE_PRE_AUTHORIZED_CODE import at.asitplus.wallet.lib.oidvci.mdl.RequestedCredentialClaimSpecification import com.benasher44.uuid.uuid4 +import io.github.aakira.napier.Napier import io.matthewnelson.encoding.core.Encoder.Companion.encodeToString import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -63,6 +69,10 @@ class WalletService( * Used to prove possession of the key material to create [CredentialRequestProof]. */ private val jwsService: JwsService = DefaultJwsService(cryptoService), + /** + * Used to prove possession of the key material to create [CredentialRequestProof]. + */ + private val coseService: CoseService = DefaultCoseService(cryptoService), ) { private val stateToCodeChallengeMap = mutableMapOf() @@ -108,9 +118,7 @@ class WalletService( @OptIn(ExperimentalStdlibApi::class) private suspend fun generateCodeVerifier(state: String): String { val codeVerifier = Random.nextBytes(32).toHexString(HexFormat.Default) - codeChallengeMutex.withLock { - stateToCodeChallengeMap.put(state, codeVerifier) - } + codeChallengeMutex.withLock { stateToCodeChallengeMap.put(state, codeVerifier) } return codeVerifier.encodeToByteArray().sha256().encodeToString(Base64UrlStrict) } @@ -153,34 +161,70 @@ class WalletService( * Also send along the [TokenResponseParameters.accessToken] from [tokenResponse] in HTTP header `Authorization` * as value `Bearer accessTokenValue` (depending on the [TokenResponseParameters.tokenType]). */ - suspend fun createCredentialRequest( + suspend fun createCredentialRequestJwt( tokenResponse: TokenResponseParameters, - issuerMetadata: IssuerMetadata + issuerMetadata: IssuerMetadata, ): KmmResult { - // NOTE: Specification is missing a proof type for binding method `cose_key`, so we'll use JWT val proofPayload = jwsService.createSignedJwsAddingParams( header = JwsHeader( algorithm = cryptoService.algorithm.toJwsAlgorithm(), type = OpenIdConstants.ProofTypes.JWT_HEADER_TYPE, ), payload = JsonWebToken( - // TODO Set correct parameters issuer = clientId, audience = issuerMetadata.credentialIssuer, issuedAt = Clock.System.now(), nonce = tokenResponse.clientNonce, ).serialize().encodeToByteArray(), - addKeyId = true, + addKeyId = false, addJsonWebKey = true + // NOTE: use `x5c` to transport key attestation ).getOrElse { + Napier.w("createCredentialRequestJwt: Error from jwsService: $it") return KmmResult.failure(it) } val proof = CredentialRequestProof( proofType = OpenIdConstants.ProofTypes.JWT, - // TODO support "cwt" jwt = proofPayload.serialize() ) - return KmmResult.success(credentialRepresentation.toCredentialRequestParameters(proof)) + val result = credentialRepresentation.toCredentialRequestParameters(proof) + Napier.i("createCredentialRequestCwt returns $result") + return KmmResult.success(result) + } + + /** + * Send the result as JSON-serialized content to the server at `/credential` (or more specific + * [IssuerMetadata.credentialEndpointUrl]). + * Also send along the [TokenResponseParameters.accessToken] from [tokenResponse] in HTTP header `Authorization` + * as value `Bearer accessTokenValue` (depending on the [TokenResponseParameters.tokenType]). + */ + suspend fun createCredentialRequestCwt( + tokenResponse: TokenResponseParameters, + issuerMetadata: IssuerMetadata, + ): KmmResult { + val proofPayload = coseService.createSignedCose( + protectedHeader = CoseHeader( + algorithm = cryptoService.algorithm.toCoseAlgorithm(), + contentType = OpenIdConstants.ProofTypes.CWT_HEADER_TYPE, + certificateChain = cryptoService.certificate?.encodeToDerOrNull() + ), + payload = CborWebToken( + issuer = clientId, + audience = issuerMetadata.credentialIssuer, + issuedAt = Clock.System.now(), + nonce = tokenResponse.clientNonce?.encodeToByteArray(), + ).serialize() + ).getOrElse { + Napier.w("createCredentialRequestCwt: Error from coseService: $it") + return KmmResult.failure(it) + } + val proof = CredentialRequestProof( + proofType = OpenIdConstants.ProofTypes.CWT, + cwt = proofPayload.serialize().encodeToString(Base64UrlStrict), + ) + val result = credentialRepresentation.toCredentialRequestParameters(proof) + Napier.i("createCredentialRequestCwt returns $result") + return KmmResult.success(result) } private fun ConstantIndex.CredentialRepresentation.toAuthorizationDetails() = when (this) { diff --git a/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidvciInteropTest.kt b/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidvciInteropTest.kt new file mode 100644 index 000000000..da4352aaf --- /dev/null +++ b/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidvciInteropTest.kt @@ -0,0 +1,196 @@ +package at.asitplus.wallet.lib.oidvci + +import at.asitplus.wallet.lib.agent.DefaultCryptoService +import at.asitplus.wallet.lib.agent.IssuerAgent +import at.asitplus.wallet.lib.data.ConstantIndex +import at.asitplus.wallet.lib.oidc.AuthenticationResponseResult +import at.asitplus.wallet.lib.oidc.DummyCredentialDataProvider +import at.asitplus.wallet.lib.oidc.OpenIdConstants.PATH_WELL_KNOWN_CREDENTIAL_ISSUER +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.collections.shouldContainAll +import io.kotest.matchers.collections.shouldHaveSingleElement +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf +import io.ktor.http.* + +class OidvciInteropTest : FunSpec({ + + beforeSpec { + at.asitplus.wallet.eupid.Initializer.initWithVcLib() + } + + lateinit var issuer: IssuerService + + beforeEach { + issuer = IssuerService( + issuer = IssuerAgent.newDefaultInstance( + cryptoService = DefaultCryptoService(), + dataProvider = DummyCredentialDataProvider() + ), + credentialSchemes = listOf(ConstantIndex.AtomicAttribute2023, ConstantIndex.MobileDrivingLicence2023) + ) + } + + test("EUDIW URL") { + val url = + "eudi-openid4ci://credentialsOffer?credential_offer=%7B%22credential_issuer%22:%22https://localhost/pid-issuer%22,%22credential_configuration_ids%22:[%22eu.europa.ec.eudiw.pid_vc_sd_jwt%22],%22grants%22:%7B%22authorization_code%22:%7B%22authorization_server%22:%22https://localhost/idp/realms/pid-issuer-realm%22%7D%7D%7D" + + val client = WalletService( + credentialScheme = ConstantIndex.AtomicAttribute2023, + credentialRepresentation = ConstantIndex.CredentialRepresentation.SD_JWT, + ) + + val credentialOffer = + Url(url).parameters["credential_offer"]?.let { CredentialOffer.deserialize(it).getOrThrow() } + credentialOffer.shouldNotBeNull() + println(credentialOffer) + val credentialIssuerMetadataUrl = credentialOffer.credentialIssuer + PATH_WELL_KNOWN_CREDENTIAL_ISSUER + val credentialIssuerMetadataString = """ + { + "credential_issuer": "https://localhost/pid-issuer", + "authorization_servers": [ + "https://localhost/idp/realms/pid-issuer-realm" + ], + "credential_endpoint": "https://localhost/pid-issuer/wallet/credentialEndpoint", + "deferred_credential_endpoint": "https://localhost/pid-issuer/wallet/deferredEndpoint", + "notification_endpoint": "https://localhost/pid-issuer/wallet/notificationEndpoint", + "credential_response_encryption": { + "alg_values_supported": [ + "RSA-OAEP-256" + ], + "enc_values_supported": [ + "A128CBC-HS256" + ], + "encryption_required": true + }, + "credential_identifiers_supported": true, + "credential_configurations_supported": { + "eu.europa.ec.eudiw.pid_vc_sd_jwt": { + "format": "vc+sd-jwt", + "scope": "eu.europa.ec.eudiw.pid_vc_sd_jwt", + "cryptographic_binding_methods_supported": [ + "jwk" + ], + "credential_signing_alg_values_supported": [ + "ES256" + ], + "proof_types_supported": { + "jwt": { + "proof_signing_alg_values_supported": [ + "RS256", + "ES256" + ] + } + }, + "vct": "eu.europa.ec.eudiw.pid.1", + "display": [ + { + "name": "PID", + "locale": "en", + "logo": { + "uri": "https://examplestate.com/public/mdl.png", + "alt_text": "A square figure of a PID" + } + } + ], + "credential_definition": { + "type": ["eu.europa.ec.eudiw.pid.1"], + "claims": { + "family_name": { + "mandatory": false, + "display": [ + { + "name": "Current Family Name", + "locale": "en" + } + ] + }, + "issuance_date": { + "mandatory": true + } + } + } + } + } + } + """.trimIndent() + + val credentialIssuerMetadata = IssuerMetadata.deserialize(credentialIssuerMetadataString).getOrThrow() + credentialIssuerMetadata.credentialIssuer shouldBe "https://localhost/pid-issuer" + credentialIssuerMetadata.authorizationServers!!.shouldHaveSingleElement("https://localhost/idp/realms/pid-issuer-realm") + credentialIssuerMetadata.credentialEndpointUrl shouldBe "https://localhost/pid-issuer/wallet/credentialEndpoint" + credentialIssuerMetadata.deferredCredentialEndpointUrl shouldBe "https://localhost/pid-issuer/wallet/deferredEndpoint" + credentialIssuerMetadata.notificationEndpointUrl shouldBe "https://localhost/pid-issuer/wallet/notificationEndpoint" + credentialIssuerMetadata.credentialResponseEncryption!!.supportedAlgorithms.shouldHaveSingleElement("RSA-OAEP-256") + credentialIssuerMetadata.credentialResponseEncryption!!.supportedEncryptionAlgorithms!!.shouldHaveSingleElement( + "A128CBC-HS256" + ) + credentialIssuerMetadata.credentialResponseEncryption!!.encryptionRequired shouldBe true + credentialIssuerMetadata.supportsCredentialIdentifiers shouldBe true + // select correct credential config by using a configurationId from the offer it self + val credentialConfig = + credentialIssuerMetadata.supportedCredentialConfigurations!![credentialOffer.configurationIds.first()]!! + credentialConfig.format shouldBe CredentialFormatEnum.VC_SD_JWT + credentialConfig.scope shouldBe "eu.europa.ec.eudiw.pid_vc_sd_jwt" + credentialConfig.supportedBindingMethods!!.shouldHaveSingleElement("jwk") + credentialConfig.supportedSigningAlgorithms!!.shouldHaveSingleElement("ES256") + credentialConfig.supportedProofTypes!!["jwt"]!!.supportedSigningAlgorithms.shouldContainAll("RS256", "ES256") + credentialConfig.sdJwtVcType shouldBe "eu.europa.ec.eudiw.pid.1" + // TODO this is wrong in EUDIW's metadata? Should be an array! credentialConfig.credentialDefinition!!.types + credentialConfig.credentialDefinition!!.claims!!.firstNotNullOfOrNull { it.key == "family_name" } + .shouldNotBeNull() + + val authorizationServerMetadataUrl = + credentialIssuerMetadata.authorizationServers?.firstOrNull()?.plus("/.well-known/openid-configuration") + // need to get from URL and parse ... + val authorizationServerMetadata = IssuerMetadata( + issuer = "https://localhiost/idp/realms/pid-issuer-realm", + authorizationEndpointUrl = "https://localhost/idp/realms/pid-issuer-realm/protocol/openid-connect/auth" + ) + val authorizationEndpoint = credentialIssuerMetadata.authorizationEndpointUrl + ?: authorizationServerMetadata.authorizationEndpointUrl + authorizationEndpoint.shouldNotBeNull() + + // selection of end-user, which credential to get + val scopeToRequest = credentialConfig.scope!! + // would also need to parse from authorizationServerMetadata if `request_parameter_supported` is true and so on ... + val authnRequest = client.createAuthRequest(scopeToRequest, credentialIssuerMetadata.credentialIssuer) + println(URLBuilder(authorizationEndpoint) + .apply { + authnRequest.encodeToParameters().forEach { + this.parameters.append(it.key, it.value) + } + } + .buildString() + ) + // Clients may also need to push the authorization request, which is a FORM POST 5.1.4 + + // TODO Better use https://github.com/eu-digital-identity-wallet/eudi-srv-web-issuing-eudiw-py/blob/main/api_docs/pid_oidc_no_auth.md + // and maybe need to implement `code_challenge` and so on + } + + test("process with pre-authorized code and credential offer") { + val client = WalletService( + credentialScheme = ConstantIndex.AtomicAttribute2023, + credentialRepresentation = ConstantIndex.CredentialRepresentation.SD_JWT, + ) + val credentialOffer = issuer.credentialOffer() + val credentialIssuerMetadata = issuer.metadata + val credentialConfig = + credentialIssuerMetadata.supportedCredentialConfigurations!![credentialOffer.configurationIds.first()]!! + val scopeToRequest = credentialConfig.scope!! + val authnRequest = client.createAuthRequest(scopeToRequest, credentialIssuerMetadata.credentialIssuer) + val authnResponse = issuer.authorize(authnRequest).getOrThrow() + authnResponse.shouldBeInstanceOf() + val code = authnResponse.params.code + code.shouldNotBeNull() + // TODO Provide a way to authenticate the client ... but how? see `token_endpoint_auth_method` in Client Metadata, RFC 6749 + val tokenRequest = client.createTokenRequestParameters(authnResponse.params, credentialOffer) + val token = issuer.token(tokenRequest).getOrThrow() + val credentialRequest = client.createCredentialRequestJwt(token, credentialIssuerMetadata).getOrThrow() + val credential = issuer.credential(token.accessToken, credentialRequest) + + credential.shouldNotBeNull() + } +}) diff --git a/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidvciProcessTest.kt b/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidvciProcessTest.kt index dd75b6b45..dd5dca8c0 100644 --- a/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidvciProcessTest.kt +++ b/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidvciProcessTest.kt @@ -10,7 +10,6 @@ import at.asitplus.wallet.lib.iso.IssuerSigned import at.asitplus.wallet.lib.iso.MobileDrivingLicenceDataElements import at.asitplus.wallet.lib.oidc.AuthenticationResponseResult import at.asitplus.wallet.lib.oidc.DummyCredentialDataProvider -import at.asitplus.wallet.lib.oidc.OpenIdConstants.TOKEN_PREFIX_BEARER import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.ints.shouldBeGreaterThan import io.kotest.matchers.nulls.shouldNotBeNull @@ -35,7 +34,7 @@ class OidvciProcessTest : FunSpec({ credentialScheme = ConstantIndex.AtomicAttribute2023, credentialRepresentation = ConstantIndex.CredentialRepresentation.PLAIN_JWT, ) - val credential = runProcess(issuer, client) + val credential = runProcessWithJwtProof(issuer, client) credential.format shouldBe CredentialFormatEnum.JWT_VC val serializedCredential = credential.credential serializedCredential.shouldNotBeNull().also { println(it) } @@ -51,7 +50,7 @@ class OidvciProcessTest : FunSpec({ credentialScheme = ConstantIndex.AtomicAttribute2023, credentialRepresentation = ConstantIndex.CredentialRepresentation.SD_JWT, ) - val credential = runProcess(issuer, client) + val credential = runProcessWithJwtProof(issuer, client) credential.format shouldBe CredentialFormatEnum.VC_SD_JWT val serializedCredential = credential.credential serializedCredential.shouldNotBeNull().also { println(it) } @@ -69,7 +68,7 @@ class OidvciProcessTest : FunSpec({ credentialRepresentation = ConstantIndex.CredentialRepresentation.SD_JWT, requestedAttributes = listOf("family-name") ) - val credential = runProcess(issuer, client) + val credential = runProcessWithJwtProof(issuer, client) credential.format shouldBe CredentialFormatEnum.VC_SD_JWT val serializedCredential = credential.credential serializedCredential.shouldNotBeNull().also { println(it) } @@ -86,7 +85,7 @@ class OidvciProcessTest : FunSpec({ credentialScheme = ConstantIndex.MobileDrivingLicence2023, credentialRepresentation = ConstantIndex.CredentialRepresentation.ISO_MDOC, ) - val credential = runProcess(issuer, client) + val credential = runProcessWithCwtProof(issuer, client) credential.format shouldBe CredentialFormatEnum.MSO_MDOC val serializedCredential = credential.credential serializedCredential.shouldNotBeNull().also { println(it) } @@ -104,7 +103,7 @@ class OidvciProcessTest : FunSpec({ credentialRepresentation = ConstantIndex.CredentialRepresentation.ISO_MDOC, requestedAttributes = listOf(MobileDrivingLicenceDataElements.DOCUMENT_NUMBER) ) - val credential = runProcess(issuer, client) + val credential = runProcessWithCwtProof(issuer, client) credential.format shouldBe CredentialFormatEnum.MSO_MDOC val serializedCredential = credential.credential serializedCredential.shouldNotBeNull().also { println(it) } @@ -121,7 +120,7 @@ class OidvciProcessTest : FunSpec({ credentialScheme = ConstantIndex.AtomicAttribute2023, credentialRepresentation = ConstantIndex.CredentialRepresentation.ISO_MDOC, ) - val credential = runProcess(issuer, client) + val credential = runProcessWithCwtProof(issuer, client) credential.format shouldBe CredentialFormatEnum.MSO_MDOC val serializedCredential = credential.credential serializedCredential.shouldNotBeNull().also { println(it) } @@ -132,11 +131,28 @@ class OidvciProcessTest : FunSpec({ }) -private suspend fun runProcess( +private suspend fun runProcessWithJwtProof( issuer: IssuerService, client: WalletService ): CredentialResponseParameters { - val metadata = issuer.metadata + val token = runProcessGetToken(client, issuer) + val credentialRequest = client.createCredentialRequestJwt(token, issuer.metadata).getOrThrow() + return issuer.credential(token.accessToken, credentialRequest).getOrThrow() +} + +private suspend fun runProcessWithCwtProof( + issuer: IssuerService, + client: WalletService +): CredentialResponseParameters { + val token = runProcessGetToken(client, issuer) + val credentialRequest = client.createCredentialRequestCwt(token, issuer.metadata).getOrThrow() + return issuer.credential(token.accessToken, credentialRequest).getOrThrow() +} + +private suspend fun runProcessGetToken( + client: WalletService, + issuer: IssuerService +): TokenResponseParameters { val authnRequest = client.createAuthRequest() val authnResponse = issuer.authorize(authnRequest).getOrThrow() authnResponse.shouldBeInstanceOf() @@ -144,6 +160,5 @@ private suspend fun runProcess( code.shouldNotBeNull() val tokenRequest = client.createTokenRequestParameters(authnResponse.params) val token = issuer.token(tokenRequest).getOrThrow() - val credentialRequest = client.createCredentialRequest(token, metadata).getOrThrow() - return issuer.credential(TOKEN_PREFIX_BEARER + token.accessToken, credentialRequest).getOrThrow() + return token } diff --git a/vclib/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/IssuerAgent.kt b/vclib/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/IssuerAgent.kt index 75c09dac3..fe3855b35 100644 --- a/vclib/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/IssuerAgent.kt +++ b/vclib/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/IssuerAgent.kt @@ -36,6 +36,7 @@ import at.asitplus.wallet.lib.jws.JwsContentTypeConstants import at.asitplus.wallet.lib.jws.JwsService import com.benasher44.uuid.uuid4 import io.github.aakira.napier.Napier +import io.ktor.util.* import io.matthewnelson.encoding.core.Encoder.Companion.encodeToString import kotlinx.datetime.Clock import kotlinx.datetime.Instant @@ -103,6 +104,8 @@ class IssuerAgent( val successful = mutableListOf() for (attributeType in attributeTypes) { val scheme = AttributeIndex.resolveAttributeType(attributeType) + ?: AttributeIndex.resolveIsoNamespace(attributeType) + ?: AttributeIndex.resolveSchemaUri(attributeType) if (scheme == null) { failed += Issuer.FailedAttribute(attributeType, IllegalArgumentException("type not resolved to scheme")) continue From 52c01d52f2c1e6284bb6bc7c89de388f0b030471 Mon Sep 17 00:00:00 2001 From: Christian Kollmann Date: Tue, 16 Apr 2024 13:53:01 +0200 Subject: [PATCH 07/18] Move constructor parameters to request options for each method call --- CHANGELOG.md | 1 + .../wallet/lib/oidvci/WalletService.kt | 141 ++++++++++++------ .../wallet/lib/oidvci/OidvciInteropTest.kt | 28 ++-- .../wallet/lib/oidvci/OidvciProcessTest.kt | 98 +++++++----- 4 files changed, 178 insertions(+), 90 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ff7b33781..ece162e60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ Release 3.6.0: - `IssuerService`: Change parameter of `credential()` from `authorizationHeader` to `accessToken`, requiring the plain access token - `WalletService`: Make public API functions suspending - `WalletService`: Implement proving possesion of private key with CBOR Web Tokens + - `WalletService`: Move constructor parameters to `requestOptions` for every method call Release 3.5.0: - Kotlin 1.9.23 diff --git a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/WalletService.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/WalletService.kt index aed2917e6..c255cf10b 100644 --- a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/WalletService.kt +++ b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/WalletService.kt @@ -19,6 +19,7 @@ import at.asitplus.wallet.lib.jws.DefaultJwsService import at.asitplus.wallet.lib.jws.JwsService import at.asitplus.wallet.lib.oidc.AuthenticationRequestParameters import at.asitplus.wallet.lib.oidc.AuthenticationResponseParameters +import at.asitplus.wallet.lib.oidc.OidcSiopVerifier.AuthnResponseResult import at.asitplus.wallet.lib.oidc.OpenIdConstants import at.asitplus.wallet.lib.oidc.OpenIdConstants.CODE_CHALLENGE_METHOD_SHA256 import at.asitplus.wallet.lib.oidc.OpenIdConstants.CREDENTIAL_TYPE_OPENID @@ -39,18 +40,6 @@ import kotlin.random.Random * Implemented from Draft `openid-4-verifiable-credential-issuance-1_0-11`, 2023-02-03. */ class WalletService( - /** - * Credential to request from the issuer. - */ - private val credentialScheme: ConstantIndex.CredentialScheme, - /** - * Representation of the credential to request from the issuer. - */ - private val credentialRepresentation: ConstantIndex.CredentialRepresentation, - /** - * Pass names of attributes the credential shall contain, e.g. [ConstantIndex.CredentialScheme.claimNames]. - */ - private val requestedAttributes: Collection? = null, /** * Used to create [AuthenticationRequestParameters], [TokenRequestParameters] and [CredentialRequestProof], * typically a URI. @@ -78,27 +67,55 @@ class WalletService( private val stateToCodeChallengeMap = mutableMapOf() private val codeChallengeMutex = Mutex() + data class RequestOptions( + /** + * Credential type to request + */ + val credentialScheme: ConstantIndex.CredentialScheme, + /** + * Required representation, see [ConstantIndex.CredentialRepresentation] + */ + val representation: ConstantIndex.CredentialRepresentation = ConstantIndex.CredentialRepresentation.PLAIN_JWT, + /** + * List of attributes that shall be requested explicitly (selective disclosure), + * or `null` to make no restrictions + */ + val requestedAttributes: List? = null, + /** + * Opaque value which will be returned by the OpenId Provider and also in [AuthnResponseResult] + */ + val state: String = uuid4().toString(), + ) + /** * Send the result as parameters (either POST or GET) to the server at `/authorize` (or more specific * [IssuerMetadata.authorizationEndpointUrl]) + * + * @param requestOptions which credential in which representation to request */ suspend fun createAuthRequest( + requestOptions: RequestOptions, credentialIssuer: String? = null, - state: String = uuid4().toString() ) = AuthenticationRequestParameters( responseType = GRANT_TYPE_CODE, - state = state, + state = requestOptions.state, clientId = clientId, - authorizationDetails = credentialRepresentation.toAuthorizationDetails(), + // TODO in authnrequest, and again in tokenrequest? + authorizationDetails = requestOptions.representation.toAuthorizationDetails( + requestOptions.credentialScheme, + requestOptions.requestedAttributes + ), resource = credentialIssuer, redirectUrl = redirectUrl, - codeChallenge = generateCodeVerifier(state), + codeChallenge = generateCodeVerifier(requestOptions.state), codeChallengeMethod = CODE_CHALLENGE_METHOD_SHA256, ) /** * Send the result as parameters (either POST or GET) to the server at `/authorize` (or more specific - * [IssuerMetadata.authorizationEndpointUrl]) + * [IssuerMetadata.authorizationEndpointUrl]). + * + * @param scope Credential to request from the issuer, may be obtained from [IssuerMetadata.supportedCredentialConfigurations], or [SupportedCredentialFormat.scope]. */ suspend fun createAuthRequest( scope: String, @@ -125,31 +142,45 @@ class WalletService( /** * Send the result as POST parameters (form-encoded) to the server at `/token` (or more specific * [IssuerMetadata.tokenEndpointUrl]) + * + * @param requestOptions which credential in which representation to request */ suspend fun createTokenRequestParameters( - params: AuthenticationResponseParameters + params: AuthenticationResponseParameters, + requestOptions: RequestOptions, ) = TokenRequestParameters( grantType = GRANT_TYPE_CODE, code = params.code, redirectUrl = redirectUrl, clientId = clientId, - authorizationDetails = credentialRepresentation.toAuthorizationDetails(), + // TODO in authnrequest, and again in tokenrequest? + authorizationDetails = requestOptions.representation.toAuthorizationDetails( + requestOptions.credentialScheme, + requestOptions.requestedAttributes + ), codeVerifier = codeChallengeMutex.withLock { stateToCodeChallengeMap.remove(params.state) } ) /** * Send the result as POST parameters (form-encoded) to the server at `/token` (or more specific * [IssuerMetadata.tokenEndpointUrl]) + * + * @param requestOptions which credential in which representation to request */ suspend fun createTokenRequestParameters( params: AuthenticationResponseParameters, - credentialOffer: CredentialOffer + credentialOffer: CredentialOffer, + requestOptions: RequestOptions, ) = TokenRequestParameters( grantType = GRANT_TYPE_PRE_AUTHORIZED_CODE, // TODO Verify if `redirect_uri` and `client_id` are even needed redirectUrl = redirectUrl, clientId = clientId, - authorizationDetails = credentialRepresentation.toAuthorizationDetails(), + // TODO in authnrequest, and again in tokenrequest? + authorizationDetails = requestOptions.representation.toAuthorizationDetails( + requestOptions.credentialScheme, + requestOptions.requestedAttributes + ), transactionCode = credentialOffer.grants?.preAuthorizedCode?.transactionCode, preAuthorizedCode = credentialOffer.grants?.preAuthorizedCode?.preAuthorizedCode, codeVerifier = codeChallengeMutex.withLock { stateToCodeChallengeMap.remove(params.state) } @@ -160,10 +191,13 @@ class WalletService( * [IssuerMetadata.credentialEndpointUrl]). * Also send along the [TokenResponseParameters.accessToken] from [tokenResponse] in HTTP header `Authorization` * as value `Bearer accessTokenValue` (depending on the [TokenResponseParameters.tokenType]). + * + * @param requestOptions which credential in which representation to request */ suspend fun createCredentialRequestJwt( tokenResponse: TokenResponseParameters, issuerMetadata: IssuerMetadata, + requestOptions: RequestOptions, ): KmmResult { val proofPayload = jwsService.createSignedJwsAddingParams( header = JwsHeader( @@ -187,7 +221,11 @@ class WalletService( proofType = OpenIdConstants.ProofTypes.JWT, jwt = proofPayload.serialize() ) - val result = credentialRepresentation.toCredentialRequestParameters(proof) + val result = requestOptions.representation.toCredentialRequestParameters( + requestOptions.credentialScheme, + requestOptions.requestedAttributes, + proof + ) Napier.i("createCredentialRequestCwt returns $result") return KmmResult.success(result) } @@ -197,10 +235,13 @@ class WalletService( * [IssuerMetadata.credentialEndpointUrl]). * Also send along the [TokenResponseParameters.accessToken] from [tokenResponse] in HTTP header `Authorization` * as value `Bearer accessTokenValue` (depending on the [TokenResponseParameters.tokenType]). + * + * @param requestOptions which credential in which representation to request */ suspend fun createCredentialRequestCwt( tokenResponse: TokenResponseParameters, issuerMetadata: IssuerMetadata, + requestOptions: RequestOptions, ): KmmResult { val proofPayload = coseService.createSignedCose( protectedHeader = CoseHeader( @@ -222,12 +263,19 @@ class WalletService( proofType = OpenIdConstants.ProofTypes.CWT, cwt = proofPayload.serialize().encodeToString(Base64UrlStrict), ) - val result = credentialRepresentation.toCredentialRequestParameters(proof) + val result = requestOptions.representation.toCredentialRequestParameters( + requestOptions.credentialScheme, + requestOptions.requestedAttributes, + proof + ) Napier.i("createCredentialRequestCwt returns $result") return KmmResult.success(result) } - private fun ConstantIndex.CredentialRepresentation.toAuthorizationDetails() = when (this) { + private fun ConstantIndex.CredentialRepresentation.toAuthorizationDetails( + credentialScheme: ConstantIndex.CredentialScheme, + requestedAttributes: Collection? + ) = when (this) { ConstantIndex.CredentialRepresentation.PLAIN_JWT, ConstantIndex.CredentialRepresentation.SD_JWT -> AuthorizationDetails( type = CREDENTIAL_TYPE_OPENID, @@ -235,38 +283,41 @@ class WalletService( credentialDefinition = SupportedCredentialFormatDefinition( types = listOf(VERIFIABLE_CREDENTIAL, credentialScheme.vcType), ), - claims = requestedAttributes?.toRequestedClaims(), + claims = requestedAttributes?.toRequestedClaims(credentialScheme), ) ConstantIndex.CredentialRepresentation.ISO_MDOC -> AuthorizationDetails( type = CREDENTIAL_TYPE_OPENID, format = toFormat(), docType = credentialScheme.isoDocType, - claims = requestedAttributes?.toRequestedClaims() + claims = requestedAttributes?.toRequestedClaims(credentialScheme) ) } - private fun ConstantIndex.CredentialRepresentation.toCredentialRequestParameters(proof: CredentialRequestProof) = - when (this) { - ConstantIndex.CredentialRepresentation.PLAIN_JWT, - ConstantIndex.CredentialRepresentation.SD_JWT -> CredentialRequestParameters( - format = toFormat(), - claims = requestedAttributes?.toRequestedClaims(), - credentialDefinition = SupportedCredentialFormatDefinition( - types = listOf(VERIFIABLE_CREDENTIAL) + credentialScheme.vcType, - ), - proof = proof - ) + private fun ConstantIndex.CredentialRepresentation.toCredentialRequestParameters( + credentialScheme: ConstantIndex.CredentialScheme, + requestedAttributes: Collection?, + proof: CredentialRequestProof + ) = when (this) { + ConstantIndex.CredentialRepresentation.PLAIN_JWT, + ConstantIndex.CredentialRepresentation.SD_JWT -> CredentialRequestParameters( + format = toFormat(), + claims = requestedAttributes?.toRequestedClaims(credentialScheme), + credentialDefinition = SupportedCredentialFormatDefinition( + types = listOf(VERIFIABLE_CREDENTIAL) + credentialScheme.vcType, + ), + proof = proof + ) - ConstantIndex.CredentialRepresentation.ISO_MDOC -> CredentialRequestParameters( - format = toFormat(), - docType = credentialScheme.isoDocType, - claims = requestedAttributes?.toRequestedClaims(), - proof = proof - ) - } + ConstantIndex.CredentialRepresentation.ISO_MDOC -> CredentialRequestParameters( + format = toFormat(), + docType = credentialScheme.isoDocType, + claims = requestedAttributes?.toRequestedClaims(credentialScheme), + proof = proof + ) + } - private fun Collection.toRequestedClaims() = + private fun Collection.toRequestedClaims(credentialScheme: ConstantIndex.CredentialScheme) = mapOf(credentialScheme.isoNamespace to this.associateWith { RequestedCredentialClaimSpecification() }) } diff --git a/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidvciInteropTest.kt b/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidvciInteropTest.kt index da4352aaf..5a79f4d1c 100644 --- a/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidvciInteropTest.kt +++ b/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidvciInteropTest.kt @@ -36,10 +36,7 @@ class OidvciInteropTest : FunSpec({ val url = "eudi-openid4ci://credentialsOffer?credential_offer=%7B%22credential_issuer%22:%22https://localhost/pid-issuer%22,%22credential_configuration_ids%22:[%22eu.europa.ec.eudiw.pid_vc_sd_jwt%22],%22grants%22:%7B%22authorization_code%22:%7B%22authorization_server%22:%22https://localhost/idp/realms/pid-issuer-realm%22%7D%7D%7D" - val client = WalletService( - credentialScheme = ConstantIndex.AtomicAttribute2023, - credentialRepresentation = ConstantIndex.CredentialRepresentation.SD_JWT, - ) + val client = WalletService() val credentialOffer = Url(url).parameters["credential_offer"]?.let { CredentialOffer.deserialize(it).getOrThrow() } @@ -171,10 +168,7 @@ class OidvciInteropTest : FunSpec({ } test("process with pre-authorized code and credential offer") { - val client = WalletService( - credentialScheme = ConstantIndex.AtomicAttribute2023, - credentialRepresentation = ConstantIndex.CredentialRepresentation.SD_JWT, - ) + val client = WalletService() val credentialOffer = issuer.credentialOffer() val credentialIssuerMetadata = issuer.metadata val credentialConfig = @@ -186,9 +180,23 @@ class OidvciInteropTest : FunSpec({ val code = authnResponse.params.code code.shouldNotBeNull() // TODO Provide a way to authenticate the client ... but how? see `token_endpoint_auth_method` in Client Metadata, RFC 6749 - val tokenRequest = client.createTokenRequestParameters(authnResponse.params, credentialOffer) + val tokenRequest = client.createTokenRequestParameters( + authnResponse.params, + credentialOffer, + WalletService.RequestOptions( + ConstantIndex.AtomicAttribute2023, + representation = ConstantIndex.CredentialRepresentation.SD_JWT + ) + ) val token = issuer.token(tokenRequest).getOrThrow() - val credentialRequest = client.createCredentialRequestJwt(token, credentialIssuerMetadata).getOrThrow() + val credentialRequest = client.createCredentialRequestJwt( + token, + credentialIssuerMetadata, + WalletService.RequestOptions( + ConstantIndex.AtomicAttribute2023, + representation = ConstantIndex.CredentialRepresentation.SD_JWT + ) + ).getOrThrow() val credential = issuer.credential(token.accessToken, credentialRequest) credential.shouldNotBeNull() diff --git a/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidvciProcessTest.kt b/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidvciProcessTest.kt index dd5dca8c0..744e7a82b 100644 --- a/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidvciProcessTest.kt +++ b/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidvciProcessTest.kt @@ -18,6 +18,7 @@ import io.kotest.matchers.types.shouldBeInstanceOf import io.matthewnelson.encoding.base64.Base64 import io.matthewnelson.encoding.core.Decoder.Companion.decodeToByteArray + class OidvciProcessTest : FunSpec({ val dataProvider = DummyCredentialDataProvider() @@ -30,11 +31,15 @@ class OidvciProcessTest : FunSpec({ ) test("process with W3C VC JWT") { - val client = WalletService( - credentialScheme = ConstantIndex.AtomicAttribute2023, - credentialRepresentation = ConstantIndex.CredentialRepresentation.PLAIN_JWT, + val client = WalletService() + val credential = runProcessWithJwtProof( + issuer, + client, + WalletService.RequestOptions( + ConstantIndex.AtomicAttribute2023, + representation = ConstantIndex.CredentialRepresentation.PLAIN_JWT + ) ) - val credential = runProcessWithJwtProof(issuer, client) credential.format shouldBe CredentialFormatEnum.JWT_VC val serializedCredential = credential.credential serializedCredential.shouldNotBeNull().also { println(it) } @@ -46,11 +51,15 @@ class OidvciProcessTest : FunSpec({ } test("process with W3C VC SD-JWT") { - val client = WalletService( - credentialScheme = ConstantIndex.AtomicAttribute2023, - credentialRepresentation = ConstantIndex.CredentialRepresentation.SD_JWT, + val client = WalletService() + val credential = runProcessWithJwtProof( + issuer, + client, + WalletService.RequestOptions( + ConstantIndex.AtomicAttribute2023, + representation = ConstantIndex.CredentialRepresentation.SD_JWT, + ) ) - val credential = runProcessWithJwtProof(issuer, client) credential.format shouldBe CredentialFormatEnum.VC_SD_JWT val serializedCredential = credential.credential serializedCredential.shouldNotBeNull().also { println(it) } @@ -63,12 +72,16 @@ class OidvciProcessTest : FunSpec({ } test("process with W3C VC SD-JWT one requested claim") { - val client = WalletService( - credentialScheme = ConstantIndex.AtomicAttribute2023, - credentialRepresentation = ConstantIndex.CredentialRepresentation.SD_JWT, - requestedAttributes = listOf("family-name") + val client = WalletService() + val credential = runProcessWithJwtProof( + issuer, + client, + WalletService.RequestOptions( + ConstantIndex.AtomicAttribute2023, + representation = ConstantIndex.CredentialRepresentation.SD_JWT, + requestedAttributes = listOf("family-name") + ) ) - val credential = runProcessWithJwtProof(issuer, client) credential.format shouldBe CredentialFormatEnum.VC_SD_JWT val serializedCredential = credential.credential serializedCredential.shouldNotBeNull().also { println(it) } @@ -81,11 +94,15 @@ class OidvciProcessTest : FunSpec({ } test("process with ISO mobile driving licence") { - val client = WalletService( - credentialScheme = ConstantIndex.MobileDrivingLicence2023, - credentialRepresentation = ConstantIndex.CredentialRepresentation.ISO_MDOC, + val client = WalletService() + val credential = runProcessWithCwtProof( + issuer, + client, + WalletService.RequestOptions( + ConstantIndex.MobileDrivingLicence2023, + representation = ConstantIndex.CredentialRepresentation.ISO_MDOC, + ) ) - val credential = runProcessWithCwtProof(issuer, client) credential.format shouldBe CredentialFormatEnum.MSO_MDOC val serializedCredential = credential.credential serializedCredential.shouldNotBeNull().also { println(it) } @@ -98,12 +115,16 @@ class OidvciProcessTest : FunSpec({ } test("process with ISO mobile driving licence one requested claim") { - val client = WalletService( - credentialScheme = ConstantIndex.MobileDrivingLicence2023, - credentialRepresentation = ConstantIndex.CredentialRepresentation.ISO_MDOC, - requestedAttributes = listOf(MobileDrivingLicenceDataElements.DOCUMENT_NUMBER) + val client = WalletService() + val credential = runProcessWithCwtProof( + issuer, + client, + WalletService.RequestOptions( + ConstantIndex.MobileDrivingLicence2023, + representation = ConstantIndex.CredentialRepresentation.ISO_MDOC, + requestedAttributes = listOf(MobileDrivingLicenceDataElements.DOCUMENT_NUMBER) + ) ) - val credential = runProcessWithCwtProof(issuer, client) credential.format shouldBe CredentialFormatEnum.MSO_MDOC val serializedCredential = credential.credential serializedCredential.shouldNotBeNull().also { println(it) } @@ -116,11 +137,15 @@ class OidvciProcessTest : FunSpec({ } test("process with ISO atomic attributes") { - val client = WalletService( - credentialScheme = ConstantIndex.AtomicAttribute2023, - credentialRepresentation = ConstantIndex.CredentialRepresentation.ISO_MDOC, + val client = WalletService() + val credential = runProcessWithCwtProof( + issuer, + client, + WalletService.RequestOptions( + ConstantIndex.AtomicAttribute2023, + representation = ConstantIndex.CredentialRepresentation.ISO_MDOC + ) ) - val credential = runProcessWithCwtProof(issuer, client) credential.format shouldBe CredentialFormatEnum.MSO_MDOC val serializedCredential = credential.credential serializedCredential.shouldNotBeNull().also { println(it) } @@ -133,32 +158,35 @@ class OidvciProcessTest : FunSpec({ private suspend fun runProcessWithJwtProof( issuer: IssuerService, - client: WalletService + client: WalletService, + requestOptions: WalletService.RequestOptions, ): CredentialResponseParameters { - val token = runProcessGetToken(client, issuer) - val credentialRequest = client.createCredentialRequestJwt(token, issuer.metadata).getOrThrow() + val token = runProcessGetToken(client, issuer, requestOptions) + val credentialRequest = client.createCredentialRequestJwt(token, issuer.metadata, requestOptions).getOrThrow() return issuer.credential(token.accessToken, credentialRequest).getOrThrow() } private suspend fun runProcessWithCwtProof( issuer: IssuerService, - client: WalletService + client: WalletService, + requestOptions: WalletService.RequestOptions, ): CredentialResponseParameters { - val token = runProcessGetToken(client, issuer) - val credentialRequest = client.createCredentialRequestCwt(token, issuer.metadata).getOrThrow() + val token = runProcessGetToken(client, issuer, requestOptions) + val credentialRequest = client.createCredentialRequestCwt(token, issuer.metadata, requestOptions).getOrThrow() return issuer.credential(token.accessToken, credentialRequest).getOrThrow() } private suspend fun runProcessGetToken( client: WalletService, - issuer: IssuerService + issuer: IssuerService, + requestOptions: WalletService.RequestOptions, ): TokenResponseParameters { - val authnRequest = client.createAuthRequest() + val authnRequest = client.createAuthRequest(requestOptions) val authnResponse = issuer.authorize(authnRequest).getOrThrow() authnResponse.shouldBeInstanceOf() val code = authnResponse.params.code code.shouldNotBeNull() - val tokenRequest = client.createTokenRequestParameters(authnResponse.params) + val tokenRequest = client.createTokenRequestParameters(authnResponse.params, requestOptions) val token = issuer.token(tokenRequest).getOrThrow() return token } From 42ed6d4f87fa7580167ce10223141bb0933d8929 Mon Sep 17 00:00:00 2001 From: Christian Kollmann Date: Tue, 16 Apr 2024 20:01:28 +0200 Subject: [PATCH 08/18] OID4VCI: Extract separate authorization service --- CHANGELOG.md | 1 + .../wallet/lib/oidvci/AuthorizationService.kt | 178 +++++++++++++ .../asitplus/wallet/lib/oidvci/Extensions.kt | 72 ++++++ .../wallet/lib/oidvci/IssuerService.kt | 238 ++---------------- .../lib/oidvci/OpenIdAuthorizationServer.kt | 36 +++ .../wallet/lib/oidvci/OidvciInteropTest.kt | 9 +- .../wallet/lib/oidvci/OidvciProcessTest.kt | 23 +- 7 files changed, 339 insertions(+), 218 deletions(-) create mode 100644 vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/AuthorizationService.kt create mode 100644 vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/Extensions.kt create mode 100644 vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/OpenIdAuthorizationServer.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index ece162e60..3985aa1d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Release 3.6.0: - Implement RFC 7636 Proof Key for Code Exchange for OpenID for Verifiable Credential Issuance implementations, i.e. `IssuerService` and `WalletService` - `IssuerService`: Make public API functions suspending, also return `KmmResult` to transport exceptions - `IssuerService`: Change parameter of `credential()` from `authorizationHeader` to `accessToken`, requiring the plain access token + - `IssuerService`: Extract responsibilities of an OAuth Authorizaiton Server into `AuthorizationService` - `WalletService`: Make public API functions suspending - `WalletService`: Implement proving possesion of private key with CBOR Web Tokens - `WalletService`: Move constructor parameters to `requestOptions` for every method call diff --git a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/AuthorizationService.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/AuthorizationService.kt new file mode 100644 index 000000000..89e8430a3 --- /dev/null +++ b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/AuthorizationService.kt @@ -0,0 +1,178 @@ +package at.asitplus.wallet.lib.oidvci + +import at.asitplus.KmmResult +import at.asitplus.crypto.datatypes.io.Base64UrlStrict +import at.asitplus.wallet.lib.agent.EmptyCredentialDataProvider +import at.asitplus.wallet.lib.agent.IssuerCredentialDataProvider +import at.asitplus.wallet.lib.data.ConstantIndex +import at.asitplus.wallet.lib.iso.sha256 +import at.asitplus.wallet.lib.oidc.AuthenticationRequestParameters +import at.asitplus.wallet.lib.oidc.AuthenticationResponseParameters +import at.asitplus.wallet.lib.oidc.AuthenticationResponseResult +import at.asitplus.wallet.lib.oidc.OpenIdConstants +import at.asitplus.wallet.lib.oidc.OpenIdConstants.Errors +import io.github.aakira.napier.Napier +import io.ktor.http.* +import io.matthewnelson.encoding.core.Encoder.Companion.encodeToString +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * Simple authorization server implementation, to be used for [IssuerService], + * when issuing credentials directly from a local [dataProvider]. + * + * Implemented from [OpenID for Verifiable Credential Issuance] + * (https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html), Draft 13, 2024-02-08. + */ +class AuthorizationService( + /** + * Source of user data. + */ + private val dataProvider: IssuerCredentialDataProvider = EmptyCredentialDataProvider, + /** + * List of supported schemes. + */ + private val credentialSchemes: Collection, + /** + * Used to create and verify authorization codes during issuing. + */ + private val codeService: CodeService = DefaultCodeService(), + /** + * Used to create and verify bearer tokens during issuing. + */ + private val tokenService: TokenService = DefaultTokenService(), + /** + * Used to provide challenge to clients to include in proof of possession of key material. + */ + private val clientNonceService: NonceService = DefaultNonceService(), + /** + * Used in several fields in [IssuerMetadata], to provide endpoint URLs to clients. + */ + override val publicContext: String = "https://wallet.a-sit.at/", + /** + * Used to build [IssuerMetadata.authorizationEndpointUrl], i.e. implementers need to forward requests + * to that URI (which starts with [publicContext]) to [authorize]. + */ + private val authorizationEndpointPath: String = "/authorize", + /** + * Used to build [IssuerMetadata.tokenEndpointUrl], i.e. implementers need to forward requests + * to that URI (which starts with [publicContext]) to [token]. + */ + private val tokenEndpointPath: String = "/token", +) : OpenIdAuthorizationServer { + + private val codeToCodeChallengeMap = mutableMapOf() + private val codeChallengeMutex = Mutex() + + /** + * Serve this result JSON-serialized under `/.well-known/openid-configuration` + */ + override val metadata: IssuerMetadata by lazy { + IssuerMetadata( + issuer = publicContext, + authorizationEndpointUrl = "$publicContext$authorizationEndpointPath", + tokenEndpointUrl = "$publicContext$tokenEndpointPath", + ) + } + + /** + * Builds the authentication response. + * Send this result as HTTP Header `Location` in a 302 response to the client. + * @return URL build from client's `redirect_uri` with a `code` query parameter containing a fresh authorization + * code from [codeService]. + */ + override suspend fun authorize(request: AuthenticationRequestParameters): KmmResult { + // TODO Need to store the `scope` or `authorization_details`, i.e. may respond with `invalid_scope` here! + if (request.redirectUrl == null) + return KmmResult.failure( + OAuth2Exception(OpenIdConstants.Errors.INVALID_REQUEST, "redirect_uri not set") + ).also { Napier.w("authorize: client did not set redirect_uri in $request") } + val code = codeService.provideCode() + val responseParams = AuthenticationResponseParameters( + code = code, + state = request.state, + ) + if (request.codeChallenge != null) { + codeChallengeMutex.withLock { codeToCodeChallengeMap[code] = request.codeChallenge } + } + // TODO Also implement POST? + val url = URLBuilder(request.redirectUrl) + .apply { responseParams.encodeToParameters().forEach { this.parameters.append(it.key, it.value) } } + .buildString() + val result = AuthenticationResponseResult.Redirect(url, responseParams) + Napier.i("authorize returns $result") + return KmmResult.success(result) + } + + /** + * Verifies the authorization code sent by the client and issues an access token. + * Send this value JSON-serialized back to the client. + * + * @return [KmmResult] may contain a [OAuth2Exception] + */ + override suspend fun token(params: TokenRequestParameters): KmmResult { + // TODO This is part of the Authorization Server + when (params.grantType) { + OpenIdConstants.GRANT_TYPE_CODE -> if (params.code == null || !codeService.verifyCode(params.code)) + return KmmResult.failure(OAuth2Exception(OpenIdConstants.Errors.INVALID_CODE)) + .also { Napier.w("token: client did not provide correct code") } + + OpenIdConstants.GRANT_TYPE_PRE_AUTHORIZED_CODE -> if (params.preAuthorizedCode == null || + !codeService.verifyCode(params.preAuthorizedCode) + ) return KmmResult.failure(OAuth2Exception(OpenIdConstants.Errors.INVALID_GRANT)) + .also { Napier.w("token: client did not provide pre authorized code") } + + else -> + return KmmResult.failure( + OAuth2Exception(OpenIdConstants.Errors.INVALID_REQUEST, "No valid grant_type") + ).also { Napier.w("token: client did not provide valid grant_type: ${params.grantType}") } + } + if (params.authorizationDetails != null) { + // TODO verify params.authorizationDetails.claims and so on + params.authorizationDetails.credentialIdentifiers?.forEach { credentialIdentifier -> + if (!credentialSchemes.map { it.vcType }.contains(credentialIdentifier)) { + return KmmResult.failure(OAuth2Exception(OpenIdConstants.Errors.INVALID_GRANT)) + .also { Napier.w("token: client requested invalid credential identifier: $credentialIdentifier") } + } + } + } + params.codeVerifier?.let { codeVerifier -> + val codeChallenge = codeChallengeMutex.withLock { codeToCodeChallengeMap.remove(params.code) } + val codeChallengeCalculated = codeVerifier.encodeToByteArray().sha256() + .encodeToString(Base64UrlStrict) + if (codeChallenge != codeChallengeCalculated) { + return KmmResult.failure(OAuth2Exception(OpenIdConstants.Errors.INVALID_GRANT)) + .also { Napier.w("token: client did not provide correct code verifier: $codeVerifier") } + } + } + val result = TokenResponseParameters( + accessToken = tokenService.provideToken(), + tokenType = OpenIdConstants.TOKEN_TYPE_BEARER, + expires = 3600, + clientNonce = clientNonceService.provideNonce(), + authorizationDetails = params.authorizationDetails?.let { + // TODO supported credential identifiers! + listOf(it) + } + ) + Napier.i("token returns $result") + return KmmResult.success(result) + } + + override fun providePreAuthorizedCode(): String { + return codeService.provideCode() + } + + override fun verifyAndRemoveClientNonce(nonce: String): Boolean { + return clientNonceService.verifyAndRemoveNonce(nonce) + } + + override fun getUserInfo(accessToken: String): KmmResult { + if (!tokenService.verifyToken(accessToken)) { + return KmmResult.failure(OAuth2Exception(Errors.INVALID_TOKEN)) + .also { Napier.w("verifyToken: client did not provide correct token: $accessToken") } + } + return KmmResult.success(Unit) + } + +} \ No newline at end of file diff --git a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/Extensions.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/Extensions.kt new file mode 100644 index 000000000..0baedfefc --- /dev/null +++ b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/Extensions.kt @@ -0,0 +1,72 @@ +package at.asitplus.wallet.lib.oidvci + +import at.asitplus.crypto.datatypes.CryptoAlgorithm +import at.asitplus.crypto.datatypes.io.Base64UrlStrict +import at.asitplus.crypto.datatypes.jws.toJwsAlgorithm +import at.asitplus.wallet.lib.agent.Issuer +import at.asitplus.wallet.lib.data.ConstantIndex +import at.asitplus.wallet.lib.data.VcDataModelConstants +import at.asitplus.wallet.lib.oidc.OpenIdConstants +import at.asitplus.wallet.lib.oidvci.mdl.RequestedCredentialClaimSpecification +import io.matthewnelson.encoding.core.Encoder.Companion.encodeToString + +fun ConstantIndex.CredentialScheme.toSupportedCredentialFormat(cryptoAlgorithms: List) = mapOf( + this.isoNamespace to SupportedCredentialFormat( + format = CredentialFormatEnum.MSO_MDOC, + scope = vcType, + docType = isoDocType, + claims = mapOf( + isoNamespace to claimNames.associateWith { RequestedCredentialClaimSpecification() } + ), + supportedBindingMethods = listOf(OpenIdConstants.BINDING_METHOD_COSE_KEY), + supportedSigningAlgorithms = cryptoAlgorithms.map { it.toJwsAlgorithm().identifier }, + ), + "$vcType-${CredentialFormatEnum.JWT_VC.text}" to SupportedCredentialFormat( + format = CredentialFormatEnum.JWT_VC, + scope = vcType, + credentialDefinition = SupportedCredentialFormatDefinition( + types = listOf(VcDataModelConstants.VERIFIABLE_CREDENTIAL, vcType), + credentialSubject = claimNames.associateWith { CredentialSubjectMetadataSingle() } + ), + supportedBindingMethods = listOf(OpenIdConstants.PREFIX_DID_KEY, OpenIdConstants.URN_TYPE_JWK_THUMBPRINT), + supportedSigningAlgorithms = cryptoAlgorithms.map { it.toJwsAlgorithm().identifier }, + ), + "$vcType-${CredentialFormatEnum.VC_SD_JWT.text}" to SupportedCredentialFormat( + format = CredentialFormatEnum.VC_SD_JWT, + scope = vcType, + sdJwtVcType = vcType, + claims = mapOf( + isoNamespace to claimNames.associateWith { RequestedCredentialClaimSpecification() } + ), + supportedBindingMethods = listOf(OpenIdConstants.PREFIX_DID_KEY, OpenIdConstants.URN_TYPE_JWK_THUMBPRINT), + supportedSigningAlgorithms = cryptoAlgorithms.map { it.toJwsAlgorithm().identifier }, + ) +) + +fun CredentialFormatEnum.toRepresentation() = when (this) { + CredentialFormatEnum.JWT_VC_SD_UNOFFICIAL -> ConstantIndex.CredentialRepresentation.SD_JWT + CredentialFormatEnum.VC_SD_JWT -> ConstantIndex.CredentialRepresentation.SD_JWT + CredentialFormatEnum.MSO_MDOC -> ConstantIndex.CredentialRepresentation.ISO_MDOC + else -> ConstantIndex.CredentialRepresentation.PLAIN_JWT +} + +fun Issuer.IssuedCredential.toCredentialResponseParameters() = when (this) { + is Issuer.IssuedCredential.Iso -> CredentialResponseParameters( + format = CredentialFormatEnum.MSO_MDOC, + credential = issuerSigned.serialize().encodeToString(Base64UrlStrict), + ) + + is Issuer.IssuedCredential.VcJwt -> CredentialResponseParameters( + format = CredentialFormatEnum.JWT_VC, + credential = vcJws, + ) + + is Issuer.IssuedCredential.VcSdJwt -> CredentialResponseParameters( + format = CredentialFormatEnum.VC_SD_JWT, + credential = vcSdJwt, + ) +} + +class OAuth2Exception(val error: String, val errorDescription: String? = null) : Throwable(error) { + +} \ No newline at end of file diff --git a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/IssuerService.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/IssuerService.kt index 7a28ecace..e5c15748e 100644 --- a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/IssuerService.kt +++ b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/IssuerService.kt @@ -6,38 +6,26 @@ import at.asitplus.crypto.datatypes.cose.CoseSigned import at.asitplus.crypto.datatypes.io.Base64UrlStrict import at.asitplus.crypto.datatypes.jws.JsonWebToken import at.asitplus.crypto.datatypes.jws.JwsSigned -import at.asitplus.crypto.datatypes.jws.toJwsAlgorithm import at.asitplus.crypto.datatypes.pki.X509Certificate import at.asitplus.wallet.lib.agent.Issuer import at.asitplus.wallet.lib.data.ConstantIndex -import at.asitplus.wallet.lib.data.VcDataModelConstants.VERIFIABLE_CREDENTIAL -import at.asitplus.wallet.lib.iso.sha256 -import at.asitplus.wallet.lib.oidc.AuthenticationRequestParameters -import at.asitplus.wallet.lib.oidc.AuthenticationResponseParameters -import at.asitplus.wallet.lib.oidc.AuthenticationResponseResult -import at.asitplus.wallet.lib.oidc.OpenIdConstants.BINDING_METHOD_COSE_KEY import at.asitplus.wallet.lib.oidc.OpenIdConstants.Errors -import at.asitplus.wallet.lib.oidc.OpenIdConstants.GRANT_TYPE_CODE -import at.asitplus.wallet.lib.oidc.OpenIdConstants.GRANT_TYPE_PRE_AUTHORIZED_CODE -import at.asitplus.wallet.lib.oidc.OpenIdConstants.PREFIX_DID_KEY import at.asitplus.wallet.lib.oidc.OpenIdConstants.ProofTypes -import at.asitplus.wallet.lib.oidc.OpenIdConstants.TOKEN_TYPE_BEARER -import at.asitplus.wallet.lib.oidc.OpenIdConstants.URN_TYPE_JWK_THUMBPRINT -import at.asitplus.wallet.lib.oidvci.mdl.RequestedCredentialClaimSpecification import com.benasher44.uuid.uuid4 import io.github.aakira.napier.Napier -import io.ktor.http.* import io.matthewnelson.encoding.core.Decoder.Companion.decodeToByteArray -import io.matthewnelson.encoding.core.Encoder.Companion.encodeToString -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock /** - * Server implementation to issue credentials using - * [OpenID for Verifiable Credential Issuance](https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html). - * Implemented from Draft `openid-4-verifiable-credential-issuance-1_0-11`, 2023-02-03. + * Server implementation to issue credentials using OID4VCI. + * + * Implemented from [OpenID for Verifiable Credential Issuance] + * (https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html), Draft 13, 2024-02-08. */ class IssuerService( + /** + * Used to get the user data, and access tokens. + */ + private val authorizationService: OpenIdAuthorizationServer, /** * Used to actually issue the credential. */ @@ -46,36 +34,10 @@ class IssuerService( * List of supported schemes. */ private val credentialSchemes: Collection, - /** - * Used to create and verify authorization codes during issuing. - */ - private val codeService: CodeService = DefaultCodeService(), - /** - * Used to create and verify bearer tokens during issuing. - */ - private val tokenService: TokenService = DefaultTokenService(), - /** - * Used to provide challenge to clients to include in proof of posession of key material. - */ - private val clientNonceService: NonceService = DefaultNonceService(), - /** - * Used as [IssuerMetadata.authorizationServers]. - */ - private val authorizationServer: String? = null, /** * Used in several fields in [IssuerMetadata], to provide endpoint URLs to clients. */ private val publicContext: String = "https://wallet.a-sit.at/", - /** - * Used to build [IssuerMetadata.authorizationEndpointUrl], i.e. implementers need to forward requests - * to that URI (which starts with [publicContext]) to [authorize]. - */ - private val authorizationEndpointPath: String = "/authorize", - /** - * Used to build [IssuerMetadata.tokenEndpointUrl], i.e. implementers need to forward requests - * to that URI (which starts with [publicContext]) to [token]. - */ - private val tokenEndpointPath: String = "/token", /** * Used to build [IssuerMetadata.credentialEndpointUrl], i.e. implementers need to forward requests * to that URI (which starts with [publicContext]) to [credential]. @@ -83,9 +45,6 @@ class IssuerService( private val credentialEndpointPath: String = "/credential", ) { - private val codeToCodeChallengeMap = mutableMapOf() - private val codeChallengeMutex = Mutex() - /** * Serve this result JSON-serialized under `/.well-known/openid-credential-issuer` */ @@ -93,175 +52,53 @@ class IssuerService( IssuerMetadata( issuer = publicContext, credentialIssuer = publicContext, - authorizationServers = authorizationServer?.let { listOf(it) }, - authorizationEndpointUrl = "$publicContext$authorizationEndpointPath", - tokenEndpointUrl = "$publicContext$tokenEndpointPath", + authorizationServers = listOf(authorizationService.publicContext), credentialEndpointUrl = "$publicContext$credentialEndpointPath", supportedCredentialConfigurations = mutableMapOf().apply { - credentialSchemes.forEach { putAll(it.toSupportedCredentialFormat()) } + credentialSchemes.forEach { putAll(it.toSupportedCredentialFormat(issuer.cryptoAlgorithms)) } }, supportsCredentialIdentifiers = true, displayProperties = credentialSchemes.map { DisplayProperties(it.vcType, "en") } ) } - private fun ConstantIndex.CredentialScheme.toSupportedCredentialFormat() = mapOf( - this.isoNamespace to SupportedCredentialFormat( - format = CredentialFormatEnum.MSO_MDOC, - scope = vcType, - docType = isoDocType, - claims = mapOf( - isoNamespace to claimNames.associateWith { RequestedCredentialClaimSpecification() } - ), - supportedBindingMethods = listOf(BINDING_METHOD_COSE_KEY), - supportedSigningAlgorithms = issuer.cryptoAlgorithms.map { it.toJwsAlgorithm().identifier }, - ), - "$vcType-${CredentialFormatEnum.JWT_VC.text}" to SupportedCredentialFormat( - format = CredentialFormatEnum.JWT_VC, - scope = vcType, - credentialDefinition = SupportedCredentialFormatDefinition( - types = listOf(VERIFIABLE_CREDENTIAL, vcType), - credentialSubject = claimNames.associateWith { CredentialSubjectMetadataSingle() } - ), - supportedBindingMethods = listOf(PREFIX_DID_KEY, URN_TYPE_JWK_THUMBPRINT), - supportedSigningAlgorithms = issuer.cryptoAlgorithms.map { it.toJwsAlgorithm().identifier }, - ), - "$vcType-${CredentialFormatEnum.VC_SD_JWT.text}" to SupportedCredentialFormat( - format = CredentialFormatEnum.VC_SD_JWT, - scope = vcType, - sdJwtVcType = vcType, - claims = mapOf( - isoNamespace to claimNames.associateWith { RequestedCredentialClaimSpecification() } - ), - supportedBindingMethods = listOf(PREFIX_DID_KEY, URN_TYPE_JWK_THUMBPRINT), - supportedSigningAlgorithms = issuer.cryptoAlgorithms.map { it.toJwsAlgorithm().identifier }, - ) - ) - /** * Offer all [credentialSchemes] to clients. * Callers may need to transport this in [CredentialOfferUrlParameters] to (HTTPS) clients. */ fun credentialOffer(): CredentialOffer = CredentialOffer( credentialIssuer = publicContext, - configurationIds = credentialSchemes.flatMap { it.toSupportedCredentialFormat().keys }, + configurationIds = credentialSchemes.flatMap { it.toSupportedCredentialFormat(issuer.cryptoAlgorithms).keys }, grants = CredentialOfferGrants( authorizationCode = CredentialOfferGrantsAuthCode( issuerState = uuid4().toString(), // TODO remember this state, for subsequent requests from the Wallet - authorizationServer = publicContext // may need to support external AS? + authorizationServer = authorizationService.publicContext ), preAuthorizedCode = CredentialOfferGrantsPreAuthCode( - preAuthorizedCode = codeService.provideCode(), + preAuthorizedCode = authorizationService.providePreAuthorizedCode(), transactionCode = CredentialOfferGrantsPreAuthCodeTransactionCode( inputMode = "numeric", length = 16, ), - authorizationServer = publicContext // may need to support external AS?, + authorizationServer = authorizationService.publicContext ) ) ) /** - * Builds the authentication response. - * Send this result as HTTP Header `Location` in a 302 response to the client. - * @return URL build from client's `redirect_uri` with a `code` query parameter containing a fresh authorization - * code from [codeService]. - */ - suspend fun authorize(request: AuthenticationRequestParameters): KmmResult { - // TODO Need to store the `scope` or `authorization_details`, i.e. may respond with `invalid_scope` here! - if (request.redirectUrl == null) - return KmmResult.failure( - OAuth2Exception(Errors.INVALID_REQUEST, "redirect_uri not set") - ).also { Napier.w("authorize: client did not set redirect_uri in $request") } - val code = codeService.provideCode() - val responseParams = AuthenticationResponseParameters( - code = code, - state = request.state, - ) - if (request.codeChallenge != null) { - codeChallengeMutex.withLock { - codeToCodeChallengeMap[code] = request.codeChallenge - } - } - // TODO Also implement POST? - val url = URLBuilder(request.redirectUrl) - .apply { responseParams.encodeToParameters().forEach { this.parameters.append(it.key, it.value) } } - .buildString() - val result = AuthenticationResponseResult.Redirect(url, responseParams) - Napier.i("authorize returns $result") - return KmmResult.success(result) - } - - /** - * Verifies the authorization code sent by the client and issues an access token. - * Send this value JSON-serialized back to the client. - * - * @return [KmmResult] may contain a [OAuth2Exception] - */ - suspend fun token(params: TokenRequestParameters): KmmResult { - // TODO This is part of the Authorization Server - when (params.grantType) { - GRANT_TYPE_CODE -> if (params.code == null || !codeService.verifyCode(params.code)) - return KmmResult.failure(OAuth2Exception(Errors.INVALID_CODE)) - .also { Napier.w("token: client did not provide correct code") } - - GRANT_TYPE_PRE_AUTHORIZED_CODE -> if (params.preAuthorizedCode == null || !codeService.verifyCode(params.preAuthorizedCode)) - return KmmResult.failure(OAuth2Exception(Errors.INVALID_GRANT)) - .also { Napier.w("token: client did not provide pre authorized code") } - - else -> - return KmmResult.failure( - OAuth2Exception(Errors.INVALID_REQUEST, "No valid grant_type") - ).also { Napier.w("token: client did not provide valid grant_type: ${params.grantType}") } - } - if (params.authorizationDetails != null) { - // TODO verify params.authorizationDetails.claims and so on - params.authorizationDetails.credentialIdentifiers?.forEach { credentialIdentifier -> - if (!credentialSchemes.map { it.vcType }.contains(credentialIdentifier)) { - return KmmResult.failure(OAuth2Exception(Errors.INVALID_GRANT)) - .also { Napier.w("token: client requested invalid credential identifier: $credentialIdentifier") } - } - } - } - params.codeVerifier?.let { codeVerifier -> - val codeChallenge = codeChallengeMutex.withLock { codeToCodeChallengeMap.remove(params.code) } - val codeChallengeCalculated = codeVerifier.encodeToByteArray().sha256() - .encodeToString(Base64UrlStrict) - if (codeChallenge != codeChallengeCalculated) { - return KmmResult.failure(OAuth2Exception(Errors.INVALID_GRANT)) - .also { Napier.w("token: client did not provide correct code verifier: $codeVerifier") } - } - } - val result = TokenResponseParameters( - accessToken = tokenService.provideToken(), - tokenType = TOKEN_TYPE_BEARER, - expires = 3600, - clientNonce = clientNonceService.provideNonce(), - authorizationDetails = params.authorizationDetails?.let { - // TODO supported credential identifiers! - listOf(it) - } - ) - Napier.i("token returns $result") - return KmmResult.success(result) - } - - /** - * Verifies the [accessToken] to contain a token from [tokenService], - * verifies the proof sent by the client (must contain a nonce from [clientNonceService]), + * Verifies the [accessToken] to contain a token from [authorizationService], + * verifies the proof sent by the client (must contain a nonce sent from [authorizationService]), * and issues credentials to the client. * Send the result JSON-serialized back to the client. * - * @param accessToken The value of HTTP header `Authorization` sent by the client, with the prefix `Bearer ` removed, so the plain access token + * @param accessToken The value of HTTP header `Authorization` sent by the client, + * with the prefix `Bearer ` removed, so the plain access token * @param params Parameters the client sent JSON-serialized in the HTTP body */ suspend fun credential( accessToken: String, params: CredentialRequestParameters ): KmmResult { - if (!tokenService.verifyToken(accessToken)) - return KmmResult.failure(OAuth2Exception(Errors.INVALID_TOKEN)) - .also { Napier.w("credential: client did not provide correct token: $accessToken") } val proof = params.proof ?: return KmmResult.failure(OAuth2Exception(Errors.INVALID_REQUEST)) .also { Napier.w("credential: client did not provide proof of possession") } @@ -276,7 +113,7 @@ class IssuerService( val jwt = JsonWebToken.deserialize(jwsSigned.payload.decodeToString()).getOrNull() ?: return KmmResult.failure(OAuth2Exception(Errors.INVALID_PROOF)) .also { Napier.w("credential: client did provide invalid JWT in proof: $proof") } - if (jwt.nonce == null || !clientNonceService.verifyAndRemoveNonce(jwt.nonce!!)) + if (jwt.nonce == null || !authorizationService.verifyAndRemoveClientNonce(jwt.nonce!!)) return KmmResult.failure(OAuth2Exception(Errors.INVALID_PROOF)) .also { Napier.w("credential: client did provide invalid nonce in JWT in proof: ${jwt.nonce}") } if (jwsSigned.header.type != ProofTypes.JWT_HEADER_TYPE) @@ -289,6 +126,7 @@ class IssuerService( ?: return KmmResult.failure(OAuth2Exception(Errors.INVALID_PROOF)) .also { Napier.w("credential: client did provide no valid key in header in JWT in proof: ${jwsSigned.header}") } } + ProofTypes.CWT -> { if (proof.cwt == null) return KmmResult.failure(OAuth2Exception(Errors.INVALID_PROOF)) @@ -299,7 +137,7 @@ class IssuerService( val cwt = coseSigned.payload?.let { CborWebToken.deserialize(it).getOrNull() } ?: return KmmResult.failure(OAuth2Exception(Errors.INVALID_PROOF)) .also { Napier.w("credential: client did provide invalid CWT in proof: $proof") } - if (cwt.nonce == null || !clientNonceService.verifyAndRemoveNonce(cwt.nonce!!.decodeToString())) + if (cwt.nonce == null || !authorizationService.verifyAndRemoveClientNonce(cwt.nonce!!.decodeToString())) return KmmResult.failure(OAuth2Exception(Errors.INVALID_PROOF)) .also { Napier.w("credential: client did provide invalid nonce in CWT in proof: ${cwt.nonce}") } val header = coseSigned.protectedHeader.value @@ -313,12 +151,18 @@ class IssuerService( ?: return KmmResult.failure(OAuth2Exception(Errors.INVALID_PROOF)) .also { Napier.w("credential: client did provide no valid key in header in CWT in proof: $header") } } + else -> { return KmmResult.failure(OAuth2Exception(Errors.INVALID_PROOF)) .also { Napier.w("credential: client did provide invalid proof type: ${proof.proofType}") } } } + val userInfo = authorizationService.getUserInfo(accessToken).getOrNull() + ?: return KmmResult.failure(OAuth2Exception(Errors.INVALID_TOKEN)) + .also { Napier.w("credential: client did not provide correct token: $accessToken") } + // NOTE: In a real deployment, the Credential Issuer would send the access token + // to the Authorization Server's User Info endpoint to receive the user's attributes val issuedCredentialResult = when { params.format != null -> { issuer.issueCredential( @@ -335,7 +179,6 @@ class IssuerService( val representation = CredentialFormatEnum.parse(params.credentialIdentifier.substringAfterLast("-")) ?: return KmmResult.failure(OAuth2Exception(Errors.INVALID_REQUEST)) .also { Napier.w("credential: client did not provide correct credential identifier: ${params.credentialIdentifier}") } - // TODO what to do in case of ISO, look at string constants from EUDIW val vcType = params.credentialIdentifier.substringBeforeLast("-") issuer.issueCredential( subjectPublicKey = subjectPublicKey, @@ -360,32 +203,5 @@ class IssuerService( return KmmResult.success(result) } - private fun Issuer.IssuedCredential.toCredentialResponseParameters() = when (this) { - is Issuer.IssuedCredential.Iso -> CredentialResponseParameters( - format = CredentialFormatEnum.MSO_MDOC, - credential = issuerSigned.serialize().encodeToString(Base64UrlStrict), - ) - - is Issuer.IssuedCredential.VcJwt -> CredentialResponseParameters( - format = CredentialFormatEnum.JWT_VC, - credential = vcJws, - ) - - is Issuer.IssuedCredential.VcSdJwt -> CredentialResponseParameters( - format = CredentialFormatEnum.VC_SD_JWT, - credential = vcSdJwt, - ) - } - -} -private fun CredentialFormatEnum.toRepresentation() = when (this) { - CredentialFormatEnum.JWT_VC_SD_UNOFFICIAL -> ConstantIndex.CredentialRepresentation.SD_JWT - CredentialFormatEnum.VC_SD_JWT -> ConstantIndex.CredentialRepresentation.SD_JWT - CredentialFormatEnum.MSO_MDOC -> ConstantIndex.CredentialRepresentation.ISO_MDOC - else -> ConstantIndex.CredentialRepresentation.PLAIN_JWT } - -class OAuth2Exception(val error: String, val errorDescription: String? = null) : Throwable(error) { - -} \ No newline at end of file diff --git a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/OpenIdAuthorizationServer.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/OpenIdAuthorizationServer.kt new file mode 100644 index 000000000..2173ddc78 --- /dev/null +++ b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/OpenIdAuthorizationServer.kt @@ -0,0 +1,36 @@ +package at.asitplus.wallet.lib.oidvci + +import at.asitplus.KmmResult +import at.asitplus.wallet.lib.oidc.AuthenticationRequestParameters +import at.asitplus.wallet.lib.oidc.AuthenticationResponseResult + +interface OpenIdAuthorizationServer { + /** + * Used in several fields in [IssuerMetadata], to provide endpoint URLs to clients. + */ + val publicContext: String + + /** + * Serve this result JSON-serialized under `/.well-known/openid-configuration` + */ + val metadata: IssuerMetadata + + /** + * Builds the authentication response. + */ + suspend fun authorize(request: AuthenticationRequestParameters): KmmResult + + /** + * Verifies the authorization code sent by the client and issues an access token. + * Send this value JSON-serialized back to the client. + * + * @return [KmmResult] may contain a [OAuth2Exception] + */ + suspend fun token(params: TokenRequestParameters): KmmResult + + fun providePreAuthorizedCode(): String + + fun getUserInfo(accessToken: String): KmmResult + + fun verifyAndRemoveClientNonce(nonce: String): Boolean +} diff --git a/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidvciInteropTest.kt b/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidvciInteropTest.kt index 5a79f4d1c..b56835465 100644 --- a/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidvciInteropTest.kt +++ b/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidvciInteropTest.kt @@ -20,10 +20,15 @@ class OidvciInteropTest : FunSpec({ at.asitplus.wallet.eupid.Initializer.initWithVcLib() } + lateinit var authorizationService: AuthorizationService lateinit var issuer: IssuerService beforeEach { + authorizationService = AuthorizationService( + credentialSchemes = listOf(ConstantIndex.AtomicAttribute2023, ConstantIndex.MobileDrivingLicence2023) + ) issuer = IssuerService( + authorizationService = authorizationService, issuer = IssuerAgent.newDefaultInstance( cryptoService = DefaultCryptoService(), dataProvider = DummyCredentialDataProvider() @@ -175,7 +180,7 @@ class OidvciInteropTest : FunSpec({ credentialIssuerMetadata.supportedCredentialConfigurations!![credentialOffer.configurationIds.first()]!! val scopeToRequest = credentialConfig.scope!! val authnRequest = client.createAuthRequest(scopeToRequest, credentialIssuerMetadata.credentialIssuer) - val authnResponse = issuer.authorize(authnRequest).getOrThrow() + val authnResponse = authorizationService.authorize(authnRequest).getOrThrow() authnResponse.shouldBeInstanceOf() val code = authnResponse.params.code code.shouldNotBeNull() @@ -188,7 +193,7 @@ class OidvciInteropTest : FunSpec({ representation = ConstantIndex.CredentialRepresentation.SD_JWT ) ) - val token = issuer.token(tokenRequest).getOrThrow() + val token = authorizationService.token(tokenRequest).getOrThrow() val credentialRequest = client.createCredentialRequestJwt( token, credentialIssuerMetadata, diff --git a/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidvciProcessTest.kt b/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidvciProcessTest.kt index 744e7a82b..0b63f1773 100644 --- a/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidvciProcessTest.kt +++ b/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidvciProcessTest.kt @@ -22,7 +22,12 @@ import io.matthewnelson.encoding.core.Decoder.Companion.decodeToByteArray class OidvciProcessTest : FunSpec({ val dataProvider = DummyCredentialDataProvider() + val authorizationService = AuthorizationService( + dataProvider = dataProvider, + credentialSchemes = listOf(ConstantIndex.AtomicAttribute2023, ConstantIndex.MobileDrivingLicence2023) + ) val issuer = IssuerService( + authorizationService = authorizationService, issuer = IssuerAgent.newDefaultInstance( cryptoService = DefaultCryptoService(), dataProvider = dataProvider @@ -33,6 +38,7 @@ class OidvciProcessTest : FunSpec({ test("process with W3C VC JWT") { val client = WalletService() val credential = runProcessWithJwtProof( + authorizationService, issuer, client, WalletService.RequestOptions( @@ -53,6 +59,7 @@ class OidvciProcessTest : FunSpec({ test("process with W3C VC SD-JWT") { val client = WalletService() val credential = runProcessWithJwtProof( + authorizationService, issuer, client, WalletService.RequestOptions( @@ -74,6 +81,7 @@ class OidvciProcessTest : FunSpec({ test("process with W3C VC SD-JWT one requested claim") { val client = WalletService() val credential = runProcessWithJwtProof( + authorizationService, issuer, client, WalletService.RequestOptions( @@ -96,6 +104,7 @@ class OidvciProcessTest : FunSpec({ test("process with ISO mobile driving licence") { val client = WalletService() val credential = runProcessWithCwtProof( + authorizationService, issuer, client, WalletService.RequestOptions( @@ -117,6 +126,7 @@ class OidvciProcessTest : FunSpec({ test("process with ISO mobile driving licence one requested claim") { val client = WalletService() val credential = runProcessWithCwtProof( + authorizationService, issuer, client, WalletService.RequestOptions( @@ -139,6 +149,7 @@ class OidvciProcessTest : FunSpec({ test("process with ISO atomic attributes") { val client = WalletService() val credential = runProcessWithCwtProof( + authorizationService, issuer, client, WalletService.RequestOptions( @@ -157,36 +168,38 @@ class OidvciProcessTest : FunSpec({ }) private suspend fun runProcessWithJwtProof( + authorizationService: AuthorizationService, issuer: IssuerService, client: WalletService, requestOptions: WalletService.RequestOptions, ): CredentialResponseParameters { - val token = runProcessGetToken(client, issuer, requestOptions) + val token = runProcessGetToken(authorizationService, client, requestOptions) val credentialRequest = client.createCredentialRequestJwt(token, issuer.metadata, requestOptions).getOrThrow() return issuer.credential(token.accessToken, credentialRequest).getOrThrow() } private suspend fun runProcessWithCwtProof( + authorizationService: AuthorizationService, issuer: IssuerService, client: WalletService, requestOptions: WalletService.RequestOptions, ): CredentialResponseParameters { - val token = runProcessGetToken(client, issuer, requestOptions) + val token = runProcessGetToken(authorizationService, client, requestOptions) val credentialRequest = client.createCredentialRequestCwt(token, issuer.metadata, requestOptions).getOrThrow() return issuer.credential(token.accessToken, credentialRequest).getOrThrow() } private suspend fun runProcessGetToken( + authorizationService: AuthorizationService, client: WalletService, - issuer: IssuerService, requestOptions: WalletService.RequestOptions, ): TokenResponseParameters { val authnRequest = client.createAuthRequest(requestOptions) - val authnResponse = issuer.authorize(authnRequest).getOrThrow() + val authnResponse = authorizationService.authorize(authnRequest).getOrThrow() authnResponse.shouldBeInstanceOf() val code = authnResponse.params.code code.shouldNotBeNull() val tokenRequest = client.createTokenRequestParameters(authnResponse.params, requestOptions) - val token = issuer.token(tokenRequest).getOrThrow() + val token = authorizationService.token(tokenRequest).getOrThrow() return token } From 66a920ebc270b187c221304bd2075536840c53fb Mon Sep 17 00:00:00 2001 From: Christian Kollmann Date: Tue, 16 Apr 2024 20:13:17 +0200 Subject: [PATCH 09/18] OID4VCI: Fix usage of request options --- .../wallet/lib/oidvci/AuthorizationService.kt | 13 +++++++------ .../wallet/lib/oidvci/OidvciInteropTest.kt | 19 ++++++++++--------- 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/AuthorizationService.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/AuthorizationService.kt index 89e8430a3..e1ca52928 100644 --- a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/AuthorizationService.kt +++ b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/AuthorizationService.kt @@ -137,12 +137,13 @@ class AuthorizationService( } } params.codeVerifier?.let { codeVerifier -> - val codeChallenge = codeChallengeMutex.withLock { codeToCodeChallengeMap.remove(params.code) } - val codeChallengeCalculated = codeVerifier.encodeToByteArray().sha256() - .encodeToString(Base64UrlStrict) - if (codeChallenge != codeChallengeCalculated) { - return KmmResult.failure(OAuth2Exception(OpenIdConstants.Errors.INVALID_GRANT)) - .also { Napier.w("token: client did not provide correct code verifier: $codeVerifier") } + codeChallengeMutex.withLock { codeToCodeChallengeMap.remove(params.code) }?.let { codeChallenge -> + val codeChallengeCalculated = codeVerifier.encodeToByteArray().sha256() + .encodeToString(Base64UrlStrict) + if (codeChallenge != codeChallengeCalculated) { + return KmmResult.failure(OAuth2Exception(OpenIdConstants.Errors.INVALID_GRANT)) + .also { Napier.w("token: client did not provide correct code verifier: $codeVerifier") } + } } } val result = TokenResponseParameters( diff --git a/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidvciInteropTest.kt b/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidvciInteropTest.kt index b56835465..8560374f0 100644 --- a/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidvciInteropTest.kt +++ b/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidvciInteropTest.kt @@ -6,6 +6,7 @@ import at.asitplus.wallet.lib.data.ConstantIndex import at.asitplus.wallet.lib.oidc.AuthenticationResponseResult import at.asitplus.wallet.lib.oidc.DummyCredentialDataProvider import at.asitplus.wallet.lib.oidc.OpenIdConstants.PATH_WELL_KNOWN_CREDENTIAL_ISSUER +import com.benasher44.uuid.uuid4 import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.collections.shouldContainAll import io.kotest.matchers.collections.shouldHaveSingleElement @@ -179,28 +180,28 @@ class OidvciInteropTest : FunSpec({ val credentialConfig = credentialIssuerMetadata.supportedCredentialConfigurations!![credentialOffer.configurationIds.first()]!! val scopeToRequest = credentialConfig.scope!! - val authnRequest = client.createAuthRequest(scopeToRequest, credentialIssuerMetadata.credentialIssuer) + val state = uuid4().toString() + val authnRequest = client.createAuthRequest(scopeToRequest, credentialIssuerMetadata.credentialIssuer, state) val authnResponse = authorizationService.authorize(authnRequest).getOrThrow() authnResponse.shouldBeInstanceOf() val code = authnResponse.params.code code.shouldNotBeNull() // TODO Provide a way to authenticate the client ... but how? see `token_endpoint_auth_method` in Client Metadata, RFC 6749 + val requestOptions = WalletService.RequestOptions( + ConstantIndex.AtomicAttribute2023, + representation = ConstantIndex.CredentialRepresentation.SD_JWT, + state = state + ) val tokenRequest = client.createTokenRequestParameters( authnResponse.params, credentialOffer, - WalletService.RequestOptions( - ConstantIndex.AtomicAttribute2023, - representation = ConstantIndex.CredentialRepresentation.SD_JWT - ) + requestOptions ) val token = authorizationService.token(tokenRequest).getOrThrow() val credentialRequest = client.createCredentialRequestJwt( token, credentialIssuerMetadata, - WalletService.RequestOptions( - ConstantIndex.AtomicAttribute2023, - representation = ConstantIndex.CredentialRepresentation.SD_JWT - ) + requestOptions ).getOrThrow() val credential = issuer.credential(token.accessToken, credentialRequest) From a274beb85c1b9d08d1b87d78c54d3c8aebc2dd01 Mon Sep 17 00:00:00 2001 From: Christian Kollmann Date: Tue, 16 Apr 2024 21:57:27 +0200 Subject: [PATCH 10/18] OID4VCI: Enhance separation between credential issuer and AS --- CHANGELOG.md | 39 ++-- .../{IssuerService.kt => CredentialIssuer.kt} | 51 +++--- .../asitplus/wallet/lib/oidvci/Extensions.kt | 19 +- .../lib/oidvci/OAuth2AuthorizationServer.kt | 30 ++++ .../wallet/lib/oidvci/OAuth2DataProvider.kt | 15 ++ .../OAuth2IssuerCredentialDataProvider.kt | 88 +++++++++ .../wallet/lib/oidvci/OidcAddressClaim.kt | 23 +++ .../wallet/lib/oidvci/OidcUserInfo.kt | 27 +++ .../lib/oidvci/OpenIdAuthorizationServer.kt | 36 ---- ...rvice.kt => SimpleAuthorizationService.kt} | 105 ++++++----- .../wallet/lib/oidvci/WalletService.kt | 2 +- ...DummyOAuth2IssuerCredentialDataProvider.kt | 167 ++++++++++++++++++ .../wallet/lib/oidvci/OidvciInteropTest.kt | 21 ++- .../wallet/lib/oidvci/OidvciProcessTest.kt | 35 ++-- .../at/asitplus/wallet/lib/agent/Issuer.kt | 4 + .../asitplus/wallet/lib/agent/IssuerAgent.kt | 6 +- 16 files changed, 518 insertions(+), 150 deletions(-) rename vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/{IssuerService.kt => CredentialIssuer.kt} (87%) create mode 100644 vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/OAuth2AuthorizationServer.kt create mode 100644 vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/OAuth2DataProvider.kt create mode 100644 vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/OAuth2IssuerCredentialDataProvider.kt create mode 100644 vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/OidcAddressClaim.kt create mode 100644 vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/OidcUserInfo.kt delete mode 100644 vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/OpenIdAuthorizationServer.kt rename vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/{AuthorizationService.kt => SimpleAuthorizationService.kt} (61%) create mode 100644 vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/DummyOAuth2IssuerCredentialDataProvider.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 3985aa1d5..969c2a4b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,28 +1,31 @@ # Changelog Release 3.6.0: - - `OidcSiopWallet.AuthenticationResponseResult.Post`: Replace property `body: String` with `params: Map`, to be posted to the Relying Party. Clients may call extension function `at.asitplus.wallet.lib.oidvci.formUrlEncode` on `params` to get the encoded `body` for HTTP calls. - - Move `JsonWebKeySet` to library `at.asitplus.crypto:datatypes-jws` - - `DefaultVerifierJwsService` may load public keys for verifying JWS from a JWK Set URL in the header, see constructor argument `jwkSetRetriever` (cf. to `OidcSiopWallet`) - - `OidcSiopWallet` and `OidcSiopVerifier` implement response mode `direct_post.jwt`, as per OpenID for Verifiable Presentations draft 20 - - `OidcSiopVerifier`: Add constructor parameter `attestationJwt` to create authentication requests as JWS with an Verifier Attestation JWT in header `jwt` (see OpenId4VP draft 20) - - `OidcSiopVerifier`: Rename `createAuthnRequestAsRequestObject()` to `createAuthnRequestAsSignedRequestObject()`, also changing the return type - - `OidcSiopVerifier`: Add option to set `client_metadata_uri` instead of embedding client metadata in authentication requests - - `OidcSiopVerifier`: Refactor list of parameters for customizing authentication requests to single data class `RequestOptions` - - `OidcSiopWallet`: Rename constructor parameter `jwkSetRetriever` to a more general `remoteResourceRetriever`, to use it for various parameters defined by reference - - `OidcSiopWallet`: Replace constructor parameter `verifierJwsService` with `requestObjectJwsVerifier` to allow callers to verify JWS objects with a pre-registered key (as in the OpenId4VP client ID scheme "pre-registered") + - Self-Issued OpenID Provider v2: + - `OidcSiopWallet.AuthenticationResponseResult.Post`: Replace property `body: String` with `params: Map`, to be posted to the Relying Party. Clients may call extension function `at.asitplus.wallet.lib.oidvci.formUrlEncode` on `params` to get the encoded `body` for HTTP calls. + - Move `JsonWebKeySet` to library `at.asitplus.crypto:datatypes-jws` + - `DefaultVerifierJwsService` may load public keys for verifying JWS from a JWK Set URL in the header, see constructor argument `jwkSetRetriever` (cf. to `OidcSiopWallet`) + - `OidcSiopWallet` and `OidcSiopVerifier` implement response mode `direct_post.jwt`, as per OpenID for Verifiable Presentations draft 20 + - `OidcSiopVerifier`: Add constructor parameter `attestationJwt` to create authentication requests as JWS with an Verifier Attestation JWT in header `jwt` (see OpenId4VP draft 20) + - `OidcSiopVerifier`: Rename `createAuthnRequestAsRequestObject()` to `createAuthnRequestAsSignedRequestObject()`, also changing the return type + - `OidcSiopVerifier`: Add option to set `client_metadata_uri` instead of embedding client metadata in authentication requests + - `OidcSiopVerifier`: Refactor list of parameters for customizing authentication requests to single data class `RequestOptions` + - `OidcSiopWallet`: Rename constructor parameter `jwkSetRetriever` to a more general `remoteResourceRetriever`, to use it for various parameters defined by reference + - `OidcSiopWallet`: Replace constructor parameter `verifierJwsService` with `requestObjectJwsVerifier` to allow callers to verify JWS objects with a pre-registered key (as in the OpenId4VP client ID scheme "pre-registered") + - OpenID for Verifiable Credential Issuance: + - Implement OpenID for Verifiable Credential Issuance draft 13, from 2024-02-08 + - Rename `IssuerService` to `CredentialIssuer` + - Implement RFC 7636 Proof Key for Code Exchange for OpenID for Verifiable Credential Issuance implementations, i.e. `IssuerService`/`CredentialIssuer` and `WalletService` + - `IssuerService`/`CredentialIssuer`: Make public API functions suspending, also return `KmmResult` to transport exceptions + - `IssuerService`/`CredentialIssuer`: Change parameter of `credential()` from `authorizationHeader` to `accessToken`, requiring the plain access token + - `IssuerService`/`CredentialIssuer`: Extract responsibilities of an OAuth Authorizaiton Server into `AuthorizationService` + - `WalletService`: Make public API functions suspending + - `WalletService`: Implement proving possesion of private key with CBOR Web Tokens + - `WalletService`: Move constructor parameters to `requestOptions` for every method call - Dependency updates - Conventions 1.9.23+20240410 - Ktor 2.3.10 - Auto-publish version catalogs - - Implement OpenID for Verifiable Credential Issuance draft 13, from 2024-02-08 - - Implement RFC 7636 Proof Key for Code Exchange for OpenID for Verifiable Credential Issuance implementations, i.e. `IssuerService` and `WalletService` - - `IssuerService`: Make public API functions suspending, also return `KmmResult` to transport exceptions - - `IssuerService`: Change parameter of `credential()` from `authorizationHeader` to `accessToken`, requiring the plain access token - - `IssuerService`: Extract responsibilities of an OAuth Authorizaiton Server into `AuthorizationService` - - `WalletService`: Make public API functions suspending - - `WalletService`: Implement proving possesion of private key with CBOR Web Tokens - - `WalletService`: Move constructor parameters to `requestOptions` for every method call Release 3.5.0: - Kotlin 1.9.23 diff --git a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/IssuerService.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CredentialIssuer.kt similarity index 87% rename from vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/IssuerService.kt rename to vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CredentialIssuer.kt index e5c15748e..a787c5bbe 100644 --- a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/IssuerService.kt +++ b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CredentialIssuer.kt @@ -8,6 +8,7 @@ import at.asitplus.crypto.datatypes.jws.JsonWebToken import at.asitplus.crypto.datatypes.jws.JwsSigned import at.asitplus.crypto.datatypes.pki.X509Certificate import at.asitplus.wallet.lib.agent.Issuer +import at.asitplus.wallet.lib.agent.IssuerCredentialDataProvider import at.asitplus.wallet.lib.data.ConstantIndex import at.asitplus.wallet.lib.oidc.OpenIdConstants.Errors import at.asitplus.wallet.lib.oidc.OpenIdConstants.ProofTypes @@ -21,11 +22,11 @@ import io.matthewnelson.encoding.core.Decoder.Companion.decodeToByteArray * Implemented from [OpenID for Verifiable Credential Issuance] * (https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html), Draft 13, 2024-02-08. */ -class IssuerService( +class CredentialIssuer( /** * Used to get the user data, and access tokens. */ - private val authorizationService: OpenIdAuthorizationServer, + private val authorizationService: OAuth2AuthorizationServer, /** * Used to actually issue the credential. */ @@ -37,14 +38,19 @@ class IssuerService( /** * Used in several fields in [IssuerMetadata], to provide endpoint URLs to clients. */ - private val publicContext: String = "https://wallet.a-sit.at/", + private val publicContext: String = "https://wallet.a-sit.at/credential-issuer", /** * Used to build [IssuerMetadata.credentialEndpointUrl], i.e. implementers need to forward requests * to that URI (which starts with [publicContext]) to [credential]. */ private val credentialEndpointPath: String = "/credential", + /** + * Used during issuance, when issuing credentials (using [issuer]) with data from [OidcUserInfo] + */ + private val buildIssuerCredentialDataProviderOverride: (OidcUserInfo) -> IssuerCredentialDataProvider = { + OAuth2IssuerCredentialDataProvider(it) + } ) { - /** * Serve this result JSON-serialized under `/.well-known/openid-credential-issuer` */ @@ -66,7 +72,7 @@ class IssuerService( * Offer all [credentialSchemes] to clients. * Callers may need to transport this in [CredentialOfferUrlParameters] to (HTTPS) clients. */ - fun credentialOffer(): CredentialOffer = CredentialOffer( + suspend fun credentialOffer(): CredentialOffer = CredentialOffer( credentialIssuer = publicContext, configurationIds = credentialSchemes.flatMap { it.toSupportedCredentialFormat(issuer.cryptoAlgorithms).keys }, grants = CredentialOfferGrants( @@ -74,14 +80,16 @@ class IssuerService( issuerState = uuid4().toString(), // TODO remember this state, for subsequent requests from the Wallet authorizationServer = authorizationService.publicContext ), - preAuthorizedCode = CredentialOfferGrantsPreAuthCode( - preAuthorizedCode = authorizationService.providePreAuthorizedCode(), - transactionCode = CredentialOfferGrantsPreAuthCodeTransactionCode( - inputMode = "numeric", - length = 16, - ), - authorizationServer = authorizationService.publicContext - ) + preAuthorizedCode = authorizationService.providePreAuthorizedCode()?.let { + CredentialOfferGrantsPreAuthCode( + preAuthorizedCode = it, + transactionCode = CredentialOfferGrantsPreAuthCodeTransactionCode( + inputMode = "numeric", + length = 16, + ), + authorizationServer = authorizationService.publicContext + ) + } ) ) @@ -161,8 +169,7 @@ class IssuerService( val userInfo = authorizationService.getUserInfo(accessToken).getOrNull() ?: return KmmResult.failure(OAuth2Exception(Errors.INVALID_TOKEN)) .also { Napier.w("credential: client did not provide correct token: $accessToken") } - // NOTE: In a real deployment, the Credential Issuer would send the access token - // to the Authorization Server's User Info endpoint to receive the user's attributes + val issuedCredentialResult = when { params.format != null -> { issuer.issueCredential( @@ -170,21 +177,23 @@ class IssuerService( attributeTypes = listOfNotNull(params.sdJwtVcType, params.docType) + (params.credentialDefinition?.types?.toList() ?: listOf()), representation = params.format.toRepresentation(), - claimNames = params.claims?.map { it.value.keys }?.flatten()?.ifEmpty { null } + claimNames = params.claims?.map { it.value.keys }?.flatten()?.ifEmpty { null }, + dataProviderOverride = buildIssuerCredentialDataProviderOverride(userInfo) ) } params.credentialIdentifier != null -> { - // TODO this delimiter is probably not safe - val representation = CredentialFormatEnum.parse(params.credentialIdentifier.substringAfterLast("-")) - ?: return KmmResult.failure(OAuth2Exception(Errors.INVALID_REQUEST)) + val (vcType, representation) = decodeFromCredentialIdentifier(params.credentialIdentifier) + if (vcType.isEmpty()) { + return KmmResult.failure(OAuth2Exception(Errors.INVALID_REQUEST)) .also { Napier.w("credential: client did not provide correct credential identifier: ${params.credentialIdentifier}") } - val vcType = params.credentialIdentifier.substringBeforeLast("-") + } issuer.issueCredential( subjectPublicKey = subjectPublicKey, attributeTypes = listOf(vcType), representation = representation.toRepresentation(), - claimNames = params.claims?.map { it.value.keys }?.flatten()?.ifEmpty { null } + claimNames = params.claims?.map { it.value.keys }?.flatten()?.ifEmpty { null }, + dataProviderOverride = buildIssuerCredentialDataProviderOverride(userInfo) ) } diff --git a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/Extensions.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/Extensions.kt index 0baedfefc..7d0870bfc 100644 --- a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/Extensions.kt +++ b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/Extensions.kt @@ -21,7 +21,7 @@ fun ConstantIndex.CredentialScheme.toSupportedCredentialFormat(cryptoAlgorithms: supportedBindingMethods = listOf(OpenIdConstants.BINDING_METHOD_COSE_KEY), supportedSigningAlgorithms = cryptoAlgorithms.map { it.toJwsAlgorithm().identifier }, ), - "$vcType-${CredentialFormatEnum.JWT_VC.text}" to SupportedCredentialFormat( + encodeToCredentialIdentifier(CredentialFormatEnum.JWT_VC) to SupportedCredentialFormat( format = CredentialFormatEnum.JWT_VC, scope = vcType, credentialDefinition = SupportedCredentialFormatDefinition( @@ -31,7 +31,7 @@ fun ConstantIndex.CredentialScheme.toSupportedCredentialFormat(cryptoAlgorithms: supportedBindingMethods = listOf(OpenIdConstants.PREFIX_DID_KEY, OpenIdConstants.URN_TYPE_JWK_THUMBPRINT), supportedSigningAlgorithms = cryptoAlgorithms.map { it.toJwsAlgorithm().identifier }, ), - "$vcType-${CredentialFormatEnum.VC_SD_JWT.text}" to SupportedCredentialFormat( + encodeToCredentialIdentifier(CredentialFormatEnum.VC_SD_JWT) to SupportedCredentialFormat( format = CredentialFormatEnum.VC_SD_JWT, scope = vcType, sdJwtVcType = vcType, @@ -43,6 +43,21 @@ fun ConstantIndex.CredentialScheme.toSupportedCredentialFormat(cryptoAlgorithms: ) ) +/** + * Reverse functionality of [decodeFromCredentialIdentifier] + */ +private fun ConstantIndex.CredentialScheme.encodeToCredentialIdentifier(format: CredentialFormatEnum) = + "$vcType#${format.text}" + +/** + * Reverse functionality of [ConstantIndex.CredentialScheme.encodeToCredentialIdentifier] + */ +fun decodeFromCredentialIdentifier(input: String): Pair { + val vcTypeOrIsoNamespace = input.substringBeforeLast("#") + val format = CredentialFormatEnum.parse(input.substringAfterLast("#")) ?: CredentialFormatEnum.MSO_MDOC + return Pair(vcTypeOrIsoNamespace, format) +} + fun CredentialFormatEnum.toRepresentation() = when (this) { CredentialFormatEnum.JWT_VC_SD_UNOFFICIAL -> ConstantIndex.CredentialRepresentation.SD_JWT CredentialFormatEnum.VC_SD_JWT -> ConstantIndex.CredentialRepresentation.SD_JWT diff --git a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/OAuth2AuthorizationServer.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/OAuth2AuthorizationServer.kt new file mode 100644 index 000000000..5fa5d5e8e --- /dev/null +++ b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/OAuth2AuthorizationServer.kt @@ -0,0 +1,30 @@ +package at.asitplus.wallet.lib.oidvci + +import at.asitplus.KmmResult +import at.asitplus.wallet.lib.oidc.AuthenticationRequestParameters +import at.asitplus.wallet.lib.oidc.AuthenticationResponseResult + +/** + * Used by [CredentialIssuer] to obtain user data when issuing credentials using OID4VCI. + */ +interface OAuth2AuthorizationServer { + /** + * Used in several fields in [IssuerMetadata], to provide endpoint URLs to clients. + */ + val publicContext: String + + /** + * Provide a pre-authorized code (for flow defined in OID4VCI), to be used by the Wallet implementation + * to load credentials. + */ + suspend fun providePreAuthorizedCode(): String? + + /** + * Get the [OidcUserInfo] associated with the [accessToken], that was created before at the Authorization Server. + */ + suspend fun getUserInfo(accessToken: String): KmmResult + + // TODO How is this supposed to happen when using an external Authorization Server? + fun verifyAndRemoveClientNonce(nonce: String): Boolean +} + diff --git a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/OAuth2DataProvider.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/OAuth2DataProvider.kt new file mode 100644 index 000000000..d94b31195 --- /dev/null +++ b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/OAuth2DataProvider.kt @@ -0,0 +1,15 @@ +package at.asitplus.wallet.lib.oidvci + +import at.asitplus.wallet.lib.oidc.AuthenticationRequestParameters + +/** + * Interface used in [SimpleAuthorizationService] to actually load user data, converting it into [OidcUserInfo]. + */ +interface OAuth2DataProvider { + /** + * Load user information (i.e. authenticate the client) with data sent from [request]. + * + * @param request May be null when using pre-authorized code flow (defined in OID4VCI). + */ + suspend fun loadUserInfo(request: AuthenticationRequestParameters? = null): OidcUserInfo +} \ No newline at end of file diff --git a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/OAuth2IssuerCredentialDataProvider.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/OAuth2IssuerCredentialDataProvider.kt new file mode 100644 index 000000000..6741c62a1 --- /dev/null +++ b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/OAuth2IssuerCredentialDataProvider.kt @@ -0,0 +1,88 @@ +package at.asitplus.wallet.lib.oidvci + +import at.asitplus.KmmResult +import at.asitplus.crypto.datatypes.CryptoPublicKey +import at.asitplus.wallet.lib.agent.ClaimToBeIssued +import at.asitplus.wallet.lib.agent.CredentialToBeIssued +import at.asitplus.wallet.lib.agent.IssuerCredentialDataProvider +import at.asitplus.wallet.lib.data.AtomicAttribute2023 +import at.asitplus.wallet.lib.data.ConstantIndex +import at.asitplus.wallet.lib.iso.DrivingPrivilege +import at.asitplus.wallet.lib.iso.ElementValue +import at.asitplus.wallet.lib.iso.IssuerSignedItem +import kotlinx.datetime.Clock +import kotlinx.datetime.LocalDate +import kotlin.random.Random +import kotlin.time.Duration.Companion.minutes + +/** + * Adapter implementation to convert [userInfo] obtained from an [OAuth2AuthorizationServer] + * into credentials needed by [IssuerCredentialDataProvider]. + */ +class OAuth2IssuerCredentialDataProvider(private val userInfo: OidcUserInfo) : IssuerCredentialDataProvider { + + private val clock: Clock = Clock.System + private val defaultLifetime = 1.minutes + + override fun getCredential( + subjectPublicKey: CryptoPublicKey, + credentialScheme: ConstantIndex.CredentialScheme, + representation: ConstantIndex.CredentialRepresentation, + claimNames: Collection? + ): KmmResult> { + val expiration = clock.now() + defaultLifetime + val credentials = mutableListOf() + if (credentialScheme == ConstantIndex.AtomicAttribute2023) { + val subjectId = subjectPublicKey.didEncoded + val claims = listOfNotNull( + // TODO Extend list of default OIDC claims + optionalClaim(claimNames, "given_name", userInfo.givenName), + optionalClaim(claimNames, "family_name", userInfo.familyName), + optionalClaim(claimNames, "subject", userInfo.subject), + ) + credentials += when (representation) { + ConstantIndex.CredentialRepresentation.SD_JWT -> listOf( + CredentialToBeIssued.VcSd(claims = claims, expiration = expiration) + ) + + ConstantIndex.CredentialRepresentation.PLAIN_JWT -> claims.map { claim -> + CredentialToBeIssued.VcJwt( + subject = AtomicAttribute2023(subjectId, claim.name, claim.value.toString()), + expiration = expiration, + ) + } + + ConstantIndex.CredentialRepresentation.ISO_MDOC -> listOf( + CredentialToBeIssued.Iso( + issuerSignedItems = claims.mapIndexed { index, claim -> + issuerSignedItem(claim.name, claim.value, index.toUInt()) + }, + expiration = expiration, + ) + ) + } + } + return KmmResult.success(credentials) + } + + private fun Collection?.isNullOrContains(s: String) = + this == null || contains(s) + + private fun optionalClaim(claimNames: Collection?, name: String, value: Any) = + if (claimNames.isNullOrContains(name)) ClaimToBeIssued(name, value) else null + + private fun issuerSignedItem(name: String, value: Any, digestId: UInt) = + IssuerSignedItem( + digestId = digestId, + random = Random.nextBytes(16), + elementIdentifier = name, + elementValue = when (value) { + is String -> ElementValue(string = value) + is ByteArray -> ElementValue(bytes = value) + is LocalDate -> ElementValue(date = value) + is Boolean -> ElementValue(boolean = value) + is DrivingPrivilege -> ElementValue(drivingPrivilege = arrayOf(value)) + else -> ElementValue(string = value.toString()) + } + ) +} diff --git a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/OidcAddressClaim.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/OidcAddressClaim.kt new file mode 100644 index 000000000..c40d679d1 --- /dev/null +++ b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/OidcAddressClaim.kt @@ -0,0 +1,23 @@ +package at.asitplus.wallet.lib.oidvci + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html) + */ +@Serializable +data class OidcAddressClaim( + @SerialName("street_address") + val street: String? = null, + @SerialName("locality") + val locality: String? = null, + @SerialName("region") + val region: String? = null, + @SerialName("postal_code") + val postalCode: String? = null, + @SerialName("country") + val country: String? = null, + @SerialName("formatted") + val formatted: String? = null, +) \ No newline at end of file diff --git a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/OidcUserInfo.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/OidcUserInfo.kt new file mode 100644 index 000000000..bf08abd2f --- /dev/null +++ b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/OidcUserInfo.kt @@ -0,0 +1,27 @@ +package at.asitplus.wallet.lib.oidvci + +import kotlinx.serialization.SerialName + +/** + * [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html) + */ +data class OidcUserInfo( + @SerialName("given_name") + val givenName: String, + @SerialName("family_name") + val familyName: String, + @SerialName("sub") + val subject: String, + @SerialName("email") + val email: String? = null, + @SerialName("address") + val address: OidcAddressClaim? = null, + @SerialName("birthdate") + val birthDate: String? = null, + @SerialName("gender") + val gender: String? = null, + @SerialName("age_over_18") + val ageOver18: Boolean? = null, + @SerialName("picture") + val picture: String? = null, +) \ No newline at end of file diff --git a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/OpenIdAuthorizationServer.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/OpenIdAuthorizationServer.kt deleted file mode 100644 index 2173ddc78..000000000 --- a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/OpenIdAuthorizationServer.kt +++ /dev/null @@ -1,36 +0,0 @@ -package at.asitplus.wallet.lib.oidvci - -import at.asitplus.KmmResult -import at.asitplus.wallet.lib.oidc.AuthenticationRequestParameters -import at.asitplus.wallet.lib.oidc.AuthenticationResponseResult - -interface OpenIdAuthorizationServer { - /** - * Used in several fields in [IssuerMetadata], to provide endpoint URLs to clients. - */ - val publicContext: String - - /** - * Serve this result JSON-serialized under `/.well-known/openid-configuration` - */ - val metadata: IssuerMetadata - - /** - * Builds the authentication response. - */ - suspend fun authorize(request: AuthenticationRequestParameters): KmmResult - - /** - * Verifies the authorization code sent by the client and issues an access token. - * Send this value JSON-serialized back to the client. - * - * @return [KmmResult] may contain a [OAuth2Exception] - */ - suspend fun token(params: TokenRequestParameters): KmmResult - - fun providePreAuthorizedCode(): String - - fun getUserInfo(accessToken: String): KmmResult - - fun verifyAndRemoveClientNonce(nonce: String): Boolean -} diff --git a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/AuthorizationService.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/SimpleAuthorizationService.kt similarity index 61% rename from vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/AuthorizationService.kt rename to vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/SimpleAuthorizationService.kt index e1ca52928..dd29bedbf 100644 --- a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/AuthorizationService.kt +++ b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/SimpleAuthorizationService.kt @@ -2,8 +2,6 @@ package at.asitplus.wallet.lib.oidvci import at.asitplus.KmmResult import at.asitplus.crypto.datatypes.io.Base64UrlStrict -import at.asitplus.wallet.lib.agent.EmptyCredentialDataProvider -import at.asitplus.wallet.lib.agent.IssuerCredentialDataProvider import at.asitplus.wallet.lib.data.ConstantIndex import at.asitplus.wallet.lib.iso.sha256 import at.asitplus.wallet.lib.oidc.AuthenticationRequestParameters @@ -18,17 +16,17 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock /** - * Simple authorization server implementation, to be used for [IssuerService], + * Simple authorization server implementation, to be used for [CredentialIssuer], * when issuing credentials directly from a local [dataProvider]. * * Implemented from [OpenID for Verifiable Credential Issuance] * (https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html), Draft 13, 2024-02-08. */ -class AuthorizationService( +class SimpleAuthorizationService( /** * Source of user data. */ - private val dataProvider: IssuerCredentialDataProvider = EmptyCredentialDataProvider, + private val dataProvider: OAuth2DataProvider, /** * List of supported schemes. */ @@ -48,7 +46,7 @@ class AuthorizationService( /** * Used in several fields in [IssuerMetadata], to provide endpoint URLs to clients. */ - override val publicContext: String = "https://wallet.a-sit.at/", + override val publicContext: String = "https://wallet.a-sit.at/authorization-server", /** * Used to build [IssuerMetadata.authorizationEndpointUrl], i.e. implementers need to forward requests * to that URI (which starts with [publicContext]) to [authorize]. @@ -59,15 +57,21 @@ class AuthorizationService( * to that URI (which starts with [publicContext]) to [token]. */ private val tokenEndpointPath: String = "/token", -) : OpenIdAuthorizationServer { +) : OAuth2AuthorizationServer { private val codeToCodeChallengeMap = mutableMapOf() - private val codeChallengeMutex = Mutex() + private val codeToCodeChallengeMutex = Mutex() + + private val codeToUserInfoMap = mutableMapOf() + private val codeToUserInfoMutex = Mutex() + + private val accessTokenToUserInfoMap = mutableMapOf() + private val accessTokenToUserInfoMutex = Mutex() /** * Serve this result JSON-serialized under `/.well-known/openid-configuration` */ - override val metadata: IssuerMetadata by lazy { + val metadata: IssuerMetadata by lazy { IssuerMetadata( issuer = publicContext, authorizationEndpointUrl = "$publicContext$authorizationEndpointPath", @@ -81,19 +85,22 @@ class AuthorizationService( * @return URL build from client's `redirect_uri` with a `code` query parameter containing a fresh authorization * code from [codeService]. */ - override suspend fun authorize(request: AuthenticationRequestParameters): KmmResult { + suspend fun authorize(request: AuthenticationRequestParameters): KmmResult { // TODO Need to store the `scope` or `authorization_details`, i.e. may respond with `invalid_scope` here! if (request.redirectUrl == null) return KmmResult.failure( - OAuth2Exception(OpenIdConstants.Errors.INVALID_REQUEST, "redirect_uri not set") + OAuth2Exception(Errors.INVALID_REQUEST, "redirect_uri not set") ).also { Napier.w("authorize: client did not set redirect_uri in $request") } - val code = codeService.provideCode() + + val code = codeService.provideCode().also { + codeToUserInfoMutex.withLock { codeToUserInfoMap[it] = dataProvider.loadUserInfo(request) } + } val responseParams = AuthenticationResponseParameters( code = code, state = request.state, ) if (request.codeChallenge != null) { - codeChallengeMutex.withLock { codeToCodeChallengeMap[code] = request.codeChallenge } + codeToCodeChallengeMutex.withLock { codeToCodeChallengeMap[code] = request.codeChallenge } } // TODO Also implement POST? val url = URLBuilder(request.redirectUrl) @@ -110,44 +117,53 @@ class AuthorizationService( * * @return [KmmResult] may contain a [OAuth2Exception] */ - override suspend fun token(params: TokenRequestParameters): KmmResult { - // TODO This is part of the Authorization Server - when (params.grantType) { - OpenIdConstants.GRANT_TYPE_CODE -> if (params.code == null || !codeService.verifyCode(params.code)) - return KmmResult.failure(OAuth2Exception(OpenIdConstants.Errors.INVALID_CODE)) - .also { Napier.w("token: client did not provide correct code") } - - OpenIdConstants.GRANT_TYPE_PRE_AUTHORIZED_CODE -> if (params.preAuthorizedCode == null || - !codeService.verifyCode(params.preAuthorizedCode) - ) return KmmResult.failure(OAuth2Exception(OpenIdConstants.Errors.INVALID_GRANT)) - .also { Napier.w("token: client did not provide pre authorized code") } - - else -> + suspend fun token(params: TokenRequestParameters): KmmResult { + val userInfo: OidcUserInfo = when (params.grantType) { + OpenIdConstants.GRANT_TYPE_CODE -> { + if (params.code == null || !codeService.verifyCode(params.code)) + return KmmResult.failure(OAuth2Exception(Errors.INVALID_CODE)) + .also { Napier.w("token: client did not provide correct code") } + codeToUserInfoMutex.withLock { codeToUserInfoMap[params.code] } + } + + OpenIdConstants.GRANT_TYPE_PRE_AUTHORIZED_CODE -> { + if (params.preAuthorizedCode == null || !codeService.verifyCode(params.preAuthorizedCode)) + return KmmResult.failure(OAuth2Exception(Errors.INVALID_GRANT)) + .also { Napier.w("token: client did not provide pre authorized code") } + codeToUserInfoMutex.withLock { codeToUserInfoMap[params.code] } + } + + else -> { return KmmResult.failure( - OAuth2Exception(OpenIdConstants.Errors.INVALID_REQUEST, "No valid grant_type") + OAuth2Exception(Errors.INVALID_REQUEST, "No valid grant_type") ).also { Napier.w("token: client did not provide valid grant_type: ${params.grantType}") } - } + } + } ?: return KmmResult.failure(OAuth2Exception(Errors.INVALID_REQUEST)) + .also { Napier.w("token: could not load user info for $params}") } + if (params.authorizationDetails != null) { // TODO verify params.authorizationDetails.claims and so on params.authorizationDetails.credentialIdentifiers?.forEach { credentialIdentifier -> if (!credentialSchemes.map { it.vcType }.contains(credentialIdentifier)) { - return KmmResult.failure(OAuth2Exception(OpenIdConstants.Errors.INVALID_GRANT)) + return KmmResult.failure(OAuth2Exception(Errors.INVALID_GRANT)) .also { Napier.w("token: client requested invalid credential identifier: $credentialIdentifier") } } } } params.codeVerifier?.let { codeVerifier -> - codeChallengeMutex.withLock { codeToCodeChallengeMap.remove(params.code) }?.let { codeChallenge -> - val codeChallengeCalculated = codeVerifier.encodeToByteArray().sha256() - .encodeToString(Base64UrlStrict) + codeToCodeChallengeMutex.withLock { codeToCodeChallengeMap.remove(params.code) }?.let { codeChallenge -> + val codeChallengeCalculated = codeVerifier.encodeToByteArray().sha256().encodeToString(Base64UrlStrict) if (codeChallenge != codeChallengeCalculated) { - return KmmResult.failure(OAuth2Exception(OpenIdConstants.Errors.INVALID_GRANT)) + return KmmResult.failure(OAuth2Exception(Errors.INVALID_GRANT)) .also { Napier.w("token: client did not provide correct code verifier: $codeVerifier") } } } } + val result = TokenResponseParameters( - accessToken = tokenService.provideToken(), + accessToken = tokenService.provideToken().also { + accessTokenToUserInfoMutex.withLock { accessTokenToUserInfoMap[it] = userInfo } + }, tokenType = OpenIdConstants.TOKEN_TYPE_BEARER, expires = 3600, clientNonce = clientNonceService.provideNonce(), @@ -156,24 +172,31 @@ class AuthorizationService( listOf(it) } ) - Napier.i("token returns $result") return KmmResult.success(result) + .also { Napier.i("token returns $result") } } - override fun providePreAuthorizedCode(): String { - return codeService.provideCode() + override suspend fun providePreAuthorizedCode(): String { + return codeService.provideCode().also { + codeToUserInfoMutex.withLock { codeToUserInfoMap[it] = dataProvider.loadUserInfo() } + } } override fun verifyAndRemoveClientNonce(nonce: String): Boolean { return clientNonceService.verifyAndRemoveNonce(nonce) } - override fun getUserInfo(accessToken: String): KmmResult { + override suspend fun getUserInfo(accessToken: String): KmmResult { if (!tokenService.verifyToken(accessToken)) { - return KmmResult.failure(OAuth2Exception(Errors.INVALID_TOKEN)) - .also { Napier.w("verifyToken: client did not provide correct token: $accessToken") } + return KmmResult.failure(OAuth2Exception(Errors.INVALID_TOKEN)) + .also { Napier.w("getUserInfo: client did not provide correct token: $accessToken") } } - return KmmResult.success(Unit) + val result = accessTokenToUserInfoMutex.withLock { accessTokenToUserInfoMap[accessToken] } + ?: return KmmResult.failure(OAuth2Exception(Errors.INVALID_TOKEN)) + .also { Napier.w("getUserInfo: could not load user info for $accessToken") } + + return KmmResult.success(result) + .also { Napier.v("getUserInfo returns $result") } } } \ No newline at end of file diff --git a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/WalletService.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/WalletService.kt index c255cf10b..77f6b9dcc 100644 --- a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/WalletService.kt +++ b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/WalletService.kt @@ -226,7 +226,7 @@ class WalletService( requestOptions.requestedAttributes, proof ) - Napier.i("createCredentialRequestCwt returns $result") + Napier.i("createCredentialRequestJwt returns $result") return KmmResult.success(result) } diff --git a/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/DummyOAuth2IssuerCredentialDataProvider.kt b/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/DummyOAuth2IssuerCredentialDataProvider.kt new file mode 100644 index 000000000..98682cc9e --- /dev/null +++ b/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/DummyOAuth2IssuerCredentialDataProvider.kt @@ -0,0 +1,167 @@ +package at.asitplus.wallet.lib.oidc + +import at.asitplus.KmmResult +import at.asitplus.crypto.datatypes.CryptoPublicKey +import at.asitplus.wallet.eupid.EuPidCredential +import at.asitplus.wallet.eupid.EuPidScheme +import at.asitplus.wallet.lib.agent.ClaimToBeIssued +import at.asitplus.wallet.lib.agent.CredentialToBeIssued +import at.asitplus.wallet.lib.agent.IssuerCredentialDataProvider +import at.asitplus.wallet.lib.data.AtomicAttribute2023 +import at.asitplus.wallet.lib.data.ConstantIndex +import at.asitplus.wallet.lib.iso.DrivingPrivilege +import at.asitplus.wallet.lib.iso.ElementValue +import at.asitplus.wallet.lib.iso.IssuerSignedItem +import at.asitplus.wallet.lib.iso.MobileDrivingLicenceDataElements.DOCUMENT_NUMBER +import at.asitplus.wallet.lib.iso.MobileDrivingLicenceDataElements.EXPIRY_DATE +import at.asitplus.wallet.lib.iso.MobileDrivingLicenceDataElements.FAMILY_NAME +import at.asitplus.wallet.lib.iso.MobileDrivingLicenceDataElements.GIVEN_NAME +import at.asitplus.wallet.lib.iso.MobileDrivingLicenceDataElements.ISSUE_DATE +import at.asitplus.wallet.lib.oidvci.OAuth2DataProvider +import at.asitplus.wallet.lib.oidvci.OidcUserInfo +import kotlinx.datetime.Clock +import kotlinx.datetime.LocalDate +import kotlin.random.Random +import kotlin.time.Duration.Companion.minutes + +class DummyOAuth2IssuerCredentialDataProvider( + private val userInfo: OidcUserInfo, + private val clock: Clock = Clock.System, +) : IssuerCredentialDataProvider { + + private val defaultLifetime = 1.minutes + + override fun getCredential( + subjectPublicKey: CryptoPublicKey, + credentialScheme: ConstantIndex.CredentialScheme, + representation: ConstantIndex.CredentialRepresentation, + claimNames: Collection? + ): KmmResult> { + val expiration = clock.now() + defaultLifetime + val credentials = mutableListOf() + if (credentialScheme == ConstantIndex.AtomicAttribute2023) { + val subjectId = subjectPublicKey.didEncoded + val claims = listOfNotNull( + optionalClaim(claimNames, "given_name", userInfo.givenName), + optionalClaim(claimNames, "family_name", userInfo.familyName), + optionalClaim(claimNames, "subject", userInfo.subject), + userInfo.birthDate?.let { optionalClaim(claimNames, "date-of-birth", it) }, + ) + credentials += when (representation) { + ConstantIndex.CredentialRepresentation.SD_JWT -> listOf( + CredentialToBeIssued.VcSd( + claims = claims, + expiration = expiration, + ) + ) + + ConstantIndex.CredentialRepresentation.PLAIN_JWT -> claims.map { claim -> + CredentialToBeIssued.VcJwt( + subject = AtomicAttribute2023(subjectId, claim.name, claim.value.toString()), + expiration = expiration, + ) + } + + ConstantIndex.CredentialRepresentation.ISO_MDOC -> listOf( + CredentialToBeIssued.Iso( + issuerSignedItems = claims.mapIndexed { index, claim -> + issuerSignedItem(claim.name, claim.value, index.toUInt()) + }, + expiration = expiration, + ) + ) + } + } + + if (credentialScheme == ConstantIndex.MobileDrivingLicence2023) { + var digestId = 0U + val issuerSignedItems = listOfNotNull( + if (claimNames.isNullOrContains(FAMILY_NAME)) + issuerSignedItem(FAMILY_NAME, userInfo.familyName, digestId++) else null, + if (claimNames.isNullOrContains(GIVEN_NAME)) + issuerSignedItem(GIVEN_NAME, userInfo.givenName, digestId++) else null, + if (claimNames.isNullOrContains(DOCUMENT_NUMBER)) + issuerSignedItem(DOCUMENT_NUMBER, "123456789", digestId++) else null, + if (claimNames.isNullOrContains(ISSUE_DATE)) + issuerSignedItem(ISSUE_DATE, "2023-01-01", digestId++) else null, + if (claimNames.isNullOrContains(EXPIRY_DATE)) + issuerSignedItem(EXPIRY_DATE, "2033-01-01", digestId++) else null, + ) + + credentials.add( + CredentialToBeIssued.Iso( + issuerSignedItems = issuerSignedItems, + expiration = expiration, + ) + ) + } + + if (credentialScheme == EuPidScheme) { + val subjectId = subjectPublicKey.didEncoded + val claims = listOfNotNull( + optionalClaim(claimNames, EuPidScheme.Attributes.FAMILY_NAME, userInfo.familyName), + optionalClaim(claimNames, EuPidScheme.Attributes.GIVEN_NAME, userInfo.givenName), + optionalClaim( + claimNames, + EuPidScheme.Attributes.BIRTH_DATE, + LocalDate.parse(userInfo.birthDate ?: "1970-01-01") + ), + ) + credentials += when (representation) { + ConstantIndex.CredentialRepresentation.SD_JWT -> listOf( + CredentialToBeIssued.VcSd(claims = claims, expiration = expiration) + ) + + ConstantIndex.CredentialRepresentation.PLAIN_JWT -> listOf( + CredentialToBeIssued.VcJwt( + EuPidCredential( + id = subjectId, + familyName = userInfo.familyName, + givenName = userInfo.givenName, + birthDate = LocalDate.parse(userInfo.birthDate ?: "1970-01-01") + ), + expiration, + ) + ) + + ConstantIndex.CredentialRepresentation.ISO_MDOC -> listOf( + CredentialToBeIssued.Iso( + issuerSignedItems = claims.mapIndexed { index, claim -> + issuerSignedItem(claim.name, claim.value, index.toUInt()) + }, + expiration = expiration, + ) + ) + } + } + return KmmResult.success(credentials) + } + + private fun Collection?.isNullOrContains(s: String) = + this == null || contains(s) + + private fun optionalClaim(claimNames: Collection?, name: String, value: Any) = + if (claimNames.isNullOrContains(name)) ClaimToBeIssued(name, value) else null + + + private fun issuerSignedItem(name: String, value: Any, digestId: UInt) = + IssuerSignedItem( + digestId = digestId, + random = Random.nextBytes(16), + elementIdentifier = name, + elementValue = when (value) { + is String -> ElementValue(string = value) + is ByteArray -> ElementValue(bytes = value) + is LocalDate -> ElementValue(date = value) + is Boolean -> ElementValue(boolean = value) + is DrivingPrivilege -> ElementValue(drivingPrivilege = arrayOf(value)) + else -> ElementValue(string = value.toString()) + } + ) +} + +object DummyOAuth2DataProvider : OAuth2DataProvider { + override suspend fun loadUserInfo(request: AuthenticationRequestParameters?): OidcUserInfo { + return OidcUserInfo("Erika", "Musterfrau", "subject") + } +} \ No newline at end of file diff --git a/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidvciInteropTest.kt b/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidvciInteropTest.kt index 8560374f0..1d6d54cdc 100644 --- a/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidvciInteropTest.kt +++ b/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidvciInteropTest.kt @@ -1,10 +1,10 @@ package at.asitplus.wallet.lib.oidvci -import at.asitplus.wallet.lib.agent.DefaultCryptoService import at.asitplus.wallet.lib.agent.IssuerAgent import at.asitplus.wallet.lib.data.ConstantIndex import at.asitplus.wallet.lib.oidc.AuthenticationResponseResult -import at.asitplus.wallet.lib.oidc.DummyCredentialDataProvider +import at.asitplus.wallet.lib.oidc.DummyOAuth2DataProvider +import at.asitplus.wallet.lib.oidc.DummyOAuth2IssuerCredentialDataProvider import at.asitplus.wallet.lib.oidc.OpenIdConstants.PATH_WELL_KNOWN_CREDENTIAL_ISSUER import com.benasher44.uuid.uuid4 import io.kotest.core.spec.style.FunSpec @@ -21,20 +21,19 @@ class OidvciInteropTest : FunSpec({ at.asitplus.wallet.eupid.Initializer.initWithVcLib() } - lateinit var authorizationService: AuthorizationService - lateinit var issuer: IssuerService + lateinit var authorizationService: SimpleAuthorizationService + lateinit var issuer: CredentialIssuer beforeEach { - authorizationService = AuthorizationService( + authorizationService = SimpleAuthorizationService( + dataProvider = DummyOAuth2DataProvider, credentialSchemes = listOf(ConstantIndex.AtomicAttribute2023, ConstantIndex.MobileDrivingLicence2023) ) - issuer = IssuerService( + issuer = CredentialIssuer( authorizationService = authorizationService, - issuer = IssuerAgent.newDefaultInstance( - cryptoService = DefaultCryptoService(), - dataProvider = DummyCredentialDataProvider() - ), - credentialSchemes = listOf(ConstantIndex.AtomicAttribute2023, ConstantIndex.MobileDrivingLicence2023) + issuer = IssuerAgent.newDefaultInstance(), + credentialSchemes = listOf(ConstantIndex.AtomicAttribute2023, ConstantIndex.MobileDrivingLicence2023), + buildIssuerCredentialDataProviderOverride = ::DummyOAuth2IssuerCredentialDataProvider ) } diff --git a/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidvciProcessTest.kt b/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidvciProcessTest.kt index 0b63f1773..938cba7e3 100644 --- a/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidvciProcessTest.kt +++ b/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidvciProcessTest.kt @@ -1,7 +1,6 @@ package at.asitplus.wallet.lib.oidvci import at.asitplus.crypto.datatypes.jws.JwsSigned -import at.asitplus.wallet.lib.agent.DefaultCryptoService import at.asitplus.wallet.lib.agent.IssuerAgent import at.asitplus.wallet.lib.data.ConstantIndex import at.asitplus.wallet.lib.data.VerifiableCredentialJws @@ -9,7 +8,8 @@ import at.asitplus.wallet.lib.data.VerifiableCredentialSdJwt import at.asitplus.wallet.lib.iso.IssuerSigned import at.asitplus.wallet.lib.iso.MobileDrivingLicenceDataElements import at.asitplus.wallet.lib.oidc.AuthenticationResponseResult -import at.asitplus.wallet.lib.oidc.DummyCredentialDataProvider +import at.asitplus.wallet.lib.oidc.DummyOAuth2DataProvider +import at.asitplus.wallet.lib.oidc.DummyOAuth2IssuerCredentialDataProvider import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.ints.shouldBeGreaterThan import io.kotest.matchers.nulls.shouldNotBeNull @@ -21,18 +21,15 @@ import io.matthewnelson.encoding.core.Decoder.Companion.decodeToByteArray class OidvciProcessTest : FunSpec({ - val dataProvider = DummyCredentialDataProvider() - val authorizationService = AuthorizationService( - dataProvider = dataProvider, + val authorizationService = SimpleAuthorizationService( + dataProvider = DummyOAuth2DataProvider, credentialSchemes = listOf(ConstantIndex.AtomicAttribute2023, ConstantIndex.MobileDrivingLicence2023) ) - val issuer = IssuerService( + val issuer = CredentialIssuer( authorizationService = authorizationService, - issuer = IssuerAgent.newDefaultInstance( - cryptoService = DefaultCryptoService(), - dataProvider = dataProvider - ), - credentialSchemes = listOf(ConstantIndex.AtomicAttribute2023, ConstantIndex.MobileDrivingLicence2023) + issuer = IssuerAgent.newDefaultInstance(), + credentialSchemes = listOf(ConstantIndex.AtomicAttribute2023, ConstantIndex.MobileDrivingLicence2023), + buildIssuerCredentialDataProviderOverride = ::DummyOAuth2IssuerCredentialDataProvider ) test("process with W3C VC JWT") { @@ -71,7 +68,7 @@ class OidvciProcessTest : FunSpec({ val serializedCredential = credential.credential serializedCredential.shouldNotBeNull().also { println(it) } - val jws = JwsSigned.parse(serializedCredential) + val jws = JwsSigned.parse(serializedCredential.substringBeforeLast("~")) jws.shouldNotBeNull() val sdJwt = VerifiableCredentialSdJwt.deserialize(jws.payload.decodeToString()) sdJwt.shouldNotBeNull().also { println(it) } @@ -87,14 +84,14 @@ class OidvciProcessTest : FunSpec({ WalletService.RequestOptions( ConstantIndex.AtomicAttribute2023, representation = ConstantIndex.CredentialRepresentation.SD_JWT, - requestedAttributes = listOf("family-name") + requestedAttributes = listOf("family_name") ) ) credential.format shouldBe CredentialFormatEnum.VC_SD_JWT val serializedCredential = credential.credential serializedCredential.shouldNotBeNull().also { println(it) } - val jws = JwsSigned.parse(serializedCredential) + val jws = JwsSigned.parse(serializedCredential.substringBeforeLast("~")) jws.shouldNotBeNull() val sdJwt = VerifiableCredentialSdJwt.deserialize(jws.payload.decodeToString()) sdJwt.shouldNotBeNull().also { println(it) } @@ -168,8 +165,8 @@ class OidvciProcessTest : FunSpec({ }) private suspend fun runProcessWithJwtProof( - authorizationService: AuthorizationService, - issuer: IssuerService, + authorizationService: SimpleAuthorizationService, + issuer: CredentialIssuer, client: WalletService, requestOptions: WalletService.RequestOptions, ): CredentialResponseParameters { @@ -179,8 +176,8 @@ private suspend fun runProcessWithJwtProof( } private suspend fun runProcessWithCwtProof( - authorizationService: AuthorizationService, - issuer: IssuerService, + authorizationService: SimpleAuthorizationService, + issuer: CredentialIssuer, client: WalletService, requestOptions: WalletService.RequestOptions, ): CredentialResponseParameters { @@ -190,7 +187,7 @@ private suspend fun runProcessWithCwtProof( } private suspend fun runProcessGetToken( - authorizationService: AuthorizationService, + authorizationService: SimpleAuthorizationService, client: WalletService, requestOptions: WalletService.RequestOptions, ): TokenResponseParameters { diff --git a/vclib/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/Issuer.kt b/vclib/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/Issuer.kt index bfec09289..d546f3c71 100644 --- a/vclib/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/Issuer.kt +++ b/vclib/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/Issuer.kt @@ -77,12 +77,16 @@ interface Issuer { * key in [subjectPublicKey] in the format specified by [representation]. * Callers may optionally define some attribute names from [ConstantIndex.CredentialScheme.claimNames] in * [claimNames] to request only some claims (if supported by the representation). + * + * @param dataProviderOverride Set this parameter to override the default [IssuerCredentialDataProvider] for this + * issuing process */ suspend fun issueCredential( subjectPublicKey: CryptoPublicKey, attributeTypes: Collection, representation: ConstantIndex.CredentialRepresentation, claimNames: Collection? = null, + dataProviderOverride: IssuerCredentialDataProvider? = null, ): IssuedCredentialResult /** diff --git a/vclib/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/IssuerAgent.kt b/vclib/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/IssuerAgent.kt index fe3855b35..869289298 100644 --- a/vclib/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/IssuerAgent.kt +++ b/vclib/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/IssuerAgent.kt @@ -93,12 +93,16 @@ class IssuerAgent( * key in [subjectPublicKey] in the format specified by [representation]. * Callers may optionally define some attribute names from [ConstantIndex.CredentialScheme.claimNames] in * [claimNames] to request only some claims (if supported by the representation). + * + * @param dataProviderOverride Set this parameter to override the default [dataProvider] for this + * issuing process */ override suspend fun issueCredential( subjectPublicKey: CryptoPublicKey, attributeTypes: Collection, representation: ConstantIndex.CredentialRepresentation, claimNames: Collection?, + dataProviderOverride: IssuerCredentialDataProvider?, ): Issuer.IssuedCredentialResult { val failed = mutableListOf() val successful = mutableListOf() @@ -110,7 +114,7 @@ class IssuerAgent( failed += Issuer.FailedAttribute(attributeType, IllegalArgumentException("type not resolved to scheme")) continue } - dataProvider.getCredential(subjectPublicKey, scheme, representation, claimNames).fold( + (dataProviderOverride ?: dataProvider).getCredential(subjectPublicKey, scheme, representation, claimNames).fold( onSuccess = { toBeIssued -> toBeIssued.forEach { credentialToBeIssued -> issueCredential(credentialToBeIssued, subjectPublicKey, scheme).also { result -> From 8bebef3038a67420484c10eaf7c4b60232013040 Mon Sep 17 00:00:00 2001 From: Christian Kollmann Date: Wed, 24 Apr 2024 11:20:31 +0200 Subject: [PATCH 11/18] Delete interop test until implementation is more advanced --- .../wallet/lib/oidvci/OidvciInteropTest.kt | 209 ------------------ 1 file changed, 209 deletions(-) delete mode 100644 vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidvciInteropTest.kt diff --git a/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidvciInteropTest.kt b/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidvciInteropTest.kt deleted file mode 100644 index 1d6d54cdc..000000000 --- a/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidvciInteropTest.kt +++ /dev/null @@ -1,209 +0,0 @@ -package at.asitplus.wallet.lib.oidvci - -import at.asitplus.wallet.lib.agent.IssuerAgent -import at.asitplus.wallet.lib.data.ConstantIndex -import at.asitplus.wallet.lib.oidc.AuthenticationResponseResult -import at.asitplus.wallet.lib.oidc.DummyOAuth2DataProvider -import at.asitplus.wallet.lib.oidc.DummyOAuth2IssuerCredentialDataProvider -import at.asitplus.wallet.lib.oidc.OpenIdConstants.PATH_WELL_KNOWN_CREDENTIAL_ISSUER -import com.benasher44.uuid.uuid4 -import io.kotest.core.spec.style.FunSpec -import io.kotest.matchers.collections.shouldContainAll -import io.kotest.matchers.collections.shouldHaveSingleElement -import io.kotest.matchers.nulls.shouldNotBeNull -import io.kotest.matchers.shouldBe -import io.kotest.matchers.types.shouldBeInstanceOf -import io.ktor.http.* - -class OidvciInteropTest : FunSpec({ - - beforeSpec { - at.asitplus.wallet.eupid.Initializer.initWithVcLib() - } - - lateinit var authorizationService: SimpleAuthorizationService - lateinit var issuer: CredentialIssuer - - beforeEach { - authorizationService = SimpleAuthorizationService( - dataProvider = DummyOAuth2DataProvider, - credentialSchemes = listOf(ConstantIndex.AtomicAttribute2023, ConstantIndex.MobileDrivingLicence2023) - ) - issuer = CredentialIssuer( - authorizationService = authorizationService, - issuer = IssuerAgent.newDefaultInstance(), - credentialSchemes = listOf(ConstantIndex.AtomicAttribute2023, ConstantIndex.MobileDrivingLicence2023), - buildIssuerCredentialDataProviderOverride = ::DummyOAuth2IssuerCredentialDataProvider - ) - } - - test("EUDIW URL") { - val url = - "eudi-openid4ci://credentialsOffer?credential_offer=%7B%22credential_issuer%22:%22https://localhost/pid-issuer%22,%22credential_configuration_ids%22:[%22eu.europa.ec.eudiw.pid_vc_sd_jwt%22],%22grants%22:%7B%22authorization_code%22:%7B%22authorization_server%22:%22https://localhost/idp/realms/pid-issuer-realm%22%7D%7D%7D" - - val client = WalletService() - - val credentialOffer = - Url(url).parameters["credential_offer"]?.let { CredentialOffer.deserialize(it).getOrThrow() } - credentialOffer.shouldNotBeNull() - println(credentialOffer) - val credentialIssuerMetadataUrl = credentialOffer.credentialIssuer + PATH_WELL_KNOWN_CREDENTIAL_ISSUER - val credentialIssuerMetadataString = """ - { - "credential_issuer": "https://localhost/pid-issuer", - "authorization_servers": [ - "https://localhost/idp/realms/pid-issuer-realm" - ], - "credential_endpoint": "https://localhost/pid-issuer/wallet/credentialEndpoint", - "deferred_credential_endpoint": "https://localhost/pid-issuer/wallet/deferredEndpoint", - "notification_endpoint": "https://localhost/pid-issuer/wallet/notificationEndpoint", - "credential_response_encryption": { - "alg_values_supported": [ - "RSA-OAEP-256" - ], - "enc_values_supported": [ - "A128CBC-HS256" - ], - "encryption_required": true - }, - "credential_identifiers_supported": true, - "credential_configurations_supported": { - "eu.europa.ec.eudiw.pid_vc_sd_jwt": { - "format": "vc+sd-jwt", - "scope": "eu.europa.ec.eudiw.pid_vc_sd_jwt", - "cryptographic_binding_methods_supported": [ - "jwk" - ], - "credential_signing_alg_values_supported": [ - "ES256" - ], - "proof_types_supported": { - "jwt": { - "proof_signing_alg_values_supported": [ - "RS256", - "ES256" - ] - } - }, - "vct": "eu.europa.ec.eudiw.pid.1", - "display": [ - { - "name": "PID", - "locale": "en", - "logo": { - "uri": "https://examplestate.com/public/mdl.png", - "alt_text": "A square figure of a PID" - } - } - ], - "credential_definition": { - "type": ["eu.europa.ec.eudiw.pid.1"], - "claims": { - "family_name": { - "mandatory": false, - "display": [ - { - "name": "Current Family Name", - "locale": "en" - } - ] - }, - "issuance_date": { - "mandatory": true - } - } - } - } - } - } - """.trimIndent() - - val credentialIssuerMetadata = IssuerMetadata.deserialize(credentialIssuerMetadataString).getOrThrow() - credentialIssuerMetadata.credentialIssuer shouldBe "https://localhost/pid-issuer" - credentialIssuerMetadata.authorizationServers!!.shouldHaveSingleElement("https://localhost/idp/realms/pid-issuer-realm") - credentialIssuerMetadata.credentialEndpointUrl shouldBe "https://localhost/pid-issuer/wallet/credentialEndpoint" - credentialIssuerMetadata.deferredCredentialEndpointUrl shouldBe "https://localhost/pid-issuer/wallet/deferredEndpoint" - credentialIssuerMetadata.notificationEndpointUrl shouldBe "https://localhost/pid-issuer/wallet/notificationEndpoint" - credentialIssuerMetadata.credentialResponseEncryption!!.supportedAlgorithms.shouldHaveSingleElement("RSA-OAEP-256") - credentialIssuerMetadata.credentialResponseEncryption!!.supportedEncryptionAlgorithms!!.shouldHaveSingleElement( - "A128CBC-HS256" - ) - credentialIssuerMetadata.credentialResponseEncryption!!.encryptionRequired shouldBe true - credentialIssuerMetadata.supportsCredentialIdentifiers shouldBe true - // select correct credential config by using a configurationId from the offer it self - val credentialConfig = - credentialIssuerMetadata.supportedCredentialConfigurations!![credentialOffer.configurationIds.first()]!! - credentialConfig.format shouldBe CredentialFormatEnum.VC_SD_JWT - credentialConfig.scope shouldBe "eu.europa.ec.eudiw.pid_vc_sd_jwt" - credentialConfig.supportedBindingMethods!!.shouldHaveSingleElement("jwk") - credentialConfig.supportedSigningAlgorithms!!.shouldHaveSingleElement("ES256") - credentialConfig.supportedProofTypes!!["jwt"]!!.supportedSigningAlgorithms.shouldContainAll("RS256", "ES256") - credentialConfig.sdJwtVcType shouldBe "eu.europa.ec.eudiw.pid.1" - // TODO this is wrong in EUDIW's metadata? Should be an array! credentialConfig.credentialDefinition!!.types - credentialConfig.credentialDefinition!!.claims!!.firstNotNullOfOrNull { it.key == "family_name" } - .shouldNotBeNull() - - val authorizationServerMetadataUrl = - credentialIssuerMetadata.authorizationServers?.firstOrNull()?.plus("/.well-known/openid-configuration") - // need to get from URL and parse ... - val authorizationServerMetadata = IssuerMetadata( - issuer = "https://localhiost/idp/realms/pid-issuer-realm", - authorizationEndpointUrl = "https://localhost/idp/realms/pid-issuer-realm/protocol/openid-connect/auth" - ) - val authorizationEndpoint = credentialIssuerMetadata.authorizationEndpointUrl - ?: authorizationServerMetadata.authorizationEndpointUrl - authorizationEndpoint.shouldNotBeNull() - - // selection of end-user, which credential to get - val scopeToRequest = credentialConfig.scope!! - // would also need to parse from authorizationServerMetadata if `request_parameter_supported` is true and so on ... - val authnRequest = client.createAuthRequest(scopeToRequest, credentialIssuerMetadata.credentialIssuer) - println(URLBuilder(authorizationEndpoint) - .apply { - authnRequest.encodeToParameters().forEach { - this.parameters.append(it.key, it.value) - } - } - .buildString() - ) - // Clients may also need to push the authorization request, which is a FORM POST 5.1.4 - - // TODO Better use https://github.com/eu-digital-identity-wallet/eudi-srv-web-issuing-eudiw-py/blob/main/api_docs/pid_oidc_no_auth.md - // and maybe need to implement `code_challenge` and so on - } - - test("process with pre-authorized code and credential offer") { - val client = WalletService() - val credentialOffer = issuer.credentialOffer() - val credentialIssuerMetadata = issuer.metadata - val credentialConfig = - credentialIssuerMetadata.supportedCredentialConfigurations!![credentialOffer.configurationIds.first()]!! - val scopeToRequest = credentialConfig.scope!! - val state = uuid4().toString() - val authnRequest = client.createAuthRequest(scopeToRequest, credentialIssuerMetadata.credentialIssuer, state) - val authnResponse = authorizationService.authorize(authnRequest).getOrThrow() - authnResponse.shouldBeInstanceOf() - val code = authnResponse.params.code - code.shouldNotBeNull() - // TODO Provide a way to authenticate the client ... but how? see `token_endpoint_auth_method` in Client Metadata, RFC 6749 - val requestOptions = WalletService.RequestOptions( - ConstantIndex.AtomicAttribute2023, - representation = ConstantIndex.CredentialRepresentation.SD_JWT, - state = state - ) - val tokenRequest = client.createTokenRequestParameters( - authnResponse.params, - credentialOffer, - requestOptions - ) - val token = authorizationService.token(tokenRequest).getOrThrow() - val credentialRequest = client.createCredentialRequestJwt( - token, - credentialIssuerMetadata, - requestOptions - ).getOrThrow() - val credential = issuer.credential(token.accessToken, credentialRequest) - - credential.shouldNotBeNull() - } -}) From 482f68b205f873aa45b922fd27bf92d66b1c1b49 Mon Sep 17 00:00:00 2001 From: Christian Kollmann Date: Wed, 24 Apr 2024 12:13:45 +0200 Subject: [PATCH 12/18] Update OIDC User Info implementation --- .../OAuth2IssuerCredentialDataProvider.kt | 4 +- .../wallet/lib/oidvci/OidcAddressClaim.kt | 4 +- .../wallet/lib/oidvci/OidcUserInfo.kt | 48 +++++++++++++++---- ...DummyOAuth2IssuerCredentialDataProvider.kt | 22 ++++----- 4 files changed, 53 insertions(+), 25 deletions(-) diff --git a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/OAuth2IssuerCredentialDataProvider.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/OAuth2IssuerCredentialDataProvider.kt index 6741c62a1..f749d475c 100644 --- a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/OAuth2IssuerCredentialDataProvider.kt +++ b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/OAuth2IssuerCredentialDataProvider.kt @@ -36,8 +36,8 @@ class OAuth2IssuerCredentialDataProvider(private val userInfo: OidcUserInfo) : I val subjectId = subjectPublicKey.didEncoded val claims = listOfNotNull( // TODO Extend list of default OIDC claims - optionalClaim(claimNames, "given_name", userInfo.givenName), - optionalClaim(claimNames, "family_name", userInfo.familyName), + userInfo.givenName?.let { optionalClaim(claimNames, "given_name", it) }, + userInfo.familyName?.let { optionalClaim(claimNames, "family_name", it) }, optionalClaim(claimNames, "subject", userInfo.subject), ) credentials += when (representation) { diff --git a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/OidcAddressClaim.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/OidcAddressClaim.kt index c40d679d1..c1919603c 100644 --- a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/OidcAddressClaim.kt +++ b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/OidcAddressClaim.kt @@ -8,6 +8,8 @@ import kotlinx.serialization.Serializable */ @Serializable data class OidcAddressClaim( + @SerialName("formatted") + val formatted: String? = null, @SerialName("street_address") val street: String? = null, @SerialName("locality") @@ -18,6 +20,4 @@ data class OidcAddressClaim( val postalCode: String? = null, @SerialName("country") val country: String? = null, - @SerialName("formatted") - val formatted: String? = null, ) \ No newline at end of file diff --git a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/OidcUserInfo.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/OidcUserInfo.kt index bf08abd2f..d44133a7c 100644 --- a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/OidcUserInfo.kt +++ b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/OidcUserInfo.kt @@ -1,27 +1,55 @@ package at.asitplus.wallet.lib.oidvci +import at.asitplus.wallet.lib.data.InstantLongSerializer +import kotlinx.datetime.Instant import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable /** * [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html) */ data class OidcUserInfo( - @SerialName("given_name") - val givenName: String, - @SerialName("family_name") - val familyName: String, @SerialName("sub") val subject: String, + @SerialName("name") + val name: String? = null, + @SerialName("given_name") + val givenName: String? = null, + @SerialName("family_name") + val familyName: String? = null, + @SerialName("middle_name") + val middleName: String? = null, + @SerialName("nickname") + val nickname: String? = null, + @SerialName("preferred_username") + val preferredUsername: String? = null, + @SerialName("profile") + val profile: String? = null, + @SerialName("picture") + val picture: String? = null, + @SerialName("website") + val website: String? = null, @SerialName("email") val email: String? = null, - @SerialName("address") - val address: OidcAddressClaim? = null, - @SerialName("birthdate") - val birthDate: String? = null, + @SerialName("email_verified") + val emailVerified: Boolean? = null, @SerialName("gender") val gender: String? = null, + @SerialName("birthdate") + val birthDate: String? = null, + @SerialName("zoneinfo") + val timezone: String? = null, + @SerialName("locale") + val locale: String? = null, + @SerialName("phone_number") + val phoneNumber: String? = null, + @SerialName("phone_number_verified") + val phoneNumberVerified: Boolean? = null, + @SerialName("address") + val address: OidcAddressClaim? = null, @SerialName("age_over_18") val ageOver18: Boolean? = null, - @SerialName("picture") - val picture: String? = null, + @SerialName("updated_at") + @Serializable(with = InstantLongSerializer::class) + val updatedAt: Instant? = null, ) \ No newline at end of file diff --git a/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/DummyOAuth2IssuerCredentialDataProvider.kt b/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/DummyOAuth2IssuerCredentialDataProvider.kt index 98682cc9e..b8717c134 100644 --- a/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/DummyOAuth2IssuerCredentialDataProvider.kt +++ b/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/DummyOAuth2IssuerCredentialDataProvider.kt @@ -42,8 +42,8 @@ class DummyOAuth2IssuerCredentialDataProvider( if (credentialScheme == ConstantIndex.AtomicAttribute2023) { val subjectId = subjectPublicKey.didEncoded val claims = listOfNotNull( - optionalClaim(claimNames, "given_name", userInfo.givenName), - optionalClaim(claimNames, "family_name", userInfo.familyName), + userInfo.givenName?.let { optionalClaim(claimNames, "given_name", it) }, + userInfo.familyName?.let { optionalClaim(claimNames, "family_name", it) }, optionalClaim(claimNames, "subject", userInfo.subject), userInfo.birthDate?.let { optionalClaim(claimNames, "date-of-birth", it) }, ) @@ -76,10 +76,10 @@ class DummyOAuth2IssuerCredentialDataProvider( if (credentialScheme == ConstantIndex.MobileDrivingLicence2023) { var digestId = 0U val issuerSignedItems = listOfNotNull( - if (claimNames.isNullOrContains(FAMILY_NAME)) - issuerSignedItem(FAMILY_NAME, userInfo.familyName, digestId++) else null, - if (claimNames.isNullOrContains(GIVEN_NAME)) - issuerSignedItem(GIVEN_NAME, userInfo.givenName, digestId++) else null, + if (claimNames.isNullOrContains(FAMILY_NAME) && userInfo.familyName != null) + issuerSignedItem(FAMILY_NAME, userInfo.familyName!!, digestId++) else null, + if (claimNames.isNullOrContains(GIVEN_NAME) && userInfo.givenName != null) + issuerSignedItem(GIVEN_NAME, userInfo.givenName!!, digestId++) else null, if (claimNames.isNullOrContains(DOCUMENT_NUMBER)) issuerSignedItem(DOCUMENT_NUMBER, "123456789", digestId++) else null, if (claimNames.isNullOrContains(ISSUE_DATE)) @@ -99,8 +99,8 @@ class DummyOAuth2IssuerCredentialDataProvider( if (credentialScheme == EuPidScheme) { val subjectId = subjectPublicKey.didEncoded val claims = listOfNotNull( - optionalClaim(claimNames, EuPidScheme.Attributes.FAMILY_NAME, userInfo.familyName), - optionalClaim(claimNames, EuPidScheme.Attributes.GIVEN_NAME, userInfo.givenName), + userInfo.familyName?.let { optionalClaim(claimNames, EuPidScheme.Attributes.FAMILY_NAME, it) }, + userInfo.givenName?.let { optionalClaim(claimNames, EuPidScheme.Attributes.GIVEN_NAME, it) }, optionalClaim( claimNames, EuPidScheme.Attributes.BIRTH_DATE, @@ -116,8 +116,8 @@ class DummyOAuth2IssuerCredentialDataProvider( CredentialToBeIssued.VcJwt( EuPidCredential( id = subjectId, - familyName = userInfo.familyName, - givenName = userInfo.givenName, + familyName = userInfo.familyName ?: "Unknown", + givenName = userInfo.givenName ?: "Unknown", birthDate = LocalDate.parse(userInfo.birthDate ?: "1970-01-01") ), expiration, @@ -162,6 +162,6 @@ class DummyOAuth2IssuerCredentialDataProvider( object DummyOAuth2DataProvider : OAuth2DataProvider { override suspend fun loadUserInfo(request: AuthenticationRequestParameters?): OidcUserInfo { - return OidcUserInfo("Erika", "Musterfrau", "subject") + return OidcUserInfo(subject = "subject", givenName = "Erika", familyName = "Musterfrau") } } \ No newline at end of file From 7f5642a49ee87765f9f2108587a6cc62732db5ae Mon Sep 17 00:00:00 2001 From: Christian Kollmann Date: Wed, 24 Apr 2024 12:16:05 +0200 Subject: [PATCH 13/18] Fix misleading comment --- .../wallet/lib/oidvci/SupportedAlgorithmsContainer.kt | 4 ---- 1 file changed, 4 deletions(-) diff --git a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/SupportedAlgorithmsContainer.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/SupportedAlgorithmsContainer.kt index f32bc5c90..72a07d733 100644 --- a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/SupportedAlgorithmsContainer.kt +++ b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/SupportedAlgorithmsContainer.kt @@ -9,10 +9,6 @@ data class SupportedAlgorithmsContainer( * OID4VP: An object where the value is an array of case sensitive strings that identify the cryptographic suites * that are supported. Parties will need to agree upon the meanings of the values used, which may be * context-specific, e.g. `EdDSA` and `ES256`. - * - * OID4VCI: REQUIRED. Array containing a list of the JWE (RFC7516) encryption algorithms (alg values) (RFC7518) - * supported by the Credential and Batch Credential Endpoint to encode the Credential or Batch Credential Response - * in a JWT (RFC7519). */ @SerialName("alg_values_supported") val supportedAlgorithms: Collection, From 310a8084e31660622dfd115ca126e5e100eb3b91 Mon Sep 17 00:00:00 2001 From: Christian Kollmann Date: Wed, 24 Apr 2024 21:08:32 +0200 Subject: [PATCH 14/18] OID4VCI: Do not assume transaction codes for authorization servers --- .../kotlin/at/asitplus/wallet/lib/oidvci/CredentialIssuer.kt | 4 ---- 1 file changed, 4 deletions(-) diff --git a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CredentialIssuer.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CredentialIssuer.kt index a787c5bbe..d578e8429 100644 --- a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CredentialIssuer.kt +++ b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CredentialIssuer.kt @@ -83,10 +83,6 @@ class CredentialIssuer( preAuthorizedCode = authorizationService.providePreAuthorizedCode()?.let { CredentialOfferGrantsPreAuthCode( preAuthorizedCode = it, - transactionCode = CredentialOfferGrantsPreAuthCodeTransactionCode( - inputMode = "numeric", - length = 16, - ), authorizationServer = authorizationService.publicContext ) } From c2a45dcb54199041564b465967d750043cb99562 Mon Sep 17 00:00:00 2001 From: Christian Kollmann Date: Wed, 24 Apr 2024 21:30:06 +0200 Subject: [PATCH 15/18] CI: Run tests on iOS --- .github/workflows/test-ios.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test-ios.yml b/.github/workflows/test-ios.yml index 7e792486e..e18fbce77 100644 --- a/.github/workflows/test-ios.yml +++ b/.github/workflows/test-ios.yml @@ -15,7 +15,7 @@ jobs: - name: Build klibs run: ./gradlew iosArm64MainKlibrary iosX64MainKlibrary - name: Run tests - run: ./gradlew iosX64Test + run: ./gradlew iosX64Test :vclib:iosX64Test :vclib-aries:iosX64Test :vclib-openid:iosX64Test - name: Test Report uses: dorny/test-reporter@v1 if: success() || failure() From dc29df2d16080ea72663133fb38d64ad64440a6a Mon Sep 17 00:00:00 2001 From: Christian Kollmann Date: Mon, 29 Apr 2024 21:07:28 +0200 Subject: [PATCH 16/18] Use sets to improve semantics --- CHANGELOG.md | 3 +++ .../wallet/lib/oidc/OidcSiopWallet.kt | 14 ++++++------- .../wallet/lib/oidvci/AuthorizationDetails.kt | 4 ++-- .../wallet/lib/oidvci/CredentialIssuer.kt | 6 +++--- .../oidvci/CredentialSubjectMetadataSingle.kt | 2 +- .../asitplus/wallet/lib/oidvci/Extensions.kt | 14 ++++++------- .../wallet/lib/oidvci/IssuerMetadata.kt | 20 +++++++++---------- .../lib/oidvci/OAuth2AuthorizationServer.kt | 2 +- .../OAuth2IssuerCredentialDataProvider.kt | 6 ++++-- .../lib/oidvci/SimpleAuthorizationService.kt | 6 +++--- .../oidvci/SupportedAlgorithmsContainer.kt | 4 ++-- .../lib/oidvci/SupportedCredentialFormat.kt | 8 ++++---- .../lib/oidvci/TokenResponseParameters.kt | 2 +- .../wallet/lib/oidvci/WalletService.kt | 14 ++++++++----- .../wallet/lib/oidvci/OidvciProcessTest.kt | 8 ++++---- .../at/asitplus/wallet/lib/agent/Issuer.kt | 2 +- .../asitplus/wallet/lib/agent/IssuerAgent.kt | 4 ++-- 17 files changed, 64 insertions(+), 55 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 969c2a4b0..03da4918f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Release 3.6.0: - `OidcSiopVerifier`: Refactor list of parameters for customizing authentication requests to single data class `RequestOptions` - `OidcSiopWallet`: Rename constructor parameter `jwkSetRetriever` to a more general `remoteResourceRetriever`, to use it for various parameters defined by reference - `OidcSiopWallet`: Replace constructor parameter `verifierJwsService` with `requestObjectJwsVerifier` to allow callers to verify JWS objects with a pre-registered key (as in the OpenId4VP client ID scheme "pre-registered") + - Get rid of collections in serializable types and use sets instead - OpenID for Verifiable Credential Issuance: - Implement OpenID for Verifiable Credential Issuance draft 13, from 2024-02-08 - Rename `IssuerService` to `CredentialIssuer` @@ -22,10 +23,12 @@ Release 3.6.0: - `WalletService`: Make public API functions suspending - `WalletService`: Implement proving possesion of private key with CBOR Web Tokens - `WalletService`: Move constructor parameters to `requestOptions` for every method call + - Get rid of collections in serializable types and use sets instead - Dependency updates - Conventions 1.9.23+20240410 - Ktor 2.3.10 - Auto-publish version catalogs + - `Issuer`: Change `cryptoAlgorithms` from `Collection` to `Set` Release 3.5.0: - Kotlin 1.9.23 diff --git a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopWallet.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopWallet.kt index e3571abbb..c2ea922c1 100644 --- a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopWallet.kt +++ b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopWallet.kt @@ -89,13 +89,13 @@ class OidcSiopWallet( IssuerMetadata( issuer = clientId, authorizationEndpointUrl = clientId, - responseTypesSupported = listOf(ID_TOKEN), - scopesSupported = listOf(SCOPE_OPENID), - subjectTypesSupported = listOf("pairwise", "public"), - idTokenSigningAlgorithmsSupported = listOf(jwsService.algorithm.identifier), - requestObjectSigningAlgorithmsSupported = listOf(jwsService.algorithm.identifier), - subjectSyntaxTypesSupported = listOf(URN_TYPE_JWK_THUMBPRINT, PREFIX_DID_KEY), - idTokenTypesSupported = listOf(IdTokenType.SUBJECT_SIGNED), + responseTypesSupported = setOf(ID_TOKEN), + scopesSupported = setOf(SCOPE_OPENID), + subjectTypesSupported = setOf("pairwise", "public"), + idTokenSigningAlgorithmsSupported = setOf(jwsService.algorithm.identifier), + requestObjectSigningAlgorithmsSupported = setOf(jwsService.algorithm.identifier), + subjectSyntaxTypesSupported = setOf(URN_TYPE_JWK_THUMBPRINT, PREFIX_DID_KEY), + idTokenTypesSupported = setOf(IdTokenType.SUBJECT_SIGNED), presentationDefinitionUriSupported = false, ) } diff --git a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/AuthorizationDetails.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/AuthorizationDetails.kt index fc693b274..d2374e3a3 100644 --- a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/AuthorizationDetails.kt +++ b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/AuthorizationDetails.kt @@ -68,7 +68,7 @@ data class AuthorizationDetails( * Must contain an entry form [IssuerMetadata.authorizationServers]. */ @SerialName("locations") - val locations: Collection? = null, + val locations: Set? = null, /** * OID4VCI: OPTIONAL. Array of strings, each uniquely identifying a Credential that can be issued using the Access @@ -81,5 +81,5 @@ data class AuthorizationDetails( * Token in subsequent Credential Requests. */ @SerialName("credential_identifiers") - val credentialIdentifiers: Collection? = null, + val credentialIdentifiers: Set? = null, ) \ No newline at end of file diff --git a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CredentialIssuer.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CredentialIssuer.kt index d578e8429..50cce64c0 100644 --- a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CredentialIssuer.kt +++ b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CredentialIssuer.kt @@ -34,7 +34,7 @@ class CredentialIssuer( /** * List of supported schemes. */ - private val credentialSchemes: Collection, + private val credentialSchemes: Set, /** * Used in several fields in [IssuerMetadata], to provide endpoint URLs to clients. */ @@ -58,13 +58,13 @@ class CredentialIssuer( IssuerMetadata( issuer = publicContext, credentialIssuer = publicContext, - authorizationServers = listOf(authorizationService.publicContext), + authorizationServers = setOf(authorizationService.publicContext), credentialEndpointUrl = "$publicContext$credentialEndpointPath", supportedCredentialConfigurations = mutableMapOf().apply { credentialSchemes.forEach { putAll(it.toSupportedCredentialFormat(issuer.cryptoAlgorithms)) } }, supportsCredentialIdentifiers = true, - displayProperties = credentialSchemes.map { DisplayProperties(it.vcType, "en") } + displayProperties = credentialSchemes.map { DisplayProperties(it.vcType, "en") }.toSet() ) } diff --git a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CredentialSubjectMetadataSingle.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CredentialSubjectMetadataSingle.kt index 399315635..d8916972f 100644 --- a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CredentialSubjectMetadataSingle.kt +++ b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CredentialSubjectMetadataSingle.kt @@ -29,7 +29,7 @@ data class CredentialSubjectMetadataSingle( * Credential for a certain language. */ @SerialName("display") - val display: Collection? = null, + val display: Set? = null, ) diff --git a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/Extensions.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/Extensions.kt index 7d0870bfc..7ddcbe98b 100644 --- a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/Extensions.kt +++ b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/Extensions.kt @@ -10,7 +10,7 @@ import at.asitplus.wallet.lib.oidc.OpenIdConstants import at.asitplus.wallet.lib.oidvci.mdl.RequestedCredentialClaimSpecification import io.matthewnelson.encoding.core.Encoder.Companion.encodeToString -fun ConstantIndex.CredentialScheme.toSupportedCredentialFormat(cryptoAlgorithms: List) = mapOf( +fun ConstantIndex.CredentialScheme.toSupportedCredentialFormat(cryptoAlgorithms: Set) = mapOf( this.isoNamespace to SupportedCredentialFormat( format = CredentialFormatEnum.MSO_MDOC, scope = vcType, @@ -18,8 +18,8 @@ fun ConstantIndex.CredentialScheme.toSupportedCredentialFormat(cryptoAlgorithms: claims = mapOf( isoNamespace to claimNames.associateWith { RequestedCredentialClaimSpecification() } ), - supportedBindingMethods = listOf(OpenIdConstants.BINDING_METHOD_COSE_KEY), - supportedSigningAlgorithms = cryptoAlgorithms.map { it.toJwsAlgorithm().identifier }, + supportedBindingMethods = setOf(OpenIdConstants.BINDING_METHOD_COSE_KEY), + supportedSigningAlgorithms = cryptoAlgorithms.map { it.toJwsAlgorithm().identifier }.toSet(), ), encodeToCredentialIdentifier(CredentialFormatEnum.JWT_VC) to SupportedCredentialFormat( format = CredentialFormatEnum.JWT_VC, @@ -28,8 +28,8 @@ fun ConstantIndex.CredentialScheme.toSupportedCredentialFormat(cryptoAlgorithms: types = listOf(VcDataModelConstants.VERIFIABLE_CREDENTIAL, vcType), credentialSubject = claimNames.associateWith { CredentialSubjectMetadataSingle() } ), - supportedBindingMethods = listOf(OpenIdConstants.PREFIX_DID_KEY, OpenIdConstants.URN_TYPE_JWK_THUMBPRINT), - supportedSigningAlgorithms = cryptoAlgorithms.map { it.toJwsAlgorithm().identifier }, + supportedBindingMethods = setOf(OpenIdConstants.PREFIX_DID_KEY, OpenIdConstants.URN_TYPE_JWK_THUMBPRINT), + supportedSigningAlgorithms = cryptoAlgorithms.map { it.toJwsAlgorithm().identifier }.toSet(), ), encodeToCredentialIdentifier(CredentialFormatEnum.VC_SD_JWT) to SupportedCredentialFormat( format = CredentialFormatEnum.VC_SD_JWT, @@ -38,8 +38,8 @@ fun ConstantIndex.CredentialScheme.toSupportedCredentialFormat(cryptoAlgorithms: claims = mapOf( isoNamespace to claimNames.associateWith { RequestedCredentialClaimSpecification() } ), - supportedBindingMethods = listOf(OpenIdConstants.PREFIX_DID_KEY, OpenIdConstants.URN_TYPE_JWK_THUMBPRINT), - supportedSigningAlgorithms = cryptoAlgorithms.map { it.toJwsAlgorithm().identifier }, + supportedBindingMethods = setOf(OpenIdConstants.PREFIX_DID_KEY, OpenIdConstants.URN_TYPE_JWK_THUMBPRINT), + supportedSigningAlgorithms = cryptoAlgorithms.map { it.toJwsAlgorithm().identifier }.toSet(), ) ) diff --git a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/IssuerMetadata.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/IssuerMetadata.kt index 50f2c5bf3..8794ac3a5 100644 --- a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/IssuerMetadata.kt +++ b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/IssuerMetadata.kt @@ -35,7 +35,7 @@ data class IssuerMetadata( * identifier is used to obtain the Authorization Server metadata. */ @SerialName("authorization_servers") - val authorizationServers: Collection? = null, + val authorizationServers: Set? = null, /** * OID4VCI: REQUIRED. URL of the Credential Issuer's Credential Endpoint. This URL MUST use the https scheme and @@ -123,7 +123,7 @@ data class IssuerMetadata( * a certain language. */ @SerialName("display") - val displayProperties: Collection? = null, + val displayProperties: Set? = null, /** * OIDC Discovery: REQUIRED. JSON array containing a list of the OAuth 2.0 `response_type` values that this OP @@ -132,21 +132,21 @@ data class IssuerMetadata( * OIDC SIOPv2: MUST be `id_token`. */ @SerialName("response_types_supported") - val responseTypesSupported: Collection? = null, + val responseTypesSupported: Set? = null, /** * OIDC SIOPv2: REQUIRED. A JSON array of strings representing supported scopes. * MUST support the `openid` scope value. */ @SerialName("scopes_supported") - val scopesSupported: Collection? = null, + val scopesSupported: Set? = null, /** * OIDC Discovery: REQUIRED. JSON array containing a list of the Subject Identifier types that this OP supports. * Valid types include `pairwise` and `public`. */ @SerialName("subject_types_supported") - val subjectTypesSupported: Collection? = null, + val subjectTypesSupported: Set? = null, /** * OIDC Discovery: REQUIRED. A JSON array containing a list of the JWS signing algorithms (`alg` values) supported @@ -154,7 +154,7 @@ data class IssuerMetadata( * Valid values include `RS256`, `ES256`, `ES256K`, and `EdDSA`. */ @SerialName("id_token_signing_alg_values_supported") - val idTokenSigningAlgorithmsSupported: Collection? = null, + val idTokenSigningAlgorithmsSupported: Set? = null, /** * OIDC SIOPv2: REQUIRED. A JSON array containing a list of the JWS signing algorithms (alg values) supported by the @@ -162,7 +162,7 @@ data class IssuerMetadata( * Valid values include `none`, `RS256`, `ES256`, `ES256K`, and `EdDSA`. */ @SerialName("request_object_signing_alg_values_supported") - val requestObjectSigningAlgorithmsSupported: Collection? = null, + val requestObjectSigningAlgorithmsSupported: Set? = null, /** * OIDC SIOPv2: REQUIRED. A JSON array of strings representing URI scheme identifiers and optionally method names of @@ -170,7 +170,7 @@ data class IssuerMetadata( * Valid values include `urn:ietf:params:oauth:jwk-thumbprint`, `did:example` and others. */ @SerialName("subject_syntax_types_supported") - val subjectSyntaxTypesSupported: Collection? = null, + val subjectSyntaxTypesSupported: Set? = null, /** * OIDC SIOPv2: OPTIONAL. A JSON array of strings containing the list of ID Token types supported by the OP, @@ -179,7 +179,7 @@ data class IssuerMetadata( * ID Token, i.e. the id token is signed with key material under the end-user's control). */ @SerialName("id_token_types_supported") - val idTokenTypesSupported: Collection? = null, + val idTokenTypesSupported: Set? = null, /** * OID4VP: OPTIONAL. Boolean value specifying whether the Wallet supports the transfer of `presentation_definition` @@ -202,7 +202,7 @@ data class IssuerMetadata( * If omitted, the default value is pre-registered. */ @SerialName("client_id_schemes_supported") - val clientIdSchemesSupported: Collection? = null, + val clientIdSchemesSupported: Set? = null, ) { fun serialize() = jsonSerializer.encodeToString(this) diff --git a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/OAuth2AuthorizationServer.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/OAuth2AuthorizationServer.kt index 5fa5d5e8e..cb964360b 100644 --- a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/OAuth2AuthorizationServer.kt +++ b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/OAuth2AuthorizationServer.kt @@ -25,6 +25,6 @@ interface OAuth2AuthorizationServer { suspend fun getUserInfo(accessToken: String): KmmResult // TODO How is this supposed to happen when using an external Authorization Server? - fun verifyAndRemoveClientNonce(nonce: String): Boolean + suspend fun verifyAndRemoveClientNonce(nonce: String): Boolean } diff --git a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/OAuth2IssuerCredentialDataProvider.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/OAuth2IssuerCredentialDataProvider.kt index f749d475c..7c7e1b7b7 100644 --- a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/OAuth2IssuerCredentialDataProvider.kt +++ b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/OAuth2IssuerCredentialDataProvider.kt @@ -19,9 +19,11 @@ import kotlin.time.Duration.Companion.minutes * Adapter implementation to convert [userInfo] obtained from an [OAuth2AuthorizationServer] * into credentials needed by [IssuerCredentialDataProvider]. */ -class OAuth2IssuerCredentialDataProvider(private val userInfo: OidcUserInfo) : IssuerCredentialDataProvider { - +class OAuth2IssuerCredentialDataProvider( + private val userInfo: OidcUserInfo, private val clock: Clock = Clock.System +) : IssuerCredentialDataProvider { + private val defaultLifetime = 1.minutes override fun getCredential( diff --git a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/SimpleAuthorizationService.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/SimpleAuthorizationService.kt index dd29bedbf..030f7636b 100644 --- a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/SimpleAuthorizationService.kt +++ b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/SimpleAuthorizationService.kt @@ -30,7 +30,7 @@ class SimpleAuthorizationService( /** * List of supported schemes. */ - private val credentialSchemes: Collection, + private val credentialSchemes: Set, /** * Used to create and verify authorization codes during issuing. */ @@ -169,7 +169,7 @@ class SimpleAuthorizationService( clientNonce = clientNonceService.provideNonce(), authorizationDetails = params.authorizationDetails?.let { // TODO supported credential identifiers! - listOf(it) + setOf(it) } ) return KmmResult.success(result) @@ -182,7 +182,7 @@ class SimpleAuthorizationService( } } - override fun verifyAndRemoveClientNonce(nonce: String): Boolean { + override suspend fun verifyAndRemoveClientNonce(nonce: String): Boolean { return clientNonceService.verifyAndRemoveNonce(nonce) } diff --git a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/SupportedAlgorithmsContainer.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/SupportedAlgorithmsContainer.kt index 72a07d733..0b06573f7 100644 --- a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/SupportedAlgorithmsContainer.kt +++ b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/SupportedAlgorithmsContainer.kt @@ -11,7 +11,7 @@ data class SupportedAlgorithmsContainer( * context-specific, e.g. `EdDSA` and `ES256`. */ @SerialName("alg_values_supported") - val supportedAlgorithms: Collection, + val supportedAlgorithms: Set, /** * OID4VCI: REQUIRED. Array containing a list of the JWE (RFC7516) encryption algorithms (enc values) (RFC7518) @@ -19,7 +19,7 @@ data class SupportedAlgorithmsContainer( * in a JWT (RFC7519). */ @SerialName("enc_values_supported") - val supportedEncryptionAlgorithms: Collection? = null, + val supportedEncryptionAlgorithms: Set? = null, /** * OID4VCI: REQUIRED. Boolean value specifying whether the Credential Issuer requires the additional encryption diff --git a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/SupportedCredentialFormat.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/SupportedCredentialFormat.kt index c42c61ca7..578cc39f0 100644 --- a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/SupportedCredentialFormat.kt +++ b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/SupportedCredentialFormat.kt @@ -41,14 +41,14 @@ data class SupportedCredentialFormat( * represented by `did:example`. */ @SerialName("cryptographic_binding_methods_supported") - val supportedBindingMethods: Collection? = null, + val supportedBindingMethods: Set? = null, /** * OID4VCI: OPTIONAL. Array of case sensitive strings that identify the algorithms that the Issuer uses to sign the * issued Credential. Algorithm names used are determined by the Credential format and are defined in Appendix A. */ @SerialName("credential_signing_alg_values_supported") - val supportedSigningAlgorithms: Collection? = null, + val supportedSigningAlgorithms: Set? = null, /** * OID4VCI: OPTIONAL. Object that describes specifics of the key proof(s) that the Credential Issuer supports. @@ -97,12 +97,12 @@ data class SupportedCredentialFormat( * An array of `claims.display.name` values that lists them in the order they should be displayed by the Wallet. */ @SerialName("order") - val order: Collection? = null, + val order: Set? = null, /** * OID4VCI: OPTIONAL. Array of objects, where each object contains the display properties of the supported * Credential for a certain language. Below is a non-exhaustive list of parameters that MAY be included. */ @SerialName("display") - val display: Collection? = null, + val display: Set? = null, ) \ No newline at end of file diff --git a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/TokenResponseParameters.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/TokenResponseParameters.kt index 3c3a1523e..e7a3bdbf5 100644 --- a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/TokenResponseParameters.kt +++ b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/TokenResponseParameters.kt @@ -80,5 +80,5 @@ data class TokenResponseParameters( * It MUST NOT be used otherwise. */ @SerialName("authorization_details") - val authorizationDetails: Collection? = null, + val authorizationDetails: Set? = null, ) \ No newline at end of file diff --git a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/WalletService.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/WalletService.kt index 77f6b9dcc..7dd308ded 100644 --- a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/WalletService.kt +++ b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/WalletService.kt @@ -80,11 +80,15 @@ class WalletService( * List of attributes that shall be requested explicitly (selective disclosure), * or `null` to make no restrictions */ - val requestedAttributes: List? = null, + val requestedAttributes: Set? = null, /** * Opaque value which will be returned by the OpenId Provider and also in [AuthnResponseResult] */ val state: String = uuid4().toString(), + /** + * Modify clock for testing specific scenarios + */ + val clock: Clock = Clock.System, ) /** @@ -207,7 +211,7 @@ class WalletService( payload = JsonWebToken( issuer = clientId, audience = issuerMetadata.credentialIssuer, - issuedAt = Clock.System.now(), + issuedAt = requestOptions.clock.now(), nonce = tokenResponse.clientNonce, ).serialize().encodeToByteArray(), addKeyId = false, @@ -252,7 +256,7 @@ class WalletService( payload = CborWebToken( issuer = clientId, audience = issuerMetadata.credentialIssuer, - issuedAt = Clock.System.now(), + issuedAt = requestOptions.clock.now(), nonce = tokenResponse.clientNonce?.encodeToByteArray(), ).serialize() ).getOrElse { @@ -274,7 +278,7 @@ class WalletService( private fun ConstantIndex.CredentialRepresentation.toAuthorizationDetails( credentialScheme: ConstantIndex.CredentialScheme, - requestedAttributes: Collection? + requestedAttributes: Set? ) = when (this) { ConstantIndex.CredentialRepresentation.PLAIN_JWT, ConstantIndex.CredentialRepresentation.SD_JWT -> AuthorizationDetails( @@ -296,7 +300,7 @@ class WalletService( private fun ConstantIndex.CredentialRepresentation.toCredentialRequestParameters( credentialScheme: ConstantIndex.CredentialScheme, - requestedAttributes: Collection?, + requestedAttributes: Set?, proof: CredentialRequestProof ) = when (this) { ConstantIndex.CredentialRepresentation.PLAIN_JWT, diff --git a/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidvciProcessTest.kt b/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidvciProcessTest.kt index 938cba7e3..2cc06c4c1 100644 --- a/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidvciProcessTest.kt +++ b/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidvciProcessTest.kt @@ -23,12 +23,12 @@ class OidvciProcessTest : FunSpec({ val authorizationService = SimpleAuthorizationService( dataProvider = DummyOAuth2DataProvider, - credentialSchemes = listOf(ConstantIndex.AtomicAttribute2023, ConstantIndex.MobileDrivingLicence2023) + credentialSchemes = setOf(ConstantIndex.AtomicAttribute2023, ConstantIndex.MobileDrivingLicence2023) ) val issuer = CredentialIssuer( authorizationService = authorizationService, issuer = IssuerAgent.newDefaultInstance(), - credentialSchemes = listOf(ConstantIndex.AtomicAttribute2023, ConstantIndex.MobileDrivingLicence2023), + credentialSchemes = setOf(ConstantIndex.AtomicAttribute2023, ConstantIndex.MobileDrivingLicence2023), buildIssuerCredentialDataProviderOverride = ::DummyOAuth2IssuerCredentialDataProvider ) @@ -84,7 +84,7 @@ class OidvciProcessTest : FunSpec({ WalletService.RequestOptions( ConstantIndex.AtomicAttribute2023, representation = ConstantIndex.CredentialRepresentation.SD_JWT, - requestedAttributes = listOf("family_name") + requestedAttributes = setOf("family_name") ) ) credential.format shouldBe CredentialFormatEnum.VC_SD_JWT @@ -129,7 +129,7 @@ class OidvciProcessTest : FunSpec({ WalletService.RequestOptions( ConstantIndex.MobileDrivingLicence2023, representation = ConstantIndex.CredentialRepresentation.ISO_MDOC, - requestedAttributes = listOf(MobileDrivingLicenceDataElements.DOCUMENT_NUMBER) + requestedAttributes = setOf(MobileDrivingLicenceDataElements.DOCUMENT_NUMBER) ) ) credential.format shouldBe CredentialFormatEnum.MSO_MDOC diff --git a/vclib/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/Issuer.kt b/vclib/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/Issuer.kt index d546f3c71..f5affed2d 100644 --- a/vclib/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/Issuer.kt +++ b/vclib/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/Issuer.kt @@ -69,7 +69,7 @@ interface Issuer { * The cryptographic algorithms supported by this issuer, i.e. the ones from its cryptographic service, * used to sign credentials. */ - val cryptoAlgorithms: List + val cryptoAlgorithms: Set /** * Issues credentials for some [attributeTypes] (i.e. some of diff --git a/vclib/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/IssuerAgent.kt b/vclib/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/IssuerAgent.kt index 869289298..726b5c733 100644 --- a/vclib/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/IssuerAgent.kt +++ b/vclib/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/IssuerAgent.kt @@ -59,7 +59,7 @@ class IssuerAgent( private val coseService: CoseService, private val clock: Clock = Clock.System, override val identifier: String, - override val cryptoAlgorithms: List, + override val cryptoAlgorithms: Set, private val timePeriodProvider: TimePeriodProvider = FixedTimePeriodProvider, ) : Issuer { @@ -81,7 +81,7 @@ class IssuerAgent( coseService = DefaultCoseService(cryptoService), dataProvider = dataProvider, identifier = cryptoService.publicKey.didEncoded, - cryptoAlgorithms = listOf(cryptoService.algorithm), + cryptoAlgorithms = setOf(cryptoService.algorithm), timePeriodProvider = timePeriodProvider, clock = clock, ) From 815685bb256ea5cc612191fe309c423a0f57269a Mon Sep 17 00:00:00 2001 From: CI Runner Date: Tue, 30 Apr 2024 09:49:54 +0200 Subject: [PATCH 17/18] CI: Run iOS tests again --- .github/workflows/test-ios.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test-ios.yml b/.github/workflows/test-ios.yml index e18fbce77..9f12c8dae 100644 --- a/.github/workflows/test-ios.yml +++ b/.github/workflows/test-ios.yml @@ -15,7 +15,7 @@ jobs: - name: Build klibs run: ./gradlew iosArm64MainKlibrary iosX64MainKlibrary - name: Run tests - run: ./gradlew iosX64Test :vclib:iosX64Test :vclib-aries:iosX64Test :vclib-openid:iosX64Test + run: ./gradlew iosSimulatorArm64Test - name: Test Report uses: dorny/test-reporter@v1 if: success() || failure() From 31465f685fbaf19809b944ede5f14f0f732a508d Mon Sep 17 00:00:00 2001 From: CI Runner Date: Tue, 30 Apr 2024 10:07:46 +0200 Subject: [PATCH 18/18] CI: Fix duplicate test names --- .../at/asitplus/wallet/lib/agent/ValidatorVcTest.kt | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/vclib/src/commonTest/kotlin/at/asitplus/wallet/lib/agent/ValidatorVcTest.kt b/vclib/src/commonTest/kotlin/at/asitplus/wallet/lib/agent/ValidatorVcTest.kt index b5d2235f0..2647621ce 100644 --- a/vclib/src/commonTest/kotlin/at/asitplus/wallet/lib/agent/ValidatorVcTest.kt +++ b/vclib/src/commonTest/kotlin/at/asitplus/wallet/lib/agent/ValidatorVcTest.kt @@ -345,9 +345,12 @@ class ValidatorVcTest : FreeSpec() { private fun credentialNameFn(it: CredentialToBeIssued): String = when (it) { - is CredentialToBeIssued.Iso -> it::class.simpleName ?: "Iso" - is CredentialToBeIssued.VcJwt -> it::class.simpleName ?: "VcJwt" - is CredentialToBeIssued.VcSd -> it::class.simpleName ?: "VcSd" + is CredentialToBeIssued.Iso -> (it::class.simpleName ?: "Iso") + "-" + + it.issuerSignedItems.hashCode() + is CredentialToBeIssued.VcJwt -> (it::class.simpleName ?: "VcJwt") + "-" + + it.subject.hashCode() + is CredentialToBeIssued.VcSd -> (it::class.simpleName ?: "VcSd") + "-" + + it.claims.hashCode() } private fun issueCredential( @@ -396,7 +399,7 @@ class ValidatorVcTest : FreeSpec() { jwtId = jwtId ) - private suspend fun signJws(vcJws: VerifiableCredentialJws): String? { + private suspend fun signJws(vcJws: VerifiableCredentialJws): String { val vcSerialized = vcJws.serialize() val jwsPayload = vcSerialized.encodeToByteArray() return issuerJwsService.createSignedJwt(JwsContentTypeConstants.JWT, jwsPayload).getOrThrow().serialize()