diff --git a/.github/workflows/test-ios.yml b/.github/workflows/test-ios.yml index 7e792486e..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 + run: ./gradlew iosSimulatorArm64Test - name: Test Report uses: dorny/test-reporter@v1 if: success() || failure() diff --git a/CHANGELOG.md b/CHANGELOG.md index e2ea15724..03da4918f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,20 +1,34 @@ # 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") + - 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` + - 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 + - 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/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/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/commonMain/kotlin/at/asitplus/wallet/lib/oidc/AuthenticationRequestParameters.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/AuthenticationRequestParameters.kt index 17b97f857..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 @@ -227,6 +227,27 @@ 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, + + /** + * 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/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..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 @@ -85,34 +85,17 @@ 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, 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 = 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, ) } @@ -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 1ae140d9f..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 @@ -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" @@ -24,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] */ @@ -36,7 +40,20 @@ object OpenIdConstants { */ const val JWT = "jwt" + /** + * Proof type in [at.asitplus.wallet.lib.oidvci.CredentialRequestProof] + */ + 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" } /** @@ -145,6 +162,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 0b398d4b6..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 @@ -5,72 +5,81 @@ 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 - - other as AuthorizationDetails + @SerialName("vct") + val sdJwtVcType: String? = null, - 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 - } + /** + * Must contain an entry form [IssuerMetadata.authorizationServers]. + */ + @SerialName("locations") + val locations: Set? = null, - 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 + /** + * 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: Set? = 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/CredentialIssuer.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CredentialIssuer.kt new file mode 100644 index 000000000..50cce64c0 --- /dev/null +++ b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CredentialIssuer.kt @@ -0,0 +1,212 @@ +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.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 +import com.benasher44.uuid.uuid4 +import io.github.aakira.napier.Napier +import io.matthewnelson.encoding.core.Decoder.Companion.decodeToByteArray + +/** + * 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 CredentialIssuer( + /** + * Used to get the user data, and access tokens. + */ + private val authorizationService: OAuth2AuthorizationServer, + /** + * Used to actually issue the credential. + */ + private val issuer: Issuer, + /** + * List of supported schemes. + */ + private val credentialSchemes: Set, + /** + * Used in several fields in [IssuerMetadata], to provide endpoint URLs to clients. + */ + 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` + */ + val metadata: IssuerMetadata by lazy { + IssuerMetadata( + issuer = publicContext, + credentialIssuer = 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") }.toSet() + ) + } + + /** + * Offer all [credentialSchemes] to clients. + * Callers may need to transport this in [CredentialOfferUrlParameters] to (HTTPS) clients. + */ + suspend fun credentialOffer(): CredentialOffer = CredentialOffer( + credentialIssuer = publicContext, + 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 = authorizationService.publicContext + ), + preAuthorizedCode = authorizationService.providePreAuthorizedCode()?.let { + CredentialOfferGrantsPreAuthCode( + preAuthorizedCode = it, + authorizationServer = authorizationService.publicContext + ) + } + ) + ) + + /** + * 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 params Parameters the client sent JSON-serialized in the HTTP body + */ + suspend fun credential( + accessToken: String, + params: CredentialRequestParameters + ): KmmResult { + val proof = params.proof + ?: return KmmResult.failure(OAuth2Exception(Errors.INVALID_REQUEST)) + .also { Napier.w("credential: client did not provide proof of possession") } + 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 || !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) + 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 || !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 + 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 userInfo = authorizationService.getUserInfo(accessToken).getOrNull() + ?: return KmmResult.failure(OAuth2Exception(Errors.INVALID_TOKEN)) + .also { Napier.w("credential: client did not provide correct token: $accessToken") } + + 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 }, + dataProviderOverride = buildIssuerCredentialDataProviderOverride(userInfo) + ) + } + + params.credentialIdentifier != null -> { + 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}") } + } + issuer.issueCredential( + subjectPublicKey = subjectPublicKey, + attributeTypes = listOf(vcType), + representation = representation.toRepresentation(), + claimNames = params.claims?.map { it.value.keys }?.flatten()?.ifEmpty { null }, + dataProviderOverride = buildIssuerCredentialDataProviderOverride(userInfo) + ) + } + + 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()) { + 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 + val result = issuedCredentialResult.successful.first().toCredentialResponseParameters() + Napier.i("credential returns $result") + return KmmResult.success(result) + } + + +} 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..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,77 +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: - * ISO mDL: N/A. - * W3C Verifiable Credentials: 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. - * W3C Verifiable Credentials: N/A. - * - * 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. - * 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. + * 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 3d964a778..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,15 +6,22 @@ import kotlinx.serialization.Serializable @Serializable data class CredentialRequestProof( /** - * OID4VCI: - * e.g. `jwt` + * OID4VCI: e.g. `jwt`, or `cwt`, or `ldp_vp`. See [at.asitplus.wallet.lib.oidc.OpenIdConstants.ProofTypes]. */ @SerialName("proof_type") val proofType: String, /** - * See OID4VCI Proof Type "JWT" + * 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("jwt") - val jwt: String + 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/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..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 @@ -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: Set? = 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/Extensions.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/Extensions.kt new file mode 100644 index 000000000..7ddcbe98b --- /dev/null +++ b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/Extensions.kt @@ -0,0 +1,87 @@ +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: Set) = mapOf( + this.isoNamespace to SupportedCredentialFormat( + format = CredentialFormatEnum.MSO_MDOC, + scope = vcType, + docType = isoDocType, + claims = mapOf( + isoNamespace to claimNames.associateWith { RequestedCredentialClaimSpecification() } + ), + supportedBindingMethods = setOf(OpenIdConstants.BINDING_METHOD_COSE_KEY), + supportedSigningAlgorithms = cryptoAlgorithms.map { it.toJwsAlgorithm().identifier }.toSet(), + ), + encodeToCredentialIdentifier(CredentialFormatEnum.JWT_VC) to SupportedCredentialFormat( + format = CredentialFormatEnum.JWT_VC, + scope = vcType, + credentialDefinition = SupportedCredentialFormatDefinition( + types = listOf(VcDataModelConstants.VERIFIABLE_CREDENTIAL, vcType), + credentialSubject = claimNames.associateWith { CredentialSubjectMetadataSingle() } + ), + 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, + scope = vcType, + sdJwtVcType = vcType, + claims = mapOf( + isoNamespace to claimNames.associateWith { RequestedCredentialClaimSpecification() } + ), + supportedBindingMethods = setOf(OpenIdConstants.PREFIX_DID_KEY, OpenIdConstants.URN_TYPE_JWK_THUMBPRINT), + supportedSigningAlgorithms = cryptoAlgorithms.map { it.toJwsAlgorithm().identifier }.toSet(), + ) +) + +/** + * 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 + 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/IssuerMetadata.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/IssuerMetadata.kt index 12faa3590..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 @@ -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: Set? = 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 + * [AuthorizationDetails.credentialIdentifiers] in the 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: Set? = 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: Set? = 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: 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: Array? = null, + val subjectTypesSupported: Set? = 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: Set? = 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: Set? = 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: Set? = 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: Set? = 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: Set? = 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 deleted file mode 100644 index 496a7b002..000000000 --- a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/IssuerService.kt +++ /dev/null @@ -1,222 +0,0 @@ -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 -import at.asitplus.wallet.lib.data.ConstantIndex -import at.asitplus.wallet.lib.data.VcDataModelConstants.VERIFIABLE_CREDENTIAL -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.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 io.ktor.http.* -import io.matthewnelson.encoding.core.Encoder.Companion.encodeToString -import kotlin.coroutines.cancellation.CancellationException - -/** - * 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. - */ -class IssuerService( - /** - * Used to actually issue the credential. - */ - private val issuer: Issuer, - /** - * 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.authorizationServer]. - */ - 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]. - */ - private val credentialEndpointPath: String = "/credential", -) { - - /** - * Serve this result JSON-serialized under `/.well-known/openid-credential-issuer` - */ - val metadata: IssuerMetadata by lazy { - IssuerMetadata( - issuer = publicContext, - credentialIssuer = publicContext, - authorizationServer = authorizationServer, - authorizationEndpointUrl = "$publicContext$authorizationEndpointPath", - tokenEndpointUrl = "$publicContext$tokenEndpointPath", - credentialEndpointUrl = "$publicContext$credentialEndpointPath", - supportedCredentialFormat = credentialSchemes.flatMap { it.toSupportedCredentialFormat() }.toTypedArray(), - displayProperties = credentialSchemes - .map { DisplayProperties(it.vcType, "en") } - .toTypedArray() - ) - } - - private fun ConstantIndex.CredentialScheme.toSupportedCredentialFormat() = listOf( - 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(), - ), - 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(), - ), - 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(), - ) - ) - - 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 - * code from [codeService]. - */ - fun authorize(params: AuthenticationRequestParameters): String? { - val builder = URLBuilder(params.redirectUrl ?: return null) - builder.parameters.append(OpenIdConstants.GRANT_TYPE_CODE, codeService.provideCode()) - return builder.buildString() - } - - /** - * 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 { - if (!codeService.verifyCode(params.code)) - throw OAuth2Exception(Errors.INVALID_CODE) - return TokenResponseParameters( - accessToken = tokenService.provideToken(), - tokenType = TOKEN_TYPE_BEARER, - expires = 3600, - clientNonce = clientNonceService.provideNonce() - ) - } - - /** - * Verifies the [authorizationHeader] 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 params Parameters the client sent JSON-serialized in the HTTP body - */ - @Throws(OAuth2Exception::class, CancellationException::class) - suspend fun credential( - authorizationHeader: String, - 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) - throw OAuth2Exception(Errors.INVALID_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) - if (jwt.nonce == null || !clientNonceService.verifyAndRemoveNonce(jwt.nonce!!)) - throw OAuth2Exception(Errors.INVALID_PROOF) - if (jwsSigned.header.type != ProofTypes.JWT_HEADER_TYPE) - throw OAuth2Exception(Errors.INVALID_PROOF) - 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 } - ) - if (issuedCredentialResult.successful.isEmpty()) { - throw OAuth2Exception(Errors.INVALID_REQUEST) - } - return issuedCredentialResult.successful.first().toCredentialResponseParameters() - } - - 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.JWT_VC_SD, - credential = vcSdJwt, - ) - } - -} - -private fun CredentialFormatEnum.toRepresentation() = when (this) { - CredentialFormatEnum.JWT_VC_SD -> 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/OAuth2AuthorizationServer.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/OAuth2AuthorizationServer.kt new file mode 100644 index 000000000..cb964360b --- /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? + suspend 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..7c7e1b7b7 --- /dev/null +++ b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/OAuth2IssuerCredentialDataProvider.kt @@ -0,0 +1,90 @@ +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, + 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( + // TODO Extend list of default OIDC claims + userInfo.givenName?.let { optionalClaim(claimNames, "given_name", it) }, + userInfo.familyName?.let { optionalClaim(claimNames, "family_name", it) }, + 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..c1919603c --- /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("formatted") + val formatted: String? = null, + @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, +) \ 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..d44133a7c --- /dev/null +++ b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/OidcUserInfo.kt @@ -0,0 +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("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("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("updated_at") + @Serializable(with = InstantLongSerializer::class) + val updatedAt: Instant? = null, +) \ No newline at end of file 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 new file mode 100644 index 000000000..030f7636b --- /dev/null +++ b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/SimpleAuthorizationService.kt @@ -0,0 +1,202 @@ +package at.asitplus.wallet.lib.oidvci + +import at.asitplus.KmmResult +import at.asitplus.crypto.datatypes.io.Base64UrlStrict +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 [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 SimpleAuthorizationService( + /** + * Source of user data. + */ + private val dataProvider: OAuth2DataProvider, + /** + * List of supported schemes. + */ + private val credentialSchemes: Set, + /** + * 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/authorization-server", + /** + * 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", +) : OAuth2AuthorizationServer { + + private val codeToCodeChallengeMap = mutableMapOf() + 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` + */ + 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]. + */ + 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().also { + codeToUserInfoMutex.withLock { codeToUserInfoMap[it] = dataProvider.loadUserInfo(request) } + } + val responseParams = AuthenticationResponseParameters( + code = code, + state = request.state, + ) + if (request.codeChallenge != null) { + codeToCodeChallengeMutex.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 { + 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(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(Errors.INVALID_GRANT)) + .also { Napier.w("token: client requested invalid credential identifier: $credentialIdentifier") } + } + } + } + params.codeVerifier?.let { codeVerifier -> + codeToCodeChallengeMutex.withLock { codeToCodeChallengeMap.remove(params.code) }?.let { codeChallenge -> + 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().also { + accessTokenToUserInfoMutex.withLock { accessTokenToUserInfoMap[it] = userInfo } + }, + tokenType = OpenIdConstants.TOKEN_TYPE_BEARER, + expires = 3600, + clientNonce = clientNonceService.provideNonce(), + authorizationDetails = params.authorizationDetails?.let { + // TODO supported credential identifiers! + setOf(it) + } + ) + return KmmResult.success(result) + .also { Napier.i("token returns $result") } + } + + override suspend fun providePreAuthorizedCode(): String { + return codeService.provideCode().also { + codeToUserInfoMutex.withLock { codeToUserInfoMap[it] = dataProvider.loadUserInfo() } + } + } + + override suspend fun verifyAndRemoveClientNonce(nonce: String): Boolean { + return clientNonceService.verifyAndRemoveNonce(nonce) + } + + override suspend fun getUserInfo(accessToken: String): KmmResult { + if (!tokenService.verifyToken(accessToken)) { + return KmmResult.failure(OAuth2Exception(Errors.INVALID_TOKEN)) + .also { Napier.w("getUserInfo: client did not provide correct token: $accessToken") } + } + 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/SupportedAlgorithmsContainer.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/SupportedAlgorithmsContainer.kt new file mode 100644 index 000000000..0b06573f7 --- /dev/null +++ b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/SupportedAlgorithmsContainer.kt @@ -0,0 +1,32 @@ +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`. + */ + @SerialName("alg_values_supported") + val supportedAlgorithms: Set, + + /** + * 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: Set? = 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..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 @@ -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: Set? = 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: Set? = 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: Set? = 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: Set? = 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/TokenRequestParameters.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/TokenRequestParameters.kt index b65425220..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 @@ -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,31 @@ 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. + * RFC7636: A cryptographically random string that is used to correlate the authorization request to the token + * request. */ - @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..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 @@ -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: Set? = 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..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 @@ -1,21 +1,38 @@ 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 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 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 import kotlinx.datetime.Clock +import kotlin.random.Random /** * Client service to retrieve credentials using @@ -23,18 +40,6 @@ import kotlinx.datetime.Clock * 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. @@ -53,28 +58,136 @@ 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() + 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: 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, + ) + /** * 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, + ) = AuthenticationRequestParameters( + responseType = GRANT_TYPE_CODE, + state = requestOptions.state, + clientId = clientId, + // TODO in authnrequest, and again in tokenrequest? + authorizationDetails = requestOptions.representation.toAuthorizationDetails( + requestOptions.credentialScheme, + requestOptions.requestedAttributes + ), + resource = credentialIssuer, + redirectUrl = redirectUrl, + 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]). + * + * @param scope Credential to request from the issuer, may be obtained from [IssuerMetadata.supportedCredentialConfigurations], or [SupportedCredentialFormat.scope]. */ - fun createAuthRequest() = AuthenticationRequestParameters( + suspend fun createAuthRequest( + scope: String, + credentialIssuer: String? = null, + state: String = uuid4().toString() + ) = AuthenticationRequestParameters( responseType = GRANT_TYPE_CODE, + state = state, clientId = clientId, - authorizationDetails = credentialRepresentation.toAuthorizationDetails(), + scope = scope, + resource = credentialIssuer, redirectUrl = redirectUrl, + 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 + * 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 */ - fun createTokenRequestParameters(code: String) = TokenRequestParameters( + suspend fun createTokenRequestParameters( + params: AuthenticationResponseParameters, + requestOptions: RequestOptions, + ) = TokenRequestParameters( grantType = GRANT_TYPE_CODE, - code = code, + code = params.code, redirectUrl = redirectUrl, clientId = clientId, + // 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, + requestOptions: RequestOptions, + ) = TokenRequestParameters( + grantType = GRANT_TYPE_PRE_AUTHORIZED_CODE, + // TODO Verify if `redirect_uri` and `client_id` are even needed + redirectUrl = redirectUrl, + clientId = clientId, + // 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) } ) /** @@ -82,12 +195,14 @@ 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 createCredentialRequest( + suspend fun createCredentialRequestJwt( tokenResponse: TokenResponseParameters, - issuerMetadata: IssuerMetadata + issuerMetadata: IssuerMetadata, + requestOptions: RequestOptions, ): 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(), @@ -96,65 +211,123 @@ class WalletService( payload = JsonWebToken( issuer = clientId, audience = issuerMetadata.credentialIssuer, - issuedAt = Clock.System.now(), + issuedAt = requestOptions.clock.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, jwt = proofPayload.serialize() ) - return KmmResult.success(credentialRepresentation.toCredentialRequestParameters(proof)) + val result = requestOptions.representation.toCredentialRequestParameters( + requestOptions.credentialScheme, + requestOptions.requestedAttributes, + proof + ) + Napier.i("createCredentialRequestJwt returns $result") + return KmmResult.success(result) } - private fun ConstantIndex.CredentialRepresentation.toAuthorizationDetails() = when (this) { + /** + * 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]). + * + * @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( + algorithm = cryptoService.algorithm.toCoseAlgorithm(), + contentType = OpenIdConstants.ProofTypes.CWT_HEADER_TYPE, + certificateChain = cryptoService.certificate?.encodeToDerOrNull() + ), + payload = CborWebToken( + issuer = clientId, + audience = issuerMetadata.credentialIssuer, + issuedAt = requestOptions.clock.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 = requestOptions.representation.toCredentialRequestParameters( + requestOptions.credentialScheme, + requestOptions.requestedAttributes, + proof + ) + Napier.i("createCredentialRequestCwt returns $result") + return KmmResult.success(result) + } + + private fun ConstantIndex.CredentialRepresentation.toAuthorizationDetails( + credentialScheme: ConstantIndex.CredentialScheme, + requestedAttributes: Set? + ) = when (this) { ConstantIndex.CredentialRepresentation.PLAIN_JWT, ConstantIndex.CredentialRepresentation.SD_JWT -> AuthorizationDetails( type = CREDENTIAL_TYPE_OPENID, format = toFormat(), - types = arrayOf(VERIFIABLE_CREDENTIAL) + credentialScheme.vcType, - claims = requestedAttributes?.toRequestedClaims(), + credentialDefinition = SupportedCredentialFormatDefinition( + types = listOf(VERIFIABLE_CREDENTIAL, credentialScheme.vcType), + ), + claims = requestedAttributes?.toRequestedClaims(credentialScheme), ) ConstantIndex.CredentialRepresentation.ISO_MDOC -> AuthorizationDetails( type = CREDENTIAL_TYPE_OPENID, format = toFormat(), docType = credentialScheme.isoDocType, - types = arrayOf(credentialScheme.vcType), - 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(), - types = arrayOf(VERIFIABLE_CREDENTIAL) + credentialScheme.vcType, - proof = proof - ) - - ConstantIndex.CredentialRepresentation.ISO_MDOC -> CredentialRequestParameters( - format = toFormat(), - docType = credentialScheme.isoDocType, - claims = requestedAttributes?.toRequestedClaims(), - types = arrayOf(credentialScheme.vcType), - proof = proof - ) - } + private fun ConstantIndex.CredentialRepresentation.toCredentialRequestParameters( + credentialScheme: ConstantIndex.CredentialScheme, + requestedAttributes: Set?, + 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(credentialScheme), + proof = proof + ) + } - private fun Collection.toRequestedClaims() = + private fun Collection.toRequestedClaims(credentialScheme: ConstantIndex.CredentialScheme) = mapOf(credentialScheme.isoNamespace to this.associateWith { RequestedCredentialClaimSpecification() }) } 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/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/DummyOAuth2IssuerCredentialDataProvider.kt b/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/DummyOAuth2IssuerCredentialDataProvider.kt new file mode 100644 index 000000000..b8717c134 --- /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( + 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) }, + ) + 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) && 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)) + 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( + 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, + 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 ?: "Unknown", + givenName = userInfo.givenName ?: "Unknown", + 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(subject = "subject", givenName = "Erika", familyName = "Musterfrau") + } +} \ No newline at end of file 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..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 @@ -3,14 +3,13 @@ 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.lib.LibraryInitializer +import at.asitplus.wallet.eupid.EuPidScheme 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 @@ -21,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., @@ -36,16 +32,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 +45,7 @@ class OidcSiopInteropTest : FreeSpec({ dataProvider = DummyCredentialDataProvider(), ).issueCredential( subjectPublicKey = holderCryptoService.publicKey, - attributeTypes = listOf(EudiwPidCredentialScheme.vcType), + attributeTypes = listOf(EuPidScheme.vcType), representation = ConstantIndex.CredentialRepresentation.ISO_MDOC, ).toStoreCredentialInput() ) @@ -182,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 0f5619246..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 @@ -1,41 +1,48 @@ 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 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.DummyCredentialDataProvider -import at.asitplus.wallet.lib.oidc.OpenIdConstants.GRANT_TYPE_CODE -import at.asitplus.wallet.lib.oidc.OpenIdConstants.TOKEN_PREFIX_BEARER +import at.asitplus.wallet.lib.oidc.AuthenticationResponseResult +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 import io.kotest.matchers.shouldBe -import io.ktor.http.* +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() - val issuer = IssuerService( - issuer = IssuerAgent.newDefaultInstance( - cryptoService = DefaultCryptoService(), - dataProvider = dataProvider - ), - credentialSchemes = listOf(ConstantIndex.AtomicAttribute2023, ConstantIndex.MobileDrivingLicence2023) + val authorizationService = SimpleAuthorizationService( + dataProvider = DummyOAuth2DataProvider, + credentialSchemes = setOf(ConstantIndex.AtomicAttribute2023, ConstantIndex.MobileDrivingLicence2023) + ) + val issuer = CredentialIssuer( + authorizationService = authorizationService, + issuer = IssuerAgent.newDefaultInstance(), + credentialSchemes = setOf(ConstantIndex.AtomicAttribute2023, ConstantIndex.MobileDrivingLicence2023), + buildIssuerCredentialDataProviderOverride = ::DummyOAuth2IssuerCredentialDataProvider ) test("process with W3C VC JWT") { - val client = WalletService( - credentialScheme = ConstantIndex.AtomicAttribute2023, - credentialRepresentation = ConstantIndex.CredentialRepresentation.PLAIN_JWT, + val client = WalletService() + val credential = runProcessWithJwtProof( + authorizationService, + issuer, + client, + WalletService.RequestOptions( + ConstantIndex.AtomicAttribute2023, + representation = ConstantIndex.CredentialRepresentation.PLAIN_JWT + ) ) - val credential = runProcess(issuer, client) credential.format shouldBe CredentialFormatEnum.JWT_VC val serializedCredential = credential.credential serializedCredential.shouldNotBeNull().also { println(it) } @@ -47,16 +54,21 @@ 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( + authorizationService, + issuer, + client, + WalletService.RequestOptions( + ConstantIndex.AtomicAttribute2023, + representation = 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) } - 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) } @@ -64,17 +76,22 @@ 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( + authorizationService, + issuer, + client, + WalletService.RequestOptions( + ConstantIndex.AtomicAttribute2023, + representation = ConstantIndex.CredentialRepresentation.SD_JWT, + requestedAttributes = setOf("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) } - 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) } @@ -82,11 +99,16 @@ 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( + authorizationService, + issuer, + client, + WalletService.RequestOptions( + ConstantIndex.MobileDrivingLicence2023, + representation = ConstantIndex.CredentialRepresentation.ISO_MDOC, + ) ) - val credential = runProcess(issuer, client) credential.format shouldBe CredentialFormatEnum.MSO_MDOC val serializedCredential = credential.credential serializedCredential.shouldNotBeNull().also { println(it) } @@ -99,12 +121,17 @@ 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( + authorizationService, + issuer, + client, + WalletService.RequestOptions( + ConstantIndex.MobileDrivingLicence2023, + representation = ConstantIndex.CredentialRepresentation.ISO_MDOC, + requestedAttributes = setOf(MobileDrivingLicenceDataElements.DOCUMENT_NUMBER) + ) ) - val credential = runProcess(issuer, client) credential.format shouldBe CredentialFormatEnum.MSO_MDOC val serializedCredential = credential.credential serializedCredential.shouldNotBeNull().also { println(it) } @@ -117,11 +144,16 @@ 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( + authorizationService, + issuer, + client, + WalletService.RequestOptions( + ConstantIndex.AtomicAttribute2023, + representation = ConstantIndex.CredentialRepresentation.ISO_MDOC + ) ) - val credential = runProcess(issuer, client) credential.format shouldBe CredentialFormatEnum.MSO_MDOC val serializedCredential = credential.credential serializedCredential.shouldNotBeNull().also { println(it) } @@ -132,18 +164,39 @@ class OidvciProcessTest : FunSpec({ }) -private suspend fun runProcess( - issuer: IssuerService, - client: WalletService +private suspend fun runProcessWithJwtProof( + authorizationService: SimpleAuthorizationService, + issuer: CredentialIssuer, + client: WalletService, + requestOptions: WalletService.RequestOptions, ): 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 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: SimpleAuthorizationService, + issuer: CredentialIssuer, + client: WalletService, + requestOptions: WalletService.RequestOptions, +): CredentialResponseParameters { + 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: SimpleAuthorizationService, + client: WalletService, + requestOptions: WalletService.RequestOptions, +): TokenResponseParameters { + val authnRequest = client.createAuthRequest(requestOptions) + val authnResponse = authorizationService.authorize(authnRequest).getOrThrow() + authnResponse.shouldBeInstanceOf() + val code = authnResponse.params.code code.shouldNotBeNull() - val tokenRequest = client.createTokenRequestParameters(code) - val token = issuer.token(tokenRequest) - val credentialRequest = client.createCredentialRequest(token, metadata).getOrThrow() - return issuer.credential(TOKEN_PREFIX_BEARER + token.accessToken, credentialRequest) + val tokenRequest = client.createTokenRequestParameters(authnResponse.params, requestOptions) + val token = authorizationService.token(tokenRequest).getOrThrow() + return token } 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..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,8 +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 kotlinx.serialization.decodeFromString +import io.ktor.http.* 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(), @@ -36,7 +37,6 @@ class SerializationTest : FunSpec({ clientId = randomString(), preAuthorizedCode = randomString(), codeVerifier = randomString(), - userPin = randomString(), ) fun createTokenResponse() = TokenResponseParameters( @@ -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 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 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..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 @@ -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 75c09dac3..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 @@ -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 @@ -58,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 { @@ -80,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, ) @@ -92,22 +93,28 @@ 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() 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 } - 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 -> 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()