diff --git a/src/main/kotlin/eu/europa/ec/eudi/openid4vci/AuthorizeIssuance.kt b/src/main/kotlin/eu/europa/ec/eudi/openid4vci/AuthorizeIssuance.kt index 861ce183..ed225ed2 100644 --- a/src/main/kotlin/eu/europa/ec/eudi/openid4vci/AuthorizeIssuance.kt +++ b/src/main/kotlin/eu/europa/ec/eudi/openid4vci/AuthorizeIssuance.kt @@ -45,125 +45,42 @@ enum class Grant : java.io.Serializable { /** * Sealed hierarchy of states describing an authorized issuance request. These states hold an access token issued by the * authorization server that protects the credential issuer. + * + * @param accessToken Access token authorizing the request(s) to issue credential(s) + * @param refreshToken Refresh token to refresh the access token, if needed + * @param credentialIdentifiers authorization details, if provided by the token endpoint + * @param authorizationServerDpopNonce Nonce value for DPoP provided by the Authorization Server + * @param timestamp the point in time of the authorization (when tokens were issued) + * @param resourceServerDpopNonce Nonce value for DPoP provided by the Resource Server + * @param grant The Grant through which the authorization was obtained */ -sealed interface AuthorizedRequest : java.io.Serializable { - - /** - * Access token authorizing the request(s) to issue credential(s) - */ - val accessToken: AccessToken - val refreshToken: RefreshToken? - val credentialIdentifiers: Map>? - val timestamp: Instant - - /** - * Authorization server-provided DPoP Nonce, if any - */ - val authorizationServerDpopNonce: Nonce? - - /** - * Protected resource-provided DPoP Nonce, if any - */ - val resourceServerDpopNonce: Nonce? - - /** - * The Grant through which the authorization was obtained - */ - val grant: Grant +data class AuthorizedRequest( + val accessToken: AccessToken, + val refreshToken: RefreshToken?, + val credentialIdentifiers: Map>?, + val timestamp: Instant, + val authorizationServerDpopNonce: Nonce?, + val resourceServerDpopNonce: Nonce?, + val grant: Grant, +) : java.io.Serializable { fun isAccessTokenExpired(at: Instant): Boolean = accessToken.isExpired(timestamp, at) - /** - * In case an 'invalid_proof' error response was received from issuer with - * fresh c_nonce - * - * @param cNonce The c_nonce provided from issuer along the 'invalid_proof' error code. - * @return The new state of the request. - */ - fun withCNonce(cNonce: CNonce): ProofRequired = - ProofRequired( - accessToken = accessToken, - refreshToken = refreshToken, - cNonce = cNonce, - credentialIdentifiers = credentialIdentifiers, - timestamp = timestamp, - authorizationServerDpopNonce = authorizationServerDpopNonce, - resourceServerDpopNonce = resourceServerDpopNonce, - grant = grant, - ) - fun withRefreshedAccessToken( refreshedAccessToken: AccessToken, newRefreshToken: RefreshToken?, at: Instant, newAuthorizationServerDpopNonce: Nonce?, ): AuthorizedRequest = - when (this) { - is NoProofRequired -> copy( - accessToken = refreshedAccessToken, - refreshToken = newRefreshToken ?: refreshToken, - timestamp = at, - authorizationServerDpopNonce = newAuthorizationServerDpopNonce, - ) - - is ProofRequired -> copy( - accessToken = refreshedAccessToken, - refreshToken = newRefreshToken ?: refreshToken, - timestamp = at, - authorizationServerDpopNonce = newAuthorizationServerDpopNonce, - ) - } + copy( + accessToken = refreshedAccessToken, + refreshToken = newRefreshToken ?: refreshToken, + timestamp = at, + authorizationServerDpopNonce = newAuthorizationServerDpopNonce, + ) fun withResourceServerDpopNonce(newResourceServerDpopNonce: Nonce?): AuthorizedRequest = - when (this) { - is NoProofRequired -> copy(resourceServerDpopNonce = newResourceServerDpopNonce) - is ProofRequired -> copy(resourceServerDpopNonce = newResourceServerDpopNonce) - } - - /** - * Issuer authorized issuance - * - * @param accessToken Access token authorizing credential issuance - * @param refreshToken Refresh token to refresh the access token, if needed - * @param credentialIdentifiers authorization details, if provided by the token endpoint - * @param timestamp the point in time of the authorization (when tokens were issued) - * @param authorizationServerDpopNonce Nonce value for DPoP provided by the Authorization Server - * @param resourceServerDpopNonce Nonce value for DPoP provided by the Resource Server - * @param grant the Grant through which the authorization was obtained - */ - data class NoProofRequired( - override val accessToken: AccessToken, - override val refreshToken: RefreshToken?, - override val credentialIdentifiers: Map>?, - override val timestamp: Instant, - override val authorizationServerDpopNonce: Nonce?, - override val resourceServerDpopNonce: Nonce?, - override val grant: Grant, - ) : AuthorizedRequest - - /** - * Issuer authorized issuance and required the provision of proof of holder's binding to be provided - * along with the request - * - * @param accessToken Access token authorizing certificate issuance - * @param refreshToken Refresh token to refresh the access token, if needed - * @param cNonce Nonce value provided by issuer to be included in proof of holder's binding - * @param credentialIdentifiers authorization details, if provided by the token endpoint - * @param timestamp the point in time of the authorization (when tokens were issued) - * @param authorizationServerDpopNonce Nonce value for DPoP provided by the Authorization Server - * @param resourceServerDpopNonce Nonce value for DPoP provided by the Resource Server - * @param grant the Grant through which the authorization was obtained - */ - data class ProofRequired( - override val accessToken: AccessToken, - override val refreshToken: RefreshToken?, - val cNonce: CNonce, - override val credentialIdentifiers: Map>?, - override val timestamp: Instant, - override val authorizationServerDpopNonce: Nonce?, - override val resourceServerDpopNonce: Nonce?, - override val grant: Grant, - ) : AuthorizedRequest + copy(resourceServerDpopNonce = newResourceServerDpopNonce) } sealed interface AccessTokenOption { diff --git a/src/main/kotlin/eu/europa/ec/eudi/openid4vci/DeferredIssuer.kt b/src/main/kotlin/eu/europa/ec/eudi/openid4vci/DeferredIssuer.kt index e11b1fa0..ee82de31 100644 --- a/src/main/kotlin/eu/europa/ec/eudi/openid4vci/DeferredIssuer.kt +++ b/src/main/kotlin/eu/europa/ec/eudi/openid4vci/DeferredIssuer.kt @@ -53,25 +53,9 @@ data class DeferredIssuerConfig( * @param transactionId the id returned by deferred endpoint */ data class AuthorizedTransaction( - val authorizedRequest: AuthorizedRequest.NoProofRequired, + val authorizedRequest: AuthorizedRequest, val transactionId: TransactionId, -) { - constructor(authorizedRequest: AuthorizedRequest, transactionId: TransactionId) : this( - authorizedRequest = when (authorizedRequest) { - is AuthorizedRequest.NoProofRequired -> authorizedRequest - is AuthorizedRequest.ProofRequired -> AuthorizedRequest.NoProofRequired( - accessToken = authorizedRequest.accessToken, - refreshToken = authorizedRequest.refreshToken, - credentialIdentifiers = authorizedRequest.credentialIdentifiers, - timestamp = authorizedRequest.timestamp, - authorizationServerDpopNonce = authorizedRequest.authorizationServerDpopNonce, - resourceServerDpopNonce = authorizedRequest.resourceServerDpopNonce, - grant = authorizedRequest.grant, - ) - }, - transactionId = transactionId, - ) -} +) /** * Represents what a wallet needs to keep to be @@ -128,7 +112,6 @@ interface DeferredIssuer : QueryForDeferredCredential { val newCtx = when (outcome) { is DeferredCredentialQueryOutcome.IssuancePending, is DeferredCredentialQueryOutcome.Errored -> { if (newAuthorized != ctx.authorizedTransaction.authorizedRequest) { - check(newAuthorized is AuthorizedRequest.NoProofRequired) val newAuthorizedTransaction = ctx.authorizedTransaction.copy(authorizedRequest = newAuthorized) ctx.copy(authorizedTransaction = newAuthorizedTransaction) } else { diff --git a/src/main/kotlin/eu/europa/ec/eudi/openid4vci/Issuance.kt b/src/main/kotlin/eu/europa/ec/eudi/openid4vci/Issuance.kt index 49ccb538..3d9ea7e4 100644 --- a/src/main/kotlin/eu/europa/ec/eudi/openid4vci/Issuance.kt +++ b/src/main/kotlin/eu/europa/ec/eudi/openid4vci/Issuance.kt @@ -135,19 +135,11 @@ interface RequestIssuance { /** * Places a request to the credential issuance endpoint. - * Method will attempt to automatically retry submission in case - * - Initial authorization state is [AuthorizedRequest.NoProofRequired] and - * - one or more [popSigners] haven been provided * - * @receiver the current authorization state * @param requestPayload the payload of the request - * @param popSigners one or more signers for the proofs to be sent. - * Although this is an optional parameter, only required in case the present authorization state is [AuthorizedRequest.ProofRequired], - * caller is advised to provide it, to allow the method to automatically retry - * in case of [CredentialIssuanceError.InvalidProof] - * - * @return the possibly updated [AuthorizedRequest] (if updated it will contain a fresh c_nonce and/or - * updated Resource-Server DPoP Nonce) and the [SubmissionOutcome] + * @param popSigners one or more signers for the proofs to be sent. Required when requested credential's configuration requires proof. + * @return the possibly updated [AuthorizedRequest] (if updated it will contain a fresh updated Resource-Server DPoP Nonce) + * and the [SubmissionOutcome] */ suspend fun AuthorizedRequest.request( requestPayload: IssuanceRequestPayload, @@ -245,12 +237,10 @@ sealed class CredentialIssuanceError(message: String) : Throwable(message) { ) : CredentialIssuanceError(message) /** - * Issuer rejected the issuance request because no c_nonce was provided along with the proof. - * A fresh c_nonce is provided by the issuer. + * Issuer rejected the issuance request because no or invalid proof(s) were provided or at least one of the key proofs does + * not contain a c_nonce value. */ data class InvalidProof( - val cNonce: String, - val cNonceExpiresIn: Long? = 5, val errorDescription: String? = null, ) : CredentialIssuanceError("Invalid Proof") @@ -331,6 +321,11 @@ sealed class CredentialIssuanceError(message: String) : Throwable(message) { */ data class ResponseUnparsable(val error: String) : CredentialIssuanceError("ResponseUnparsable") + /** + * Request to nonce endpoint of issuer failed + */ + data class CNonceRequestFailed(val error: String) : CredentialIssuanceError("CNonceRequestFailed") + /** * Sealed hierarchy of errors related to proof generation */ diff --git a/src/main/kotlin/eu/europa/ec/eudi/openid4vci/Issuer.kt b/src/main/kotlin/eu/europa/ec/eudi/openid4vci/Issuer.kt index 74081684..9d57bb7c 100644 --- a/src/main/kotlin/eu/europa/ec/eudi/openid4vci/Issuer.kt +++ b/src/main/kotlin/eu/europa/ec/eudi/openid4vci/Issuer.kt @@ -159,10 +159,17 @@ interface Issuer : dPoPJwtFactory, ktorHttpClientFactory, ) + val nonceEndpointClient = credentialOffer.credentialIssuerMetadata.nonceEndpoint?.let { + NonceEndpointClient( + credentialOffer.credentialIssuerMetadata.nonceEndpoint, + ktorHttpClientFactory, + ) + } RequestIssuanceImpl( credentialOffer, config, credentialEndpointClient, + nonceEndpointClient, credentialOffer.credentialIssuerMetadata.batchCredentialIssuance, responseEncryptionSpec, ) diff --git a/src/main/kotlin/eu/europa/ec/eudi/openid4vci/Types.kt b/src/main/kotlin/eu/europa/ec/eudi/openid4vci/Types.kt index 51f7b242..6f5b111f 100644 --- a/src/main/kotlin/eu/europa/ec/eudi/openid4vci/Types.kt +++ b/src/main/kotlin/eu/europa/ec/eudi/openid4vci/Types.kt @@ -159,15 +159,12 @@ value class AuthorizationCode(val code: String) { } /** - * A c_nonce related information as provided from issuance server. + * A c_nonce as provided from issuance server's nonce endpoint. * * @param value The c_nonce value - * @param expiresInSeconds Nonce time to live in seconds. */ -data class CNonce( - val value: String, - val expiresInSeconds: Long? = 5, -) : java.io.Serializable { +@JvmInline +value class CNonce(val value: String) : java.io.Serializable { init { require(value.isNotEmpty()) { "Value cannot be empty" } } diff --git a/src/main/kotlin/eu/europa/ec/eudi/openid4vci/internal/AuthorizeIssuanceImpl.kt b/src/main/kotlin/eu/europa/ec/eudi/openid4vci/internal/AuthorizeIssuanceImpl.kt index 1950c083..d43571f2 100644 --- a/src/main/kotlin/eu/europa/ec/eudi/openid4vci/internal/AuthorizeIssuanceImpl.kt +++ b/src/main/kotlin/eu/europa/ec/eudi/openid4vci/internal/AuthorizeIssuanceImpl.kt @@ -18,8 +18,6 @@ package eu.europa.ec.eudi.openid4vci.internal import eu.europa.ec.eudi.openid4vci.* import eu.europa.ec.eudi.openid4vci.AccessTokenOption.AsRequested import eu.europa.ec.eudi.openid4vci.AccessTokenOption.Limited -import eu.europa.ec.eudi.openid4vci.AuthorizedRequest.NoProofRequired -import eu.europa.ec.eudi.openid4vci.AuthorizedRequest.ProofRequired import eu.europa.ec.eudi.openid4vci.CredentialIssuanceError.InvalidAuthorizationState import eu.europa.ec.eudi.openid4vci.internal.http.AuthorizationEndpointClient import eu.europa.ec.eudi.openid4vci.internal.http.TokenEndpointClient @@ -29,7 +27,6 @@ import com.nimbusds.oauth2.sdk.id.State as NimbusState internal data class TokenResponse( val accessToken: AccessToken, val refreshToken: RefreshToken?, - val cNonce: CNonce?, val authorizationDetails: Map> = emptyMap(), val timestamp: Instant, ) @@ -87,19 +84,27 @@ internal class AuthorizeIssuanceImpl( authorizationCode: AuthorizationCode, serverState: String, authDetailsOption: AccessTokenOption, - ): Result = - runCatching { - ensure(serverState == state) { InvalidAuthorizationState() } - val credConfigIdsAsAuthDetails = identifiersSentAsAuthDetails.filter(authDetailsOption) - val (tokenResponse, newDpopNonce) = - tokenEndpointClient.requestAccessTokenAuthFlow( - authorizationCode, - pkceVerifier, - credConfigIdsAsAuthDetails, - dpopNonce, - ).getOrThrow() - authorizedRequest(credentialOffer, tokenResponse, newDpopNonce, Grant.AuthorizationCode) - } + ): Result = runCatching { + ensure(serverState == state) { InvalidAuthorizationState() } + val credConfigIdsAsAuthDetails = identifiersSentAsAuthDetails.filter(authDetailsOption) + val (tokenResponse, newDpopNonce) = + tokenEndpointClient.requestAccessTokenAuthFlow( + authorizationCode, + pkceVerifier, + credConfigIdsAsAuthDetails, + dpopNonce, + ).getOrThrow() + + AuthorizedRequest( + accessToken = tokenResponse.accessToken, + refreshToken = tokenResponse.refreshToken, + credentialIdentifiers = tokenResponse.authorizationDetails, + timestamp = tokenResponse.timestamp, + authorizationServerDpopNonce = newDpopNonce, + resourceServerDpopNonce = null, + grant = Grant.AuthorizationCode, + ) + } override suspend fun authorizeWithPreAuthorizationCode( txCode: String?, @@ -122,7 +127,16 @@ internal class AuthorizeIssuanceImpl( credConfigIdsAsAuthDetails, dpopNonce = null, ).getOrThrow() - authorizedRequest(credentialOffer, tokenResponse, newDpopNonce, Grant.PreAuthorizedCodeGrant) + + AuthorizedRequest( + accessToken = tokenResponse.accessToken, + refreshToken = tokenResponse.refreshToken, + credentialIdentifiers = tokenResponse.authorizationDetails, + timestamp = tokenResponse.timestamp, + authorizationServerDpopNonce = newDpopNonce, + resourceServerDpopNonce = null, + grant = Grant.PreAuthorizedCodeGrant, + ) } } @@ -149,43 +163,6 @@ private fun TxCode.validate(txCode: String?) { } } -private fun authorizedRequest( - offer: CredentialOffer, - tokenResponse: TokenResponse, - newDpopNonce: Nonce?, - grant: Grant, -): AuthorizedRequest { - val offerRequiresProofs = offer.credentialConfigurationIdentifiers.any { - val credentialConfiguration = offer.credentialIssuerMetadata.credentialConfigurationsSupported[it] - credentialConfiguration != null && credentialConfiguration.proofTypesSupported.values.isNotEmpty() - } - val (accessToken, refreshToken, cNonce, authorizationDetails, timestamp) = tokenResponse - return when { - cNonce != null && offerRequiresProofs -> - ProofRequired( - accessToken, - refreshToken, - cNonce = cNonce, - authorizationDetails, - timestamp, - authorizationServerDpopNonce = newDpopNonce, - resourceServerDpopNonce = null, - grant = grant, - ) - - else -> - NoProofRequired( - accessToken, - refreshToken, - authorizationDetails, - timestamp, - authorizationServerDpopNonce = newDpopNonce, - resourceServerDpopNonce = null, - grant = grant, - ) - } -} - private fun Iterable.filter( accessTokenOption: AccessTokenOption, ): List = diff --git a/src/main/kotlin/eu/europa/ec/eudi/openid4vci/internal/ProofBuilder.kt b/src/main/kotlin/eu/europa/ec/eudi/openid4vci/internal/ProofBuilder.kt index 2d1130c8..c1588ab0 100644 --- a/src/main/kotlin/eu/europa/ec/eudi/openid4vci/internal/ProofBuilder.kt +++ b/src/main/kotlin/eu/europa/ec/eudi/openid4vci/internal/ProofBuilder.kt @@ -32,7 +32,7 @@ internal abstract class ProofBuilder( val clock: Clock, val iss: ClientId?, val aud: CredentialIssuerId, - val nonce: CNonce, + val nonce: CNonce?, val popSigner: POP_SIGNER, ) { @@ -44,7 +44,7 @@ internal abstract class ProofBuilder( clock: Clock, iss: ClientId?, aud: CredentialIssuerId, - nonce: CNonce, + nonce: CNonce?, popSigner: PopSigner, ): ProofBuilder<*, *> { return when (popSigner) { @@ -60,7 +60,7 @@ internal abstract class ProofBuilder( client: Client, grant: Grant, aud: CredentialIssuerId, - nonce: CNonce, + nonce: CNonce?, popSigner: PopSigner, ): ProofBuilder<*, *> = invoke(proofTypesSupported, clock, iss(client, grant), aud, nonce, popSigner) @@ -82,7 +82,7 @@ internal class JwtProofBuilder( clock: Clock, iss: ClientId?, aud: CredentialIssuerId, - nonce: CNonce, + nonce: CNonce?, popSigner: PopSigner.Jwt, ) : ProofBuilder(clock, iss, aud, nonce, popSigner) { @@ -109,7 +109,7 @@ internal class JwtProofBuilder( JWTClaimsSet.Builder().apply { iss?.let { issuer(it) } audience(aud.toString()) - claim("nonce", nonce.value) + nonce?.let { claim("nonce", nonce.value) } issueTime(Date.from(clock.instant())) }.build() diff --git a/src/main/kotlin/eu/europa/ec/eudi/openid4vci/internal/RequestIssuanceImpl.kt b/src/main/kotlin/eu/europa/ec/eudi/openid4vci/internal/RequestIssuanceImpl.kt index 4ab427fe..996faebd 100644 --- a/src/main/kotlin/eu/europa/ec/eudi/openid4vci/internal/RequestIssuanceImpl.kt +++ b/src/main/kotlin/eu/europa/ec/eudi/openid4vci/internal/RequestIssuanceImpl.kt @@ -17,65 +17,67 @@ package eu.europa.ec.eudi.openid4vci.internal import eu.europa.ec.eudi.openid4vci.* import eu.europa.ec.eudi.openid4vci.internal.http.CredentialEndpointClient +import eu.europa.ec.eudi.openid4vci.internal.http.NonceEndpointClient + +internal sealed interface CredentialProofsRequirement { + + data object ProofNotRequired : CredentialProofsRequirement + + sealed interface ProofRequired : CredentialProofsRequirement { + + data object WithoutCNonce : ProofRequired + + data object WithCNonce : ProofRequired + } +} internal class RequestIssuanceImpl( private val credentialOffer: CredentialOffer, private val config: OpenId4VCIConfig, private val credentialEndpointClient: CredentialEndpointClient, + private val nonceEndpointClient: NonceEndpointClient?, private val batchCredentialIssuance: BatchCredentialIssuance, private val responseEncryptionSpec: IssuanceResponseEncryptionSpec?, ) : RequestIssuance { + init { + val nonceEndpoint = credentialOffer.credentialIssuerMetadata.nonceEndpoint + if (nonceEndpoint != null && nonceEndpointClient == null) { + throw IllegalStateException("A nonce endpoint client needs to be configured if issuer advertises a nonce endpoint") + } + if (nonceEndpoint == null && nonceEndpointClient != null) { + throw IllegalStateException("A nonce endpoint client is configured although issuer does not advertises a nonce endpoint") + } + } + override suspend fun AuthorizedRequest.request( requestPayload: IssuanceRequestPayload, popSigners: List, ): Result> = runCatching { - // + val credentialConfigId = requestPayload.credentialConfigurationIdentifier + + // Deduct from credential configuration and issuer metadata if issuer requires proofs to be sent for the specific credential + val proofsRequirement = credentialConfigId.proofsRequirement() + // Place the request - // val (outcome, newResourceServerDpopNonce) = placeIssuanceRequest(accessToken, resourceServerDpopNonce) { - val proofFactories = proofFactoriesForm(popSigners) + val proofFactories = proofFactoriesFrom(popSigners, proofsRequirement) buildRequest(requestPayload, proofFactories, credentialIdentifiers.orEmpty()) } - // - // Update state - // - val updatedAuthorizedRequest = - this.withCNonceFrom(outcome).withResourceServerDpopNonce(newResourceServerDpopNonce) - - // - // Retry on invalid proof if we begin from NoProofRequired and issuer - // replied with InvalidProof - // - val retryOnInvalidProof = - this is AuthorizedRequest.NoProofRequired && - popSigners.isNotEmpty() && - updatedAuthorizedRequest is AuthorizedRequest.ProofRequired && - outcome.isInvalidProof() - - suspend fun retry() = - updatedAuthorizedRequest.request(requestPayload, popSigners) - .getOrThrow() - .markInvalidProofIrrecoverable() - - if (retryOnInvalidProof) retry() - else updatedAuthorizedRequest to outcome.toPub() + // Update state (maybe) with new Dpop Nonce from resource server + val updatedAuthorizedRequest = this.withResourceServerDpopNonce(newResourceServerDpopNonce) + updatedAuthorizedRequest to outcome.toPub() } - private fun AuthorizedRequest.withCNonceFrom(outcome: SubmissionOutcomeInternal): AuthorizedRequest { - val updated = - when (outcome) { - is SubmissionOutcomeInternal.Failed -> - outcome.cNonceFromInvalidProof()?.let { newCNonce -> withCNonce(newCNonce) } - - is SubmissionOutcomeInternal.Deferred -> - outcome.cNonce?.let { withCNonce(it) } - - is SubmissionOutcomeInternal.Success -> - outcome.cNonce?.let { withCNonce(it) } - } - return updated ?: this + private fun CredentialConfigurationIdentifier.proofsRequirement(): CredentialProofsRequirement { + val credentialIssuerMetadata = credentialOffer.credentialIssuerMetadata + val credentialConfiguration = credentialSupportedById(this) + return when { + credentialConfiguration.proofTypesSupported.values.isEmpty() -> CredentialProofsRequirement.ProofNotRequired + credentialIssuerMetadata.nonceEndpoint == null -> CredentialProofsRequirement.ProofRequired.WithoutCNonce + else -> CredentialProofsRequirement.ProofRequired.WithCNonce + } } private fun credentialSupportedById(credentialId: CredentialConfigurationIdentifier): CredentialConfiguration { @@ -86,12 +88,15 @@ internal class RequestIssuanceImpl( } } - private fun AuthorizedRequest.proofFactoriesForm(popSigners: List): List = - when (this) { - is AuthorizedRequest.NoProofRequired -> emptyList() - is AuthorizedRequest.ProofRequired -> { + private fun AuthorizedRequest.proofFactoriesFrom( + popSigners: List, + proofsRequirement: CredentialProofsRequirement, + ): List = + when (proofsRequirement) { + is CredentialProofsRequirement.ProofNotRequired -> emptyList() + is CredentialProofsRequirement.ProofRequired -> { when (val popSignersNo = popSigners.size) { - 0 -> error("At least a PopSigner is required in Authorized.ProofRequired") + 0 -> error("At least one PopSigner is required in Authorized.ProofRequired") 1 -> Unit else -> { when (batchCredentialIssuance) { @@ -105,17 +110,24 @@ internal class RequestIssuanceImpl( } } } - popSigners.map { proofFactory(it, cNonce, grant) } + popSigners.map { proofFactory(it, proofsRequirement, grant) } } } private fun proofFactory( proofSigner: PopSigner, - cNonce: CNonce, + proofsRequirement: CredentialProofsRequirement.ProofRequired, grant: Grant, ): ProofFactory = { credentialSupported -> val aud = credentialOffer.credentialIssuerMetadata.credentialIssuerIdentifier val proofTypesSupported = credentialSupported.proofTypesSupported + val cNonce = when (proofsRequirement) { + CredentialProofsRequirement.ProofRequired.WithCNonce -> { + checkNotNull(nonceEndpointClient) { "Issuer does not provide nonce endpoint." } + nonceEndpointClient.getNonce().getOrThrow() + } + CredentialProofsRequirement.ProofRequired.WithoutCNonce -> null + } ProofBuilder(proofTypesSupported, config.clock, config.client, grant, aud, cNonce, proofSigner).build() } @@ -193,13 +205,11 @@ internal sealed interface SubmissionOutcomeInternal { data class Success( val credentials: List, - val cNonce: CNonce?, val notificationId: NotificationId?, ) : SubmissionOutcomeInternal data class Deferred( val transactionId: TransactionId, - val cNonce: CNonce?, ) : SubmissionOutcomeInternal data class Failed( @@ -212,26 +222,4 @@ internal sealed interface SubmissionOutcomeInternal { is Deferred -> SubmissionOutcome.Deferred(transactionId) is Failed -> SubmissionOutcome.Failed(error) } - - fun isInvalidProof(): Boolean = - null != cNonceFromInvalidProof() - - fun cNonceFromInvalidProof(): CNonce? = - if (this is Failed && error is CredentialIssuanceError.InvalidProof) { - CNonce(error.cNonce, error.cNonceExpiresIn) - } else null } - -private fun AuthorizedRequestAnd.markInvalidProofIrrecoverable() = - first to when (val outcome = second) { - is SubmissionOutcome.Failed -> - if (outcome.error is CredentialIssuanceError.InvalidProof) { - SubmissionOutcome.Failed(outcome.error.irrecoverable()) - } else outcome - - is SubmissionOutcome.Success -> outcome - is SubmissionOutcome.Deferred -> outcome - } - -private fun CredentialIssuanceError.InvalidProof.irrecoverable() = - CredentialIssuanceError.IrrecoverableInvalidProof(errorDescription) diff --git a/src/main/kotlin/eu/europa/ec/eudi/openid4vci/internal/http/IssuanceRequestJsonMapper.kt b/src/main/kotlin/eu/europa/ec/eudi/openid4vci/internal/http/IssuanceRequestJsonMapper.kt index b8eb0152..6d19f265 100644 --- a/src/main/kotlin/eu/europa/ec/eudi/openid4vci/internal/http/IssuanceRequestJsonMapper.kt +++ b/src/main/kotlin/eu/europa/ec/eudi/openid4vci/internal/http/IssuanceRequestJsonMapper.kt @@ -129,8 +129,6 @@ internal data class CredentialResponseSuccessTO( @SerialName("credentials") val credentials: List? = null, @SerialName("transaction_id") val transactionId: String? = null, @SerialName("notification_id") val notificationId: String? = null, - @SerialName("c_nonce") val cNonce: String? = null, - @SerialName("c_nonce_expires_in") val cNonceExpiresInSeconds: Long? = null, ) { init { if (!credentials.isNullOrEmpty()) { @@ -153,7 +151,6 @@ internal data class CredentialResponseSuccessTO( } fun toDomain(): SubmissionOutcomeInternal { - val cNonce = cNonce?.let { CNonce(cNonce, cNonceExpiresInSeconds) } val transactionId = transactionId?.let { TransactionId(it) } val notificationId = notificationId?.let(::NotificationId) @@ -169,11 +166,10 @@ internal data class CredentialResponseSuccessTO( return when { issuedCredentials.isNotEmpty() -> SubmissionOutcomeInternal.Success( issuedCredentials, - cNonce, notificationId, ) - transactionId != null -> SubmissionOutcomeInternal.Deferred(transactionId, cNonce) + transactionId != null -> SubmissionOutcomeInternal.Deferred(transactionId) else -> error("Cannot happen") } } @@ -186,8 +182,6 @@ internal data class CredentialResponseSuccessTO( credentials = claims["credentials"]?.let { Json.decodeFromJsonElement>(it) }, transactionId = jwtClaimsSet.getStringClaim("transaction_id"), notificationId = jwtClaimsSet.getStringClaim("notification_id"), - cNonce = jwtClaimsSet.getStringClaim("c_nonce"), - cNonceExpiresInSeconds = jwtClaimsSet.getLongClaim("c_nonce_expires_in"), ) } } @@ -303,17 +297,11 @@ internal enum class NotificationEventTO { internal data class GenericErrorResponseTO( @SerialName("error") val error: String, @SerialName("error_description") val errorDescription: String? = null, - @SerialName("c_nonce") val cNonce: String? = null, - @SerialName("c_nonce_expires_in") val cNonceExpiresInSeconds: Long? = null, @SerialName("interval") val interval: Long? = null, ) { fun toIssuanceError(): CredentialIssuanceError = when (error) { - "invalid_proof" -> - cNonce - ?.let { InvalidProof(cNonce, cNonceExpiresInSeconds, errorDescription) } - ?: ResponseUnparsable("Issuer responded with invalid_proof error but no c_nonce was provided") - + "invalid_proof" -> InvalidProof(errorDescription) "issuance_pending" -> interval ?.let { DeferredCredentialIssuancePending(interval) } diff --git a/src/main/kotlin/eu/europa/ec/eudi/openid4vci/internal/http/NonceEndpointClient.kt b/src/main/kotlin/eu/europa/ec/eudi/openid4vci/internal/http/NonceEndpointClient.kt new file mode 100644 index 00000000..3db929f0 --- /dev/null +++ b/src/main/kotlin/eu/europa/ec/eudi/openid4vci/internal/http/NonceEndpointClient.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2023 European Commission + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package eu.europa.ec.eudi.openid4vci.internal.http + +import eu.europa.ec.eudi.openid4vci.CNonce +import eu.europa.ec.eudi.openid4vci.CredentialIssuanceError +import eu.europa.ec.eudi.openid4vci.CredentialIssuerEndpoint +import eu.europa.ec.eudi.openid4vci.KtorHttpClientFactory +import io.ktor.client.call.* +import io.ktor.client.request.* +import io.ktor.http.* +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class CNonceResponse( + @SerialName("c_nonce") val cNonce: String, +) + +internal class NonceEndpointClient( + private val nonceEndpoint: CredentialIssuerEndpoint, + private val ktorHttpClientFactory: KtorHttpClientFactory, +) { + + suspend fun getNonce(): Result = + runCatching { + requestNonce() + } + + private suspend fun requestNonce(): CNonce = + ktorHttpClientFactory().use { client -> + val url = nonceEndpoint.value + val response = client.post(url) + + if (response.status.isSuccess()) { + val cNonceResponse = response.body() + CNonce(cNonceResponse.cNonce) + } else { + throw CredentialIssuanceError.CNonceRequestFailed("Nonce request failed") + } + } +} diff --git a/src/main/kotlin/eu/europa/ec/eudi/openid4vci/internal/http/TokenEndpointClient.kt b/src/main/kotlin/eu/europa/ec/eudi/openid4vci/internal/http/TokenEndpointClient.kt index d7ddca24..04115c4f 100644 --- a/src/main/kotlin/eu/europa/ec/eudi/openid4vci/internal/http/TokenEndpointClient.kt +++ b/src/main/kotlin/eu/europa/ec/eudi/openid4vci/internal/http/TokenEndpointClient.kt @@ -40,8 +40,6 @@ internal sealed interface TokenResponseTO { * * @param accessToken The access token. * @param expiresIn Token time to live. - * @param cNonce c_nonce returned from token endpoint. - * @param cNonceExpiresIn c_nonce time to live. */ @Serializable data class Success( @@ -49,8 +47,6 @@ internal sealed interface TokenResponseTO { @SerialName("access_token") val accessToken: String, @SerialName("refresh_token") val refreshToken: String? = null, @SerialName("expires_in") val expiresIn: Long? = null, - @SerialName("c_nonce") val cNonce: String? = null, - @SerialName("c_nonce_expires_in") val cNonceExpiresIn: Long? = null, @Serializable(with = GrantedAuthorizationDetailsSerializer::class) @SerialName( "authorization_details", @@ -79,7 +75,6 @@ internal sealed interface TokenResponseTO { useDPoP = DPoP.equals(other = tokenType, ignoreCase = true), ), refreshToken = refreshToken?.let { RefreshToken(it) }, - cNonce = cNonce?.let { CNonce(it, cNonceExpiresIn) }, authorizationDetails = authorizationDetails ?: emptyMap(), timestamp = clock.instant(), ) @@ -130,8 +125,7 @@ internal class TokenEndpointClient( * @param pkceVerifier The code verifier that was used when submitting the Pushed Authorization Request. * @param credConfigIdsAsAuthDetails The list of [CredentialConfigurationIdentifier]s that have been passed to authorization server * as authorization details, part of a Rich Authorization Request. - * @return The result of the request as a pair of the access token and the optional c_nonce information returned - * from token endpoint. + * @return The result of the request as a pair of the access token and the optional DPoP nonce returned by the endpoint. */ suspend fun requestAccessTokenAuthFlow( authorizationCode: AuthorizationCode, @@ -160,8 +154,9 @@ internal class TokenEndpointClient( * * @param preAuthorizedCode The pre-authorization code. * @param txCode Extra transaction code to be passed if specified as required in the credential offer. - * @return The result of the request as a pair of the access token and the optional c_nonce information returned - * from token endpoint. + * @param credConfigIdsAsAuthDetails A list of credential configuration ids to be sent as 'authorization_details'. + * @param dpopNonce A DPoP nonce. + * @return The result of the request as a pair of the access token and the optional DPoP nonce returned by the endpoint. */ suspend fun requestAccessTokenPreAuthFlow( preAuthorizedCode: PreAuthorizedCode, diff --git a/src/test/kotlin/eu/europa/ec/eudi/openid4vci/IssuanceAuthorizationTest.kt b/src/test/kotlin/eu/europa/ec/eudi/openid4vci/IssuanceAuthorizationTest.kt index 7023b200..2a35bfad 100644 --- a/src/test/kotlin/eu/europa/ec/eudi/openid4vci/IssuanceAuthorizationTest.kt +++ b/src/test/kotlin/eu/europa/ec/eudi/openid4vci/IssuanceAuthorizationTest.kt @@ -35,7 +35,7 @@ class IssuanceAuthorizationTest { @Test fun `successful authorization with authorization code flow (wallet initiated)`() = runTest { val mockedKtorHttpClientFactory = mockedKtorHttpClientFactory( - oidcWellKnownMocker(), + oiciWellKnownMocker(), authServerWellKnownMocker(), parPostMocker { request -> val form = with(request) { parPostApplyAssertionsAndGetFormData(false) } @@ -72,7 +72,7 @@ class IssuanceAuthorizationTest { fun `successful authorization with authorization code flow`() = runTest { val mockedKtorHttpClientFactory = mockedKtorHttpClientFactory( - oidcWellKnownMocker(), + oiciWellKnownMocker(), authServerWellKnownMocker(), parPostMocker { request -> val form = with(request) { parPostApplyAssertionsAndGetFormData(false) } @@ -109,7 +109,7 @@ class IssuanceAuthorizationTest { runTest { val mockedKtorHttpClientFactory = mockedKtorHttpClientFactory( authServerWellKnownMocker(), - oidcWellKnownMocker(), + oiciWellKnownMocker(), parPostMocker { fail("No pushed authorization request should have been sent in case of pre-authorized code flow") }, @@ -133,7 +133,7 @@ class IssuanceAuthorizationTest { runTest { val mockedKtorHttpClientFactory = mockedKtorHttpClientFactory( authServerWellKnownMocker(), - oidcWellKnownMocker(), + oiciWellKnownMocker(), ) val offer = credentialOffer(mockedKtorHttpClientFactory, CredentialOfferMixedDocTypes_PRE_AUTH_GRANT) @@ -163,7 +163,7 @@ class IssuanceAuthorizationTest { runTest { val mockedKtorHttpClientFactory = mockedKtorHttpClientFactory( authServerWellKnownMocker(), - oidcWellKnownMocker(), + oiciWellKnownMocker(), ) val offer = credentialOffer(mockedKtorHttpClientFactory, CredentialOfferMixedDocTypes_PRE_AUTH_GRANT) @@ -188,220 +188,12 @@ class IssuanceAuthorizationTest { } } - @Test - fun `(pre-auth flow) when token endpoint returns nonce and offer requires proofs then authorized request must be ProofRequired`() = - runTest { - val mockedKtorHttpClientFactory = mockedKtorHttpClientFactory( - authServerWellKnownMocker(), - oidcWellKnownMocker(), - parPostMocker(), - RequestMocker( - requestMatcher = endsWith("/token", HttpMethod.Post), - responseBuilder = { - respond( - content = Json.encodeToString( - TokenResponseTO.Success( - accessToken = UUID.randomUUID().toString(), - expiresIn = 3600, - cNonce = "dfghhj34wpCJp", - cNonceExpiresIn = 86400, - ), - ), - status = HttpStatusCode.OK, - headers = headersOf( - HttpHeaders.ContentType to listOf("application/json"), - ), - ) - }, - ), - ) - val offer = credentialOffer(mockedKtorHttpClientFactory, CredentialOfferMixedDocTypes_PRE_AUTH_GRANT) - val issuer = Issuer.make( - config = OpenId4VCIConfiguration, - credentialOffer = offer, - ktorHttpClientFactory = mockedKtorHttpClientFactory, - ).getOrThrow() - - with(issuer) { - // Validate error is thrown when pin not provided although required - authorizeWithPreAuthorizationCode(null) - .fold( - onSuccess = { - fail("Exception expected to be thrown") - }, - onFailure = { - assertTrue("Expected IllegalStateException to be thrown but was not") { - it is IllegalArgumentException - } - }, - ) - - val authorizedRequest = authorizeWithPreAuthorizationCode("1234").getOrThrow() - assertTrue("Token endpoint provides c_nonce but authorized request is not ProofRequired") { - authorizedRequest is AuthorizedRequest.ProofRequired - } - } - } - - @Test - fun `(auth code flow) when token endpoint returns nonce and offer requires proofs then authorized request must be ProofRequired`() = - runTest { - val mockedKtorHttpClientFactory = mockedKtorHttpClientFactory( - authServerWellKnownMocker(), - oidcWellKnownMocker(), - parPostMocker(), - RequestMocker( - requestMatcher = endsWith("/token", HttpMethod.Post), - responseBuilder = { - respond( - content = Json.encodeToString( - TokenResponseTO.Success( - accessToken = UUID.randomUUID().toString(), - expiresIn = 3600, - cNonce = "dfghhj34wpCJp", - cNonceExpiresIn = 86400, - ), - ), - status = HttpStatusCode.OK, - headers = headersOf( - HttpHeaders.ContentType to listOf("application/json"), - ), - ) - }, - ), - ) - val offer = credentialOffer(mockedKtorHttpClientFactory, CredentialOfferMixedDocTypes_AUTH_GRANT) - val issuer = Issuer.make( - config = OpenId4VCIConfiguration, - credentialOffer = offer, - ktorHttpClientFactory = mockedKtorHttpClientFactory, - ).getOrThrow() - - with(issuer) { - val prepareAuthorizationRequest = prepareAuthorizationRequest().getOrThrow() - val serverState = prepareAuthorizationRequest.state - val authorizedRequest = prepareAuthorizationRequest - .authorizeWithAuthorizationCode(AuthorizationCode("auth-code"), serverState) - .getOrThrow() - - assertTrue("Token endpoint provides c_nonce but authorized request is not ProofRequired") { - authorizedRequest is AuthorizedRequest.ProofRequired - } - } - } - - @Test - fun `when token endpoint returns nonce and offer does not require proofs then authorized request must be NoProofRequired`() = - runTest { - val mockedKtorHttpClientFactory = mockedKtorHttpClientFactory( - authServerWellKnownMocker(), - oidcWellKnownMocker(), - parPostMocker(), - RequestMocker( - requestMatcher = endsWith("/token", HttpMethod.Post), - responseBuilder = { - respond( - content = Json.encodeToString( - TokenResponseTO.Success( - accessToken = UUID.randomUUID().toString(), - expiresIn = 3600, - cNonce = "dfghhj34wpCJp", - cNonceExpiresIn = 86400, - ), - ), - status = HttpStatusCode.OK, - headers = headersOf( - HttpHeaders.ContentType to listOf("application/json"), - ), - ) - }, - ), - ) - - val noProofRequiredOffer = """ - { - "credential_issuer": "$CREDENTIAL_ISSUER_PUBLIC_URL", - "credential_configuration_ids": ["$MDL_MsoMdoc"], - "grants": { - "authorization_code": { - "issuer_state": "eyJhbGciOiJSU0EtFYUaBy" - } - } - } - """.trimIndent() - - val offer = credentialOffer(mockedKtorHttpClientFactory, noProofRequiredOffer) - val issuer = Issuer.make( - config = OpenId4VCIConfiguration, - credentialOffer = offer, - ktorHttpClientFactory = mockedKtorHttpClientFactory, - ).getOrThrow() - - with(issuer) { - val authorizationRequestPrepared = prepareAuthorizationRequest().getOrThrow() - val serverState = authorizationRequestPrepared.state - val authorizedRequest = authorizationRequestPrepared - .authorizeWithAuthorizationCode(AuthorizationCode("auth-code"), serverState) - .getOrThrow() - - assertTrue("Offer does not require proofs but authorized request is ProofRequired instead of NoProofRequired") { - authorizedRequest is AuthorizedRequest.NoProofRequired - } - } - } - - @Test - fun `when token endpoint does not return nonce and offer require proofs then authorized request must be NoProofRequired`() = - runTest { - val mockedKtorHttpClientFactory = mockedKtorHttpClientFactory( - authServerWellKnownMocker(), - oidcWellKnownMocker(), - parPostMocker(), - RequestMocker( - requestMatcher = endsWith("/token", HttpMethod.Post), - responseBuilder = { - respond( - content = Json.encodeToString( - TokenResponseTO.Success( - accessToken = UUID.randomUUID().toString(), - expiresIn = 3600, - ), - ), - status = HttpStatusCode.OK, - headers = headersOf( - HttpHeaders.ContentType to listOf("application/json"), - ), - ) - }, - ), - ) - - val offer = credentialOffer(mockedKtorHttpClientFactory, CredentialOfferMixedDocTypes_AUTH_GRANT) - val issuer = Issuer.make( - config = OpenId4VCIConfiguration, - credentialOffer = offer, - ktorHttpClientFactory = mockedKtorHttpClientFactory, - ).getOrThrow() - - with(issuer) { - val authorizationRequestPrepared = prepareAuthorizationRequest().getOrThrow() - val serverState = authorizationRequestPrepared.state - val authorizedRequest = authorizationRequestPrepared - .authorizeWithAuthorizationCode(AuthorizationCode("auth-code"), serverState) - .getOrThrow() - - assertTrue("Expected authorized request to be of type NoProofRequired but is ProofRequired") { - authorizedRequest is AuthorizedRequest.NoProofRequired - } - } - } - @Test fun `when par endpoint responds with failure, exception PushedAuthorizationRequestFailed is thrown`() = runTest { val mockedKtorHttpClientFactory = mockedKtorHttpClientFactory( authServerWellKnownMocker(), - oidcWellKnownMocker(), + oiciWellKnownMocker(), RequestMocker( requestMatcher = endsWith("/ext/par/request", HttpMethod.Post), responseBuilder = { @@ -446,7 +238,7 @@ class IssuanceAuthorizationTest { runTest { val mockedKtorHttpClientFactory = mockedKtorHttpClientFactory( authServerWellKnownMocker(), - oidcWellKnownMocker(), + oiciWellKnownMocker(), parPostMocker(), RequestMocker( requestMatcher = endsWith("/token", HttpMethod.Post), @@ -496,7 +288,7 @@ class IssuanceAuthorizationTest { runTest { val mockedKtorHttpClientFactory = mockedKtorHttpClientFactory( authServerWellKnownMocker(), - oidcWellKnownMocker(), + oiciWellKnownMocker(), parPostMocker(), RequestMocker( requestMatcher = endsWith("/token", HttpMethod.Post), diff --git a/src/test/kotlin/eu/europa/ec/eudi/openid4vci/IssuanceBatchRequestTest.kt b/src/test/kotlin/eu/europa/ec/eudi/openid4vci/IssuanceBatchRequestTest.kt index d7a35971..99213821 100644 --- a/src/test/kotlin/eu/europa/ec/eudi/openid4vci/IssuanceBatchRequestTest.kt +++ b/src/test/kotlin/eu/europa/ec/eudi/openid4vci/IssuanceBatchRequestTest.kt @@ -31,10 +31,11 @@ class IssuanceBatchRequestTest { @Test fun `successful batch issuance`() = runTest { val mockedKtorHttpClientFactory = mockedKtorHttpClientFactory( - oidcWellKnownMocker(encryptedResponses = EncryptedResponses.REQUIRED), + oiciWellKnownMocker(issuerMetadataVersion = IssuerMetadataVersion.ENCRYPTION_REQUIRED), authServerWellKnownMocker(), parPostMocker(), tokenPostMocker(), + nonceEndpointMocker(), singleIssuanceRequestMocker( responseBuilder = { val textContent = it?.body as TextContent @@ -53,8 +54,6 @@ class IssuanceBatchRequestTest { put("credential", JsonPrimitive("issued_credential_content_mso_mdoc2")) }, ), - cNonce = "wlbQc6pCJp", - cNonceExpiresInSeconds = 86400, ), ) } @@ -63,8 +62,6 @@ class IssuanceBatchRequestTest { content = """ { "error": "invalid_proof", - "c_nonce": "ERE%@^TGWYEYWEY", - "c_nonce_expires_in": 34 } """.trimIndent(), status = HttpStatusCode.BadRequest, diff --git a/src/test/kotlin/eu/europa/ec/eudi/openid4vci/IssuanceDeferredRequestTest.kt b/src/test/kotlin/eu/europa/ec/eudi/openid4vci/IssuanceDeferredRequestTest.kt index 9c531e03..5d1c214e 100644 --- a/src/test/kotlin/eu/europa/ec/eudi/openid4vci/IssuanceDeferredRequestTest.kt +++ b/src/test/kotlin/eu/europa/ec/eudi/openid4vci/IssuanceDeferredRequestTest.kt @@ -27,10 +27,11 @@ class IssuanceDeferredRequestTest { @Test fun `when issuer responds with invalid_transaction_id, response should be of type Errored`() = runTest { val mockedKtorHttpClientFactory = mockedKtorHttpClientFactory( - oidcWellKnownMocker(), + oiciWellKnownMocker(), authServerWellKnownMocker(), parPostMocker(), tokenPostMocker(), + nonceEndpointMocker(), singleIssuanceRequestMocker( responseBuilder = { respondToIssuanceRequestWithDeferredResponseDataBuilder(it) }, ), @@ -45,8 +46,6 @@ class IssuanceDeferredRequestTest { ) with(issuer) { - assertIs(authorizedRequest) - val requestPayload = IssuanceRequestPayload.ConfigurationBased( CredentialConfigurationIdentifier(PID_SdJwtVC), ) @@ -69,10 +68,11 @@ class IssuanceDeferredRequestTest { @Test fun `when issuer responds with issuance_pending, response should be of type IssuancePending`() = runTest { val mockedKtorHttpClientFactory = mockedKtorHttpClientFactory( - oidcWellKnownMocker(), + oiciWellKnownMocker(), authServerWellKnownMocker(), parPostMocker(), tokenPostMocker(), + nonceEndpointMocker(), singleIssuanceRequestMocker( responseBuilder = { respondToIssuanceRequestWithDeferredResponseDataBuilder(it) }, ), @@ -88,7 +88,6 @@ class IssuanceDeferredRequestTest { ) with(issuer) { - assertIs(authorizedRequest) val requestPayload = IssuanceRequestPayload.ConfigurationBased( CredentialConfigurationIdentifier(PID_SdJwtVC), ) @@ -111,10 +110,11 @@ class IssuanceDeferredRequestTest { @Test fun `when deferred request is valid, credential must be issued`() = runTest { val mockedKtorHttpClientFactory = mockedKtorHttpClientFactory( - oidcWellKnownMocker(), + oiciWellKnownMocker(), authServerWellKnownMocker(), parPostMocker(), tokenPostMocker(), + nonceEndpointMocker(), singleIssuanceRequestMocker( responseBuilder = { respondToIssuanceRequestWithDeferredResponseDataBuilder(it) }, ), @@ -149,7 +149,6 @@ class IssuanceDeferredRequestTest { ) with(issuer) { - assertIs(authorizedRequest) val requestPayload = IssuanceRequestPayload.ConfigurationBased( CredentialConfigurationIdentifier(PID_SdJwtVC), ) diff --git a/src/test/kotlin/eu/europa/ec/eudi/openid4vci/IssuanceEncryptedResponsesTest.kt b/src/test/kotlin/eu/europa/ec/eudi/openid4vci/IssuanceIssuerMetadataVersionTest.kt similarity index 88% rename from src/test/kotlin/eu/europa/ec/eudi/openid4vci/IssuanceEncryptedResponsesTest.kt rename to src/test/kotlin/eu/europa/ec/eudi/openid4vci/IssuanceIssuerMetadataVersionTest.kt index 5df3b7f4..76a3b27e 100644 --- a/src/test/kotlin/eu/europa/ec/eudi/openid4vci/IssuanceEncryptedResponsesTest.kt +++ b/src/test/kotlin/eu/europa/ec/eudi/openid4vci/IssuanceIssuerMetadataVersionTest.kt @@ -37,7 +37,7 @@ import kotlin.test.assertFailsWith import kotlin.test.assertIs import kotlin.test.assertTrue -class IssuanceEncryptedResponsesTest { +class IssuanceIssuerMetadataVersionTest { @Test fun `when encryption algorithm is not supported by issuer then throw ResponseEncryptionAlgorithmNotSupportedByIssuer`() = @@ -46,7 +46,7 @@ class IssuanceEncryptedResponsesTest { authServerWellKnownMocker(), parPostMocker(), tokenPostMocker(), - oidcWellKnownMocker(EncryptedResponses.REQUIRED), + oiciWellKnownMocker(IssuerMetadataVersion.ENCRYPTION_REQUIRED), ) val issuanceResponseEncryptionSpec = IssuanceResponseEncryptionSpec( jwk = randomRSAEncryptionKey(2048), @@ -72,7 +72,7 @@ class IssuanceEncryptedResponsesTest { authServerWellKnownMocker(), parPostMocker(), tokenPostMocker(), - oidcWellKnownMocker(EncryptedResponses.REQUIRED), + oiciWellKnownMocker(IssuerMetadataVersion.ENCRYPTION_REQUIRED), ) val issuanceResponseEncryptionSpec = IssuanceResponseEncryptionSpec( jwk = randomRSAEncryptionKey(2048), @@ -98,7 +98,7 @@ class IssuanceEncryptedResponsesTest { authServerWellKnownMocker(), parPostMocker(), tokenPostMocker(), - oidcWellKnownMocker(EncryptedResponses.NOT_SUPPORTED), + oiciWellKnownMocker(IssuerMetadataVersion.ENCRYPTION_NOT_SUPPORTED), ) val issuanceResponseEncryptionSpec = IssuanceResponseEncryptionSpec( jwk = randomRSAEncryptionKey(2048), @@ -127,7 +127,7 @@ class IssuanceEncryptedResponsesTest { authServerWellKnownMocker(), parPostMocker(), tokenPostMocker(), - oidcWellKnownMocker(EncryptedResponses.SUPPORTED_NOT_REQUIRED), + oiciWellKnownMocker(IssuerMetadataVersion.ENCRYPTION_SUPPORTED_NOT_REQUIRED), ) assertFailsWith( @@ -151,7 +151,8 @@ class IssuanceEncryptedResponsesTest { authServerWellKnownMocker(), parPostMocker(), tokenPostMocker(), - oidcWellKnownMocker(EncryptedResponses.NOT_SUPPORTED), + nonceEndpointMocker(), + oiciWellKnownMocker(IssuerMetadataVersion.ENCRYPTION_NOT_SUPPORTED), singleIssuanceRequestMocker( requestValidator = { val textContent = it.body as TextContent @@ -174,10 +175,10 @@ class IssuanceEncryptedResponsesTest { ) with(issuer) { - val noProofRequired = authorizedRequest as AuthorizedRequest.NoProofRequired val credentialConfigurationId = issuer.credentialOffer.credentialConfigurationIdentifiers[0] val requestPayload = IssuanceRequestPayload.ConfigurationBased(credentialConfigurationId) - noProofRequired.request(requestPayload).getOrThrow() + val popSigners = listOf(CryptoGenerator.rsaProofSigner()) + authorizedRequest.request(requestPayload, popSigners).getOrThrow() } } @@ -188,8 +189,23 @@ class IssuanceEncryptedResponsesTest { authServerWellKnownMocker(), parPostMocker(), tokenPostMocker(), - oidcWellKnownMocker(EncryptedResponses.SUPPORTED_NOT_REQUIRED), + nonceEndpointMocker(), + oiciWellKnownMocker(IssuerMetadataVersion.ENCRYPTION_SUPPORTED_NOT_REQUIRED), singleIssuanceRequestMocker( + responseBuilder = { + encryptedResponseDataBuilder(it) { + Json.encodeToString( + CredentialResponseSuccessTO( + credentials = listOf( + buildJsonObject { + put("credential", "issued_credential") + }, + ), + notificationId = "fgh126lbHjtspVbn", + ), + ) + } + }, requestValidator = { val textContent = it.body as TextContent val issuanceRequestTO = Json.decodeFromString(textContent.text) @@ -211,10 +227,10 @@ class IssuanceEncryptedResponsesTest { ) with(issuer) { - val noProofRequired = authorizedRequest as AuthorizedRequest.NoProofRequired val credentialConfigurationId = issuer.credentialOffer.credentialConfigurationIdentifiers[0] val requestPayload = IssuanceRequestPayload.ConfigurationBased(credentialConfigurationId) - noProofRequired.request(requestPayload).getOrThrow() + val popSigners = listOf(CryptoGenerator.rsaProofSigner()) + authorizedRequest.request(requestPayload, popSigners).getOrThrow() } } @@ -225,7 +241,8 @@ class IssuanceEncryptedResponsesTest { authServerWellKnownMocker(), parPostMocker(), tokenPostMocker(), - oidcWellKnownMocker(EncryptedResponses.SUPPORTED_NOT_REQUIRED), + nonceEndpointMocker(), + oiciWellKnownMocker(IssuerMetadataVersion.ENCRYPTION_SUPPORTED_NOT_REQUIRED), singleIssuanceRequestMocker( requestValidator = { val textContent = it.body as TextContent @@ -243,10 +260,10 @@ class IssuanceEncryptedResponsesTest { ) with(issuer) { - val noProofRequired = authorizedRequest as AuthorizedRequest.NoProofRequired val credentialConfigurationId = issuer.credentialOffer.credentialConfigurationIdentifiers[0] val requestPayload = IssuanceRequestPayload.ConfigurationBased(credentialConfigurationId) - noProofRequired.request(requestPayload).getOrThrow() + val popSigners = listOf(CryptoGenerator.rsaProofSigner()) + authorizedRequest.request(requestPayload, popSigners).getOrThrow() } } @@ -257,7 +274,9 @@ class IssuanceEncryptedResponsesTest { authServerWellKnownMocker(), parPostMocker(), tokenPostMocker(), - oidcWellKnownMocker(EncryptedResponses.REQUIRED), + nonceEndpointMocker(), + nonceEndpointMocker(), + oiciWellKnownMocker(IssuerMetadataVersion.ENCRYPTION_REQUIRED), singleIssuanceRequestMocker( responseBuilder = { encryptedResponseDataBuilder(it) { @@ -269,8 +288,6 @@ class IssuanceEncryptedResponsesTest { }, ), notificationId = "fgh126lbHjtspVbn", - cNonce = "wlbQc6pCJp", - cNonceExpiresInSeconds = 86400, ), ) } @@ -316,10 +333,10 @@ class IssuanceEncryptedResponsesTest { ) with(issuer) { - assertIs(authorizedRequest) val credentialConfigurationId = issuer.credentialOffer.credentialConfigurationIdentifiers[0] val requestPayload = IssuanceRequestPayload.ConfigurationBased(credentialConfigurationId) - val (_, outcome) = authorizedRequest.request(requestPayload).getOrThrow() + val popSigners = listOf(CryptoGenerator.rsaProofSigner()) + val (_, outcome) = authorizedRequest.request(requestPayload, popSigners).getOrThrow() assertIs(outcome) } } @@ -331,7 +348,8 @@ class IssuanceEncryptedResponsesTest { authServerWellKnownMocker(), parPostMocker(), tokenPostMocker(), - oidcWellKnownMocker(EncryptedResponses.REQUIRED), + nonceEndpointMocker(), + oiciWellKnownMocker(IssuerMetadataVersion.ENCRYPTION_REQUIRED), singleIssuanceRequestMocker( responseBuilder = { encryptedResponseDataBuilder(it) { @@ -342,8 +360,6 @@ class IssuanceEncryptedResponsesTest { put("credential", "${PID_MsoMdoc}_issued_credential") }, ), - cNonce = "wlbQc6pCJp", - cNonceExpiresInSeconds = 86400, ), ) } @@ -380,7 +396,8 @@ class IssuanceEncryptedResponsesTest { with(issuer) { val payload = IssuanceRequestPayload.ConfigurationBased(CredentialConfigurationIdentifier(PID_MsoMdoc)) - authorizedRequest.request(payload).getOrThrow() + val popSigners = listOf(CryptoGenerator.rsaProofSigner()) + authorizedRequest.request(payload, popSigners).getOrThrow() } } @@ -391,7 +408,8 @@ class IssuanceEncryptedResponsesTest { authServerWellKnownMocker(), parPostMocker(), tokenPostMocker(), - oidcWellKnownMocker(EncryptedResponses.REQUIRED), + nonceEndpointMocker(), + oiciWellKnownMocker(IssuerMetadataVersion.ENCRYPTION_REQUIRED), singleIssuanceRequestMocker( responseBuilder = { encryptedResponseDataBuilder(it) { @@ -402,8 +420,6 @@ class IssuanceEncryptedResponsesTest { put("credential", "${PID_MsoMdoc}_issued_credential") }, ), - cNonce = "wlbQc6pCJp", - cNonceExpiresInSeconds = 86400, ), ) } @@ -427,7 +443,7 @@ class IssuanceEncryptedResponsesTest { val (_, outcome) = with(issuer) { authorizedRequest.request( IssuanceRequestPayload.ConfigurationBased(CredentialConfigurationIdentifier(PID_MsoMdoc)), - emptyList(), + listOf(CryptoGenerator.rsaProofSigner()), ).getOrThrow() } assertIs(outcome) @@ -437,10 +453,11 @@ class IssuanceEncryptedResponsesTest { fun `when issuance request mandates encrypted responses and deferred response is not encrypted, throw InvalidResponseContentType`() = runTest { val mockedKtorHttpClientFactory = mockedKtorHttpClientFactory( - oidcWellKnownMocker(EncryptedResponses.REQUIRED), + oiciWellKnownMocker(IssuerMetadataVersion.ENCRYPTION_REQUIRED), authServerWellKnownMocker(), parPostMocker(), tokenPostMocker(), + nonceEndpointMocker(), singleIssuanceRequestMocker( responseBuilder = { encryptedResponseDataBuilder(it) { @@ -466,12 +483,12 @@ class IssuanceEncryptedResponsesTest { ) with(issuer) { - assertIs(authorizedRequest) val requestPayload = IssuanceRequestPayload.ConfigurationBased( CredentialConfigurationIdentifier(PID_SdJwtVC), ) + val popSigners = listOf(CryptoGenerator.rsaProofSigner()) val (newAuthorizedRequest, outcome) = - authorizedRequest.request(requestPayload).getOrThrow() + authorizedRequest.request(requestPayload, popSigners).getOrThrow() assertIs(outcome) assertFailsWith( @@ -490,10 +507,11 @@ class IssuanceEncryptedResponsesTest { encryptionMethod = EncryptionMethod.A128CBC_HS256, ) val mockedKtorHttpClientFactory = mockedKtorHttpClientFactory( - oidcWellKnownMocker(EncryptedResponses.REQUIRED), + oiciWellKnownMocker(IssuerMetadataVersion.ENCRYPTION_REQUIRED), authServerWellKnownMocker(), parPostMocker(), tokenPostMocker(), + nonceEndpointMocker(), singleIssuanceRequestMocker( responseBuilder = { encryptedResponseDataBuilder(it) { @@ -505,9 +523,7 @@ class IssuanceEncryptedResponsesTest { responseBuilder = { val responseJson = """ { - "credentials": [{ "credential": "credential_content" }], - "c_nonce": "ERE%@^TGWYEYWEY", - "c_nonce_expires_in": 34 + "credentials": [{ "credential": "credential_content" }] } """.trimIndent() respond( @@ -533,12 +549,12 @@ class IssuanceEncryptedResponsesTest { ) with(issuer) { - assertIs(authorizedRequest) val requestPayload = IssuanceRequestPayload.ConfigurationBased( CredentialConfigurationIdentifier(PID_SdJwtVC), ) + val popSigners = listOf(CryptoGenerator.rsaProofSigner()) val (newAuthorizedRequest, outcome) = - authorizedRequest.request(requestPayload).getOrThrow() + authorizedRequest.request(requestPayload, popSigners).getOrThrow() assertIs(outcome) val (_, deferredOutcome) = diff --git a/src/test/kotlin/eu/europa/ec/eudi/openid4vci/IssuanceNotificationTest.kt b/src/test/kotlin/eu/europa/ec/eudi/openid4vci/IssuanceNotificationTest.kt index be928f04..0fe43280 100644 --- a/src/test/kotlin/eu/europa/ec/eudi/openid4vci/IssuanceNotificationTest.kt +++ b/src/test/kotlin/eu/europa/ec/eudi/openid4vci/IssuanceNotificationTest.kt @@ -30,13 +30,12 @@ class IssuanceNotificationTest { fun `when issuance response contains notification_id, it is present in and can be used for notifications`() = runTest { val credential = "issued_credential_content_sd_jwt_vc" val mockedKtorHttpClientFactory = mockedKtorHttpClientFactory( - oidcWellKnownMocker(), + oiciWellKnownMocker(), authServerWellKnownMocker(), parPostMocker(), tokenPostMocker(), - singleIssuanceRequestMocker( - credential = credential, - ), + nonceEndpointMocker(), + singleIssuanceRequestMocker(credential), RequestMocker( requestMatcher = endsWith("/notification", HttpMethod.Post), responseBuilder = { @@ -69,36 +68,31 @@ class IssuanceNotificationTest { ktorHttpClientFactory = mockedKtorHttpClientFactory, ) with(issuer) { - when (authorizedRequest) { - is AuthorizedRequest.NoProofRequired -> { - val credentialConfigurationId = issuer.credentialOffer.credentialConfigurationIdentifiers[0] - val requestPayload = IssuanceRequestPayload.ConfigurationBased(credentialConfigurationId) - val popSigner = CryptoGenerator.rsaProofSigner() - val (newAuthorizedRequest, outcome) = - authorizedRequest.request(requestPayload, listOf(popSigner)).getOrThrow() - assertIs(outcome) + val credentialConfigurationId = issuer.credentialOffer.credentialConfigurationIdentifiers[0] + val requestPayload = IssuanceRequestPayload.ConfigurationBased(credentialConfigurationId) + val popSigner = CryptoGenerator.rsaProofSigner() + val (newAuthorizedRequest, outcome) = + authorizedRequest.request(requestPayload, listOf(popSigner)).getOrThrow() + assertIs(outcome) - val issuedCredential = outcome.credentials.firstOrNull() - assertIs(issuedCredential) - val notId = outcome.notificationId - assertNotNull(notId) + val issuedCredential = outcome.credentials.firstOrNull() + assertIs(issuedCredential) + val notId = outcome.notificationId + assertNotNull(notId) - newAuthorizedRequest.notify( - CredentialIssuanceEvent.Accepted( - id = notId, - description = "Credential received and validated", - ), - ) - } - else -> fail("State should be Authorized.NoProofRequired when no c_nonce returned from token endpoint") - } + newAuthorizedRequest.notify( + CredentialIssuanceEvent.Accepted( + id = notId, + description = "Credential received and validated", + ), + ) } } @Test fun `when notification request failed, a Result failure is returned`() = runTest { val mockedKtorHttpClientFactory = mockedKtorHttpClientFactory( - oidcWellKnownMocker(), + oiciWellKnownMocker(), authServerWellKnownMocker(), parPostMocker(), tokenPostMocker(), diff --git a/src/test/kotlin/eu/europa/ec/eudi/openid4vci/IssuanceSingleRequestTest.kt b/src/test/kotlin/eu/europa/ec/eudi/openid4vci/IssuanceSingleRequestTest.kt index 884b5cdc..6b3061d1 100644 --- a/src/test/kotlin/eu/europa/ec/eudi/openid4vci/IssuanceSingleRequestTest.kt +++ b/src/test/kotlin/eu/europa/ec/eudi/openid4vci/IssuanceSingleRequestTest.kt @@ -16,6 +16,7 @@ package eu.europa.ec.eudi.openid4vci import eu.europa.ec.eudi.openid4vci.CredentialIssuanceError.ResponseUnparsable +import eu.europa.ec.eudi.openid4vci.IssuerMetadataVersion.NO_NONCE_ENDPOINT import eu.europa.ec.eudi.openid4vci.internal.Proof import eu.europa.ec.eudi.openid4vci.internal.http.CredentialRequestTO import io.ktor.client.engine.mock.* @@ -28,27 +29,25 @@ import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import org.junit.jupiter.api.assertDoesNotThrow -import org.junit.jupiter.api.assertInstanceOf import org.junit.jupiter.api.assertThrows import kotlin.test.* class IssuanceSingleRequestTest { @Test - fun `when issuance requested with no proof then InvalidProof error is raised with c_nonce passed`() = runTest { + fun `when issuer responds with invalid_proof it is reflected in the submission outcomes`() = runTest { val mockedKtorHttpClientFactory = mockedKtorHttpClientFactory( - oidcWellKnownMocker(), + oiciWellKnownMocker(), authServerWellKnownMocker(), parPostMocker(), tokenPostMocker(), + nonceEndpointMocker(), singleIssuanceRequestMocker( responseBuilder = { respond( content = """ { - "error": "invalid_proof", - "c_nonce": "ERE%@^TGWYEYWEY", - "c_nonce_expires_in": 34 + "error": "invalid_proof" } """.trimIndent(), status = HttpStatusCode.BadRequest, @@ -83,115 +82,174 @@ class IssuanceSingleRequestTest { ) with(issuer) { - when (authorizedRequest) { - is AuthorizedRequest.NoProofRequired -> { - val credentialConfigurationId = issuer.credentialOffer.credentialConfigurationIdentifiers[0] - val (updatedAuthorizedRequest, outcome) = assertDoesNotThrow { - val requestPayload = IssuanceRequestPayload.ConfigurationBased(credentialConfigurationId) - authorizedRequest.request(requestPayload, emptyList()).getOrThrow() - } - assertIs(updatedAuthorizedRequest) - assertIs(outcome) - assertIs(outcome.error) - } + val credentialConfigurationId = issuer.credentialOffer.credentialConfigurationIdentifiers[0] + val (_, outcome) = assertDoesNotThrow { + val requestPayload = IssuanceRequestPayload.ConfigurationBased(credentialConfigurationId) + val popSigners = listOf(CryptoGenerator.rsaProofSigner()) + authorizedRequest.request(requestPayload, popSigners).getOrThrow() + } + assertIs(outcome) + assertIs(outcome.error) + } + } + + @Test + fun `when the requested credential is not included in the offer an IllegalArgumentException is thrown`() = runTest { + val mockedKtorHttpClientFactory = mockedKtorHttpClientFactory( + oiciWellKnownMocker(), + authServerWellKnownMocker(), + parPostMocker(), + tokenPostMocker(), + nonceEndpointMocker(), + ) + val (authorizedRequest, issuer) = authorizeRequestForCredentialOffer( + credentialOfferStr = CredentialOfferMixedDocTypes_NO_GRANTS, + ktorHttpClientFactory = mockedKtorHttpClientFactory, + ) + + val credentialConfigurationId = CredentialConfigurationIdentifier("UniversityDegree") + assertFailsWith { + val requestPayload = IssuanceRequestPayload.ConfigurationBased(credentialConfigurationId) + with(issuer) { + authorizedRequest.request(requestPayload, emptyList()).getOrThrow() + } + } + } + + @Test + fun `when the passed PoPSigners are more than the expected batch limit IssuerBatchSizeLimitExceeded is thrown`() = runTest { + val mockedKtorHttpClientFactory = mockedKtorHttpClientFactory( + oiciWellKnownMocker(), + authServerWellKnownMocker(), + parPostMocker(), + tokenPostMocker(), + nonceEndpointMocker(), + ) + val (authorizedRequest, issuer) = authorizeRequestForCredentialOffer( + credentialOfferStr = CredentialOfferMixedDocTypes_NO_GRANTS, + ktorHttpClientFactory = mockedKtorHttpClientFactory, + ) - is AuthorizedRequest.ProofRequired -> fail( - "State should be Authorized.NoProofRequired when no c_nonce returned from token endpoint", + val credentialConfigurationId = issuer.credentialOffer.credentialConfigurationIdentifiers[0] + assertFailsWith { + with(issuer) { + val requestPayload = IssuanceRequestPayload.ConfigurationBased(credentialConfigurationId) + val popSigners = listOf( + CryptoGenerator.rsaProofSigner(), + CryptoGenerator.rsaProofSigner(), + CryptoGenerator.rsaProofSigner(), + CryptoGenerator.rsaProofSigner(), ) + authorizedRequest.request(requestPayload, popSigners).getOrThrow() } } } @Test - fun `when issuer responds with 'invalid_proof' and no c_nonce then ResponseUnparsable error is returned `() = runTest { + fun `when credential configuration config does not demand proofs, no proof is included in the request`() = runTest { val mockedKtorHttpClientFactory = mockedKtorHttpClientFactory( - oidcWellKnownMocker(), + oiciWellKnownMocker(), authServerWellKnownMocker(), parPostMocker(), tokenPostMocker(), + nonceEndpointMocker(), singleIssuanceRequestMocker( - responseBuilder = { - respond( - content = """ - { - "error": "invalid_proof" - } - """.trimIndent(), - status = HttpStatusCode.BadRequest, - headers = headersOf( - HttpHeaders.ContentType to listOf("application/json"), - ), + requestValidator = { + val textContent = it.body as TextContent + val issuanceRequest = Json.decodeFromString(textContent.text) + assertNull( + issuanceRequest.proof, + "No proof expected to be sent with request but was sent.", ) }, ), ) + + // In issuer metadata the 'MobileDrivingLicense_msoMdoc' credential is configured to demand no proofs val (authorizedRequest, issuer) = authorizeRequestForCredentialOffer( - credentialOfferStr = CredentialOfferMsoMdoc_NO_GRANTS, + credentialOfferStr = CredentialOfferWithMDLMdoc_NO_GRANTS, ktorHttpClientFactory = mockedKtorHttpClientFactory, ) + val credentialConfigurationId = issuer.credentialOffer.credentialConfigurationIdentifiers[0] with(issuer) { - when (authorizedRequest) { - is AuthorizedRequest.NoProofRequired -> { - val credentialConfigurationId = issuer.credentialOffer.credentialConfigurationIdentifiers[0] - val (_, outcome) = assertDoesNotThrow { - val requestPayload = IssuanceRequestPayload.ConfigurationBased(credentialConfigurationId) - authorizedRequest.request(requestPayload).getOrThrow() - } - assertIs(outcome) - assertIs(outcome.error) - } - - is AuthorizedRequest.ProofRequired -> fail( - "State should be Authorized.NoProofRequired when no c_nonce returned from token endpoint", - ) - } + val requestPayload = IssuanceRequestPayload.ConfigurationBased(credentialConfigurationId) + authorizedRequest.request(requestPayload, emptyList()).getOrThrow() } } @Test - fun `when issuer used to request credential not included in offer an IllegalArgumentException is thrown`() = runTest { + fun `when credential configuration config demands proofs and issuer has no nonce endpoint, expect proofs without nonce`() = runTest { val mockedKtorHttpClientFactory = mockedKtorHttpClientFactory( - oidcWellKnownMocker(), + oiciWellKnownMocker(NO_NONCE_ENDPOINT), authServerWellKnownMocker(), parPostMocker(), tokenPostMocker(), + singleIssuanceRequestMocker( + requestValidator = { + val textContent = it.body as TextContent + val issuanceRequest = Json.decodeFromString(textContent.text) + assertNotNull( + issuanceRequest.proof, + "Proof expected to be sent but was not sent.", + ) + assertIs(issuanceRequest.proof) + val cNonce = issuanceRequest.proof.jwt.jwtClaimsSet.getStringClaim("nonce") + assertNull( + cNonce, + "No c_nonce expected in proof but found one", + ) + }, + ), ) + val (authorizedRequest, issuer) = authorizeRequestForCredentialOffer( credentialOfferStr = CredentialOfferMixedDocTypes_NO_GRANTS, ktorHttpClientFactory = mockedKtorHttpClientFactory, ) - assertIs(authorizedRequest) - val credentialConfigurationId = CredentialConfigurationIdentifier("UniversityDegree") - assertFailsWith { + val credentialConfigurationId = issuer.credentialOffer.credentialConfigurationIdentifiers[0] + with(issuer) { val requestPayload = IssuanceRequestPayload.ConfigurationBased(credentialConfigurationId) - with(issuer) { - authorizedRequest.request(requestPayload, emptyList()).getOrThrow() - } + val popSigners = listOf(CryptoGenerator.rsaProofSigner()) + authorizedRequest.request(requestPayload, popSigners).getOrThrow() } } @Test fun `successful issuance of credential requested by credential configuration id`() = runTest { val credential = "issued_credential_content_mso_mdoc" + val nonceValue = "c_nonce_from_endpoint" val mockedKtorHttpClientFactory = mockedKtorHttpClientFactory( - oidcWellKnownMocker(), + oiciWellKnownMocker(), authServerWellKnownMocker(), parPostMocker(), tokenPostMocker(), + nonceEndpointMocker(nonceValue), singleIssuanceRequestMocker( credential = credential, requestValidator = { val textContent = it.body as TextContent val issuanceRequest = Json.decodeFromString(textContent.text) - issuanceRequest.proof?.let { - assertIs(issuanceRequest.proof) - } assertTrue( issuanceRequest.credentialConfigurationId != null, "Expected request by configuration id but was not.", ) + assertNotNull( + issuanceRequest.proof, + "Proof expected to be sent but was not sent.", + ) + assertIs(issuanceRequest.proof) + val cNonce = issuanceRequest.proof.jwt.jwtClaimsSet.getStringClaim("nonce") + assertNotNull( + cNonce, + "c_nonce expected to be found in proof but was not", + ) + assertEquals( + cNonce, + nonceValue, + "Expected c_nonce $nonceValue but found $cNonce", + ) }, ), ) @@ -201,7 +259,6 @@ class IssuanceSingleRequestTest { ktorHttpClientFactory = mockedKtorHttpClientFactory, ) - assertIs(authorizedRequest) val credentialConfigurationId = issuer.credentialOffer.credentialConfigurationIdentifiers[0] val requestPayload = IssuanceRequestPayload.ConfigurationBased(credentialConfigurationId) val popSigner = CryptoGenerator.rsaProofSigner() @@ -214,9 +271,10 @@ class IssuanceSingleRequestTest { @Test fun `when token endpoint returns credential identifiers, issuance request must be IdentifierBasedIssuanceRequestTO`() = runTest { val mockedKtorHttpClientFactory = mockedKtorHttpClientFactory( - oidcWellKnownMocker(), + oiciWellKnownMocker(), authServerWellKnownMocker(), parPostMocker(), + nonceEndpointMocker(), tokenPostMockerWithAuthDetails( listOf(CredentialConfigurationIdentifier("eu.europa.ec.eudiw.pid_vc_sd_jwt")), ), @@ -237,6 +295,7 @@ class IssuanceSingleRequestTest { ktorHttpClientFactory = mockedKtorHttpClientFactory, ) + val popSigners = listOf(CryptoGenerator.rsaProofSigner()) val requestPayload = authorizedRequest.credentialIdentifiers?.let { IssuanceRequestPayload.IdentifierBased( it.entries.first().key, @@ -244,16 +303,17 @@ class IssuanceSingleRequestTest { ) } ?: error("No credential identifier") with(issuer) { - authorizedRequest.request(requestPayload).getOrThrow() + authorizedRequest.request(requestPayload, popSigners).getOrThrow() } } @Test fun `when request is by credential id, this id must be in the list of identifiers returned from token endpoint`() = runTest { val mockedKtorHttpClientFactory = mockedKtorHttpClientFactory( - oidcWellKnownMocker(), + oiciWellKnownMocker(), authServerWellKnownMocker(), parPostMocker(), + nonceEndpointMocker(), tokenPostMockerWithAuthDetails( listOf(CredentialConfigurationIdentifier("eu.europa.ec.eudiw.pid_vc_sd_jwt")), ), @@ -288,10 +348,11 @@ class IssuanceSingleRequestTest { @Test fun `issuance request by credential id, is allowed only when token endpoint has returned credential identifiers`() = runTest { val mockedKtorHttpClientFactory = mockedKtorHttpClientFactory( - oidcWellKnownMocker(), + oiciWellKnownMocker(), authServerWellKnownMocker(), parPostMocker(), tokenPostMocker(), + nonceEndpointMocker(), singleIssuanceRequestMocker( credential = "credential", requestValidator = { @@ -324,9 +385,10 @@ class IssuanceSingleRequestTest { @Test fun `when token endpoint returns authorization_details they are parsed properly`() = runTest { val mockedKtorHttpClientFactory = mockedKtorHttpClientFactory( - oidcWellKnownMocker(), + oiciWellKnownMocker(), authServerWellKnownMocker(), parPostMocker(), + nonceEndpointMocker(), tokenPostMockerWithAuthDetails( listOf(CredentialConfigurationIdentifier("eu.europa.ec.eudiw.pid_vc_sd_jwt")), ), @@ -355,10 +417,11 @@ class IssuanceSingleRequestTest { @Test fun `when successful issuance response contains additional info, it is reflected in SubmissionOutcome_Success`() = runTest { val mockedKtorHttpClientFactory = mockedKtorHttpClientFactory( - oidcWellKnownMocker(), + oiciWellKnownMocker(), authServerWellKnownMocker(), parPostMocker(), tokenPostMocker(), + nonceEndpointMocker(), singleIssuanceRequestMocker( responseBuilder = { respond( @@ -373,9 +436,7 @@ class IssuanceSingleRequestTest { "infoStr": "valueStr", "infoArr": ["valueArr1", "valueArr2", "valueArr3"] }], - "notification_id": "valbQc6p55LS", - "c_nonce": "wlbQc6pCJp", - "c_nonce_expires_in": 86400 + "notification_id": "valbQc6p55LS" } """.trimIndent(), status = HttpStatusCode.OK, @@ -391,13 +452,12 @@ class IssuanceSingleRequestTest { ktorHttpClientFactory = mockedKtorHttpClientFactory, ) - assertInstanceOf(authorizedRequest) - with(issuer) { val credentialConfigurationId = issuer.credentialOffer.credentialConfigurationIdentifiers[0] val (_, outcome) = assertDoesNotThrow { val requestPayload = IssuanceRequestPayload.ConfigurationBased(credentialConfigurationId) - authorizedRequest.request(requestPayload).getOrThrow() + val popSigners = listOf(CryptoGenerator.rsaProofSigner()) + authorizedRequest.request(requestPayload, popSigners).getOrThrow() } assertIs(outcome) assertTrue { outcome.credentials.size == 1 } @@ -415,10 +475,11 @@ class IssuanceSingleRequestTest { @Test fun `when successful issuance response does not contain 'credential' attribute fails with ResponseUnparsable exception`() = runTest { val mockedKtorHttpClientFactory = mockedKtorHttpClientFactory( - oidcWellKnownMocker(), + oiciWellKnownMocker(), authServerWellKnownMocker(), parPostMocker(), tokenPostMocker(), + nonceEndpointMocker(), singleIssuanceRequestMocker( responseBuilder = { respond( @@ -427,9 +488,7 @@ class IssuanceSingleRequestTest { "credentials": [{ "crdntial": "credential_content" }], - "notification_id": "valbQc6p55LS", - "c_nonce": "wlbQc6pCJp", - "c_nonce_expires_in": 86400 + "notification_id": "valbQc6p55LS" } """.trimIndent(), status = HttpStatusCode.OK, @@ -445,14 +504,12 @@ class IssuanceSingleRequestTest { ktorHttpClientFactory = mockedKtorHttpClientFactory, ) - assertInstanceOf(authorizedRequest) - with(issuer) { val credentialConfigurationId = issuer.credentialOffer.credentialConfigurationIdentifiers[0] - val ex = assertFailsWith { val requestPayload = IssuanceRequestPayload.ConfigurationBased(credentialConfigurationId) - authorizedRequest.request(requestPayload).getOrThrow() + val popSigners = listOf(CryptoGenerator.rsaProofSigner()) + authorizedRequest.request(requestPayload, popSigners).getOrThrow() } assertIs(ex.cause) } diff --git a/src/test/kotlin/eu/europa/ec/eudi/openid4vci/RequestMockers.kt b/src/test/kotlin/eu/europa/ec/eudi/openid4vci/RequestMockers.kt index 48f2a3bb..c942bccc 100644 --- a/src/test/kotlin/eu/europa/ec/eudi/openid4vci/RequestMockers.kt +++ b/src/test/kotlin/eu/europa/ec/eudi/openid4vci/RequestMockers.kt @@ -26,7 +26,8 @@ import com.nimbusds.jose.jwk.JWK import com.nimbusds.jose.jwk.RSAKey import com.nimbusds.jwt.EncryptedJWT import com.nimbusds.jwt.JWTClaimsSet -import eu.europa.ec.eudi.openid4vci.EncryptedResponses.* +import eu.europa.ec.eudi.openid4vci.IssuerMetadataVersion.* +import eu.europa.ec.eudi.openid4vci.internal.http.CNonceResponse import eu.europa.ec.eudi.openid4vci.internal.http.CredentialRequestTO import eu.europa.ec.eudi.openid4vci.internal.http.PushedAuthorizationRequestResponseTO import eu.europa.ec.eudi.openid4vci.internal.http.TokenResponseTO @@ -54,13 +55,15 @@ internal fun oauthMetaDataHandler(oauth2ServerUrl: HttpsUrl, oauth2MetaDataResou jsonResponse(oauth2MetaDataResource), ) -internal fun oidcWellKnownMocker(encryptedResponses: EncryptedResponses = NOT_SUPPORTED): RequestMocker = RequestMocker( +internal fun oiciWellKnownMocker(issuerMetadataVersion: IssuerMetadataVersion = ENCRYPTION_NOT_SUPPORTED): RequestMocker = RequestMocker( requestMatcher = endsWith("/.well-known/openid-credential-issuer", HttpMethod.Get), responseBuilder = { - val content = when (encryptedResponses) { - REQUIRED -> getResourceAsText("well-known/openid-credential-issuer_encrypted_responses.json") - NOT_SUPPORTED -> getResourceAsText("well-known/openid-credential-issuer_encryption_not_supported.json") - SUPPORTED_NOT_REQUIRED -> getResourceAsText("well-known/openid-credential-issuer_encryption_not_required.json") + val content = when (issuerMetadataVersion) { + ENCRYPTION_REQUIRED -> getResourceAsText("well-known/openid-credential-issuer_encrypted_responses.json") + ENCRYPTION_NOT_SUPPORTED -> getResourceAsText("well-known/openid-credential-issuer_encryption_not_supported.json") + ENCRYPTION_SUPPORTED_NOT_REQUIRED -> getResourceAsText("well-known/openid-credential-issuer_encryption_not_required.json") + NO_NONCE_ENDPOINT -> getResourceAsText("well-known/openid-credential-issuer_no_nonce_endpoint.json") + NO_SCOPES -> getResourceAsText("well-known/openid-credential-issuer_no_scopes.json") } respond( content = content, @@ -72,8 +75,12 @@ internal fun oidcWellKnownMocker(encryptedResponses: EncryptedResponses = NOT_SU }, ) -enum class EncryptedResponses { - REQUIRED, NOT_SUPPORTED, SUPPORTED_NOT_REQUIRED +enum class IssuerMetadataVersion { + ENCRYPTION_REQUIRED, + ENCRYPTION_NOT_SUPPORTED, + ENCRYPTION_SUPPORTED_NOT_REQUIRED, + NO_NONCE_ENDPOINT, + NO_SCOPES, } internal fun authServerWellKnownMocker(): RequestMocker = RequestMocker( @@ -153,6 +160,26 @@ internal fun tokenPostMockerWithAuthDetails( requestValidator = validator, ) +internal fun nonceEndpointMocker( + nonceValue: String? = null, + validator: (request: HttpRequestData) -> Unit = {}, +): RequestMocker = + RequestMocker( + requestMatcher = endsWith("/nonce", HttpMethod.Post), + responseBuilder = { + respond( + content = Json.encodeToString( + CNonceResponse(nonceValue ?: UUID.randomUUID().toString()), + ), + status = HttpStatusCode.OK, + headers = headersOf( + HttpHeaders.ContentType to listOf("application/json"), + ), + ) + }, + requestValidator = validator, + ) + private fun authorizationDetails( configurationIds: List, ): Map> = @@ -192,9 +219,7 @@ private fun MockRequestHandleScope.defaultIssuanceResponseDataBuilder(request: H content = """ { "credentials": [ {"credential": "$credential"} ], - "notification_id": "valbQc6p55LS", - "c_nonce": "wlbQc6pCJp", - "c_nonce_expires_in": 86400 + "notification_id": "valbQc6p55LS" } """.trimIndent(), status = HttpStatusCode.OK, @@ -206,9 +231,7 @@ private fun MockRequestHandleScope.defaultIssuanceResponseDataBuilder(request: H respond( content = """ { - "error": "invalid_proof", - "c_nonce": "ERE%@^TGWYEYWEY", - "c_nonce_expires_in": 34 + "error": "invalid_proof" } """.trimIndent(), status = HttpStatusCode.BadRequest, @@ -238,9 +261,7 @@ fun MockRequestHandleScope.respondToIssuanceRequestWithDeferredResponseDataBuild respond( content = """ { - "error": "invalid_proof", - "c_nonce": "ERE%@^TGWYEYWEY", - "c_nonce_expires_in": 34 + "error": "invalid_proof" } """.trimIndent(), status = HttpStatusCode.BadRequest, @@ -259,9 +280,7 @@ fun MockRequestHandleScope.defaultIssuanceResponseDataBuilder( respond( content = """ { - "credentials": [ { "credential": "credential_content"} ], - "c_nonce": "ERE%@^TGWYEYWEY", - "c_nonce_expires_in": 34 + "credentials": [ { "credential": "credential_content"} ] } """.trimIndent(), status = HttpStatusCode.OK, diff --git a/src/test/kotlin/eu/europa/ec/eudi/openid4vci/RichAuthorizationRequestTest.kt b/src/test/kotlin/eu/europa/ec/eudi/openid4vci/RichAuthorizationRequestTest.kt index 7ce70699..d287278e 100644 --- a/src/test/kotlin/eu/europa/ec/eudi/openid4vci/RichAuthorizationRequestTest.kt +++ b/src/test/kotlin/eu/europa/ec/eudi/openid4vci/RichAuthorizationRequestTest.kt @@ -35,7 +35,7 @@ class RichAuthorizationRequestTest { fun `when AuthorizationDetailsInTokenRequest is Include in pre-authorization flow expect authorization_details`() = runTest { val mockedKtorHttpClientFactory = mockedKtorHttpClientFactory( authServerWellKnownMocker(), - oidcWellKnownMocker(), + oiciWellKnownMocker(), parPostMocker { fail("No pushed authorization request should have been sent in case of pre-authorized code flow") }, @@ -76,7 +76,7 @@ class RichAuthorizationRequestTest { fun `when config is FAVOR_SCOPES, auth details option is Include and all credentials have scopes no authorization_details expected`() = runTest { val mockedKtorHttpClientFactory = mockedKtorHttpClientFactory( - oidcWellKnownMocker(), + oiciWellKnownMocker(), authServerWellKnownMocker(), parPostMocker { request -> val form = with(request) { parPostApplyAssertionsAndGetFormData(false) } diff --git a/src/test/kotlin/eu/europa/ec/eudi/openid4vci/TestUtils.kt b/src/test/kotlin/eu/europa/ec/eudi/openid4vci/TestUtils.kt index 100083d9..82ac9fa7 100644 --- a/src/test/kotlin/eu/europa/ec/eudi/openid4vci/TestUtils.kt +++ b/src/test/kotlin/eu/europa/ec/eudi/openid4vci/TestUtils.kt @@ -54,6 +54,13 @@ val CredentialOfferWithJwtVcJson_NO_GRANTS = """ } """.trimIndent() +val CredentialOfferWithMDLMdoc_NO_GRANTS = """ + { + "credential_issuer": "$CREDENTIAL_ISSUER_PUBLIC_URL", + "credential_configuration_ids": ["$MDL_MsoMdoc"] + } +""".trimIndent() + val CredentialOfferMixedDocTypes_PRE_AUTH_GRANT = """ { "credential_issuer": "$CREDENTIAL_ISSUER_PUBLIC_URL", diff --git a/src/test/kotlin/eu/europa/ec/eudi/openid4vci/examples/DeferredIssuerExtensions.kt b/src/test/kotlin/eu/europa/ec/eudi/openid4vci/examples/DeferredIssuerExtensions.kt index 343a29c8..5c0a25c6 100644 --- a/src/test/kotlin/eu/europa/ec/eudi/openid4vci/examples/DeferredIssuerExtensions.kt +++ b/src/test/kotlin/eu/europa/ec/eudi/openid4vci/examples/DeferredIssuerExtensions.kt @@ -182,7 +182,7 @@ data class DeferredIssuanceStoredContextTO( responseEncryptionSpec = responseEncryptionSpec?.let { responseEncryption(it) }, ), authorizedTransaction = AuthorizedTransaction( - authorizedRequest = AuthorizedRequest.NoProofRequired( + authorizedRequest = AuthorizedRequest( accessToken = accessToken.toAccessToken(), refreshToken = refreshToken?.toRefreshToken(), credentialIdentifiers = emptyMap(), @@ -215,7 +215,7 @@ data class DeferredIssuanceStoredContextTO( credentialIssuerId = dCtx.config.credentialIssuerId.toString(), clientId = dCtx.config.client.id, clientAttestationJwt = dCtx.config.client.ifAttested { attestationJWT.jwt.serialize() }, - clientAttestationPopType = dCtx.config.client.ifAttested { popJwtSpec.typ.toString() }, + clientAttestationPopType = dCtx.config.client.ifAttested { popJwtSpec.typ }, clientAttestationPopDuration = dCtx.config.client.ifAttested { popJwtSpec.duration.inWholeSeconds }, clientAttestationPopAlgorithm = dCtx.config.client.ifAttested { popJwtSpec.signingAlgorithm.toJSONString() }, clientAttestationPopKeyId = dCtx.config.client.ifAttested { checkNotNull(clientAttestationPopKeyId) }, diff --git a/src/test/kotlin/eu/europa/ec/eudi/openid4vci/internal/DefaultCredentialOfferRequestResolverTest.kt b/src/test/kotlin/eu/europa/ec/eudi/openid4vci/internal/DefaultCredentialOfferRequestResolverTest.kt index 6f86052c..8232c0eb 100644 --- a/src/test/kotlin/eu/europa/ec/eudi/openid4vci/internal/DefaultCredentialOfferRequestResolverTest.kt +++ b/src/test/kotlin/eu/europa/ec/eudi/openid4vci/internal/DefaultCredentialOfferRequestResolverTest.kt @@ -30,7 +30,7 @@ internal class DefaultCredentialOfferRequestResolverTest { @Test fun `resolve a credential offer passed by value that contains a pre-authorized code grant without transaction code`() = runTest { - mockedKtorHttpClientFactory(oidcWellKnownMocker(), authServerWellKnownMocker()) + mockedKtorHttpClientFactory(oiciWellKnownMocker(), authServerWellKnownMocker()) .invoke() .use { httpClient -> val resolver = DefaultCredentialOfferRequestResolver(httpClient) @@ -69,7 +69,7 @@ internal class DefaultCredentialOfferRequestResolverTest { @Test fun `resolve a credential offer passed by value that contains a pre-authorized code grant with transaction code`() = runTest { - mockedKtorHttpClientFactory(oidcWellKnownMocker(), authServerWellKnownMocker()) + mockedKtorHttpClientFactory(oiciWellKnownMocker(), authServerWellKnownMocker()) .invoke() .use { httpClient -> val resolver = DefaultCredentialOfferRequestResolver(httpClient) diff --git a/src/test/resources/well-known/openid-credential-issuer_no_nonce_endpoint.json b/src/test/resources/well-known/openid-credential-issuer_no_nonce_endpoint.json new file mode 100644 index 00000000..6cda64af --- /dev/null +++ b/src/test/resources/well-known/openid-credential-issuer_no_nonce_endpoint.json @@ -0,0 +1,317 @@ +{ + "credential_issuer": "https://credential-issuer.example.com", + "authorization_servers": [ + "https://auth-server.example.com" + ], + "credential_endpoint": "https://credential-issuer.example.com/credentials", + "deferred_credential_endpoint": "https://credential-issuer.example.com/credentials/deferred", + "notification_endpoint": "https://credential-issuer.example.com/notification", + "batch_credential_issuance": { + "batch_size": 3 + }, + "credential_identifiers_supported": true, + "credential_configurations_supported": { + "eu.europa.ec.eudiw.pid_vc_sd_jwt": { + "format": "dc+sd-jwt", + "scope": "eu.europa.ec.eudiw.pid_vc_sd_jwt", + "cryptographic_binding_methods_supported": [ + "jwk" + ], + "credential_signing_alg_values_supported": [ + "RS256" + ], + "proof_types_supported": { + "jwt": { + "proof_signing_alg_values_supported": [ + "RS256", + "ES256" + ] + } + }, + "vct": "eu.europa.ec.eudiw.pid.1", + "claims": [ + { + "path": [ "given_name" ], + "display": [ + { + "name": "Given Name", + "locale": "en-US" + } + ] + }, + { + "path": [ "family_name" ], + "display": [ + { + "name": "Surname", + "locale": "en-US" + } + ] + }, + { + "path": [ "birth_date" ] + } + ], + "display": [ + { + "name": "Personal Identification Data ", + "locale": "en-US", + "logo": { + "uri": "https://examplestate.com/public/pid.png", + "alt_text": "a square logo of a university" + }, + "background_color": "#12107c", + "text_color": "#FFFFFF" + } + ] + }, + "eu.europa.ec.eudiw.pid_mso_mdoc": { + "format": "mso_mdoc", + "scope": "eu.europa.ec.eudiw.pid_mso_mdoc", + "doctype": "org.iso.18013.5.1.PID", + "cryptographic_binding_methods_supported": [ + "jwk" + ], + "credential_signing_alg_values_supported": [ + "RS256" + ], + "proof_types_supported": { + "jwt": { + "proof_signing_alg_values_supported": [ + "RS256", + "ES256" + ] + } + }, + "display": [ + { + "name": "Personal Identification Data", + "locale": "en-US", + "logo": { + "uri": "https://examplestate.com/public/pid.png", + "alt_text": "a square figure of a mobile driving license" + }, + "background_color": "#12107c", + "background_image": { + "uri": "https://examplestate.com/public/background.png" + }, + "text_color": "#FFFFFF" + } + ], + "claims": [ + { + "path": [ "org.iso.18013.5.1", "given_name" ], + "display": [ + { + "name": "Given Name", + "locale": "en-US" + } + ] + }, + { + "path": [ "org.iso.18013.5.1", "family_name" ], + "display": [ + { + "name": "Surname", + "locale": "en-US" + } + ] + }, + { + "path": [ "org.iso.18013.5.1", "birth_date" ] + }, + { + "path": [ "org.iso.18013.5.1.aamva", "organ_donor" ] + } + ] + }, + "UniversityDegree_mso_mdoc": { + "format": "mso_mdoc", + "scope": "UniversityDegree", + "doctype": "org.iso.18013.5.1.Degree", + "cryptographic_binding_methods_supported": [ + "jwk" + ], + "credential_signing_alg_values_supported": [ + "RS256" + ], + "proof_types_supported": { + "jwt": { + "proof_signing_alg_values_supported": [ + "RS256", + "ES256" + ] + } + }, + "display": [ + { + "name": "Mobile Driving License", + "locale": "en-US", + "logo": { + "uri": "https://examplestate.com/public/mdl.png", + "alt_text": "a square figure of a mobile driving license" + }, + "background_color": "#12107c", + "background_image": { + "uri": "https://examplestate.com/public/background.png" + }, + "text_color": "#FFFFFF" + } + ], + "claims": [ + { + "path": [ "org.iso.18013.5.1", "given_name" ], + "display": [ + { + "name": "Given Name", + "locale": "en-US" + } + ] + }, + { + "path": [ "org.iso.18013.5.1", "family_name" ], + "display": [ + { + "name": "Surname", + "locale": "en-US" + } + ] + }, + { + "path": [ "org.iso.18013.5.1", "birth_date" ] + }, + { + "path": [ "org.iso.18013.5.1.aamva", "organ_donor" ] + } + ] + }, + "UniversityDegree_jwt_vc_json": { + "format": "jwt_vc_json", + "scope": "UniversityDegree", + "cryptographic_binding_methods_supported": [ + "did:example" + ], + "credential_signing_alg_values_supported": [ + "ES256" + ], + "credential_definition": { + "type": [ + "VerifiableCredential", + "UniversityDegreeCredential" + ] + }, + "proof_types_supported": { + "jwt": { + "proof_signing_alg_values_supported": [ + "RS256", + "ES256" + ] + } + }, + "display": [ + { + "name": "University Credential", + "locale": "en-US", + "logo": { + "url": "https://university.example.edu/public/logo.png", + "alt_text": "a square logo of a university" + }, + "background_color": "#12107c", + "text_color": "#FFFFFF" + } + ], + "claims": [ + { + "path": [ "given_name" ], + "display": [ + { + "name": "Given Name", + "locale": "en-US" + } + ] + }, + { + "path": [ "family_name" ], + "display": [ + { + "name": "Surname", + "locale": "en-US" + } + ] + }, + { + "path": [ "degree" ] + }, + { + "path": [ "gpa" ], + "display": [ + { + "name": "name", + "locale": "GPA" + } + ] + } + ] + }, + "MobileDrivingLicense_msoMdoc": { + "format": "mso_mdoc", + "scope": "MobileDrivingLicense_msoMdoc", + "doctype": "org.iso.18013.5.1.mDL", + "cryptographic_binding_methods_supported": [ + "cose_key" + ], + "credential_signing_alg_values_supported": [ + "ES256", + "ES384", + "ES512" + ], + "display": [ + { + "name": "Mobile Driving License", + "locale": "en-US", + "logo": { + "uri": "https://examplestate.com/public/mdl.png", + "alt_text": "a square figure of a mobile driving license" + }, + "background_color": "#12107c", + "background_image": { + "uri": "https://examplestate.com/public/background.png" + }, + "text_color": "#FFFFFF" + } + ], + "claims": [ + { + "path": [ "org.iso.18013.5.1", "given_name" ], + "display": [ + { + "name": "Given Name", + "locale": "en-US" + } + ] + }, + { + "path": [ "org.iso.18013.5.1", "family_name" ], + "display": [ + { + "name": "Surname", + "locale": "en-US" + } + ] + }, + { + "path": [ "org.iso.18013.5.1", "birth_date" ] + }, + { + "path": [ "org.iso.18013.5.1.aamva", "organ_donor" ] + } + ] + } + }, + "display": [ + { + "name": "credential-issuer.example.com", + "locale": "en-US" + } + ] +} \ No newline at end of file