Skip to content

Commit 693fe56

Browse files
committed
fix(openid4vci): retry invalid nonce credential requests
1 parent c12d1d6 commit 693fe56

4 files changed

Lines changed: 309 additions & 61 deletions

File tree

waltid-libraries/protocols/waltid-openid4vc-wallet-server/src/main/kotlin/id/walt/wallet2/server/handlers/Wallet2RouteHandler.kt

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package id.walt.wallet2.server.handlers
33
import id.walt.crypto.keys.KeyManager
44
import id.walt.crypto.keys.TypedKeyGenerationRequest
55
import id.walt.did.dids.DidService
6+
import id.walt.openid4vci.errors.CredentialError
67
import id.walt.wallet2.data.*
78
import id.walt.wallet2.handlers.*
89
import id.walt.verifier.openid.transactiondata.TransactionDataTypeRegistry
@@ -609,13 +610,23 @@ object Wallet2RouteHandler {
609610
post("/fetch-credential", {
610611
summary = "Isolated: fetch a credential from the issuer's credential endpoint"
611612
description = "When storeInWallet is true the fetched credential(s) are automatically " +
612-
"stored in the wallet, removing the need to call the import endpoint afterwards."
613+
"stored in the wallet, removing the need to call the import endpoint afterwards. " +
614+
"If the issuer returns invalid_nonce, request a fresh nonce, sign a new proof, " +
615+
"and repeat this isolated fetch step."
613616
request { pathParameter<String>("walletId"); body<FetchCredentialRequest>() }
614-
response { HttpStatusCode.OK to { body<FetchCredentialResult>() } }
617+
response {
618+
HttpStatusCode.OK to { body<FetchCredentialResult>() }
619+
HttpStatusCode.BadRequest to { body<CredentialError>() }
620+
}
615621
}) {
616622
val wallet = call.resolveOrRespond(resolver, getAccountId) ?: return@post
617623
val req = call.receive<FetchCredentialRequest>()
618-
call.respond(WalletIssuanceHandler.fetchCredential(wallet, req))
624+
try {
625+
call.respond(WalletIssuanceHandler.fetchCredential(wallet, req))
626+
} catch (error: CredentialEndpointException) {
627+
if (!error.isInvalidNonce) throw error
628+
call.respond(HttpStatusCode.BadRequest, requireNotNull(error.credentialError))
629+
}
619630
}
620631

621632
// Auth-code grant isolated steps

waltid-libraries/protocols/waltid-openid4vc-wallet/src/commonMain/kotlin/id/walt/wallet2/handlers/WalletIssuanceHandler.kt

Lines changed: 126 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import id.walt.crypto.keys.DirectSerializedKey
55
import id.walt.crypto.keys.Key
66
import id.walt.openid4vci.CryptographicBindingMethod
77
import id.walt.openid4vci.clientauth.ClientAuthenticationMethods
8+
import id.walt.openid4vci.errors.CredentialError
9+
import id.walt.openid4vci.errors.CredentialErrorCodes
810
import id.walt.openid4vci.metadata.issuer.CredentialIssuerMetadata
911
import id.walt.openid4vci.metadata.oauth.AuthorizationServerMetadata
1012
import id.walt.openid4vci.offers.CredentialOffer
@@ -312,6 +314,25 @@ data class FetchCredentialResult(
312314
val rawCredentials: List<String>
313315
)
314316

317+
/**
318+
* A sanitized failure returned by an OpenID4VCI Credential Endpoint.
319+
*
320+
* The response body is parsed into [credentialError] when it follows the OID4VCI error shape;
321+
* raw response content, access tokens, and proof material are deliberately not retained.
322+
*/
323+
class CredentialEndpointException(
324+
val statusCode: Int,
325+
val credentialError: CredentialError? = null,
326+
) : Exception(
327+
buildString {
328+
append("Credential endpoint returned HTTP ").append(statusCode)
329+
credentialError?.error?.let { append(" (").append(it).append(')') }
330+
}
331+
) {
332+
val isInvalidNonce: Boolean
333+
get() = credentialError?.error == CredentialErrorCodes.INVALID_NONCE
334+
}
335+
315336
// Deferred issuance types
316337

317338
@Serializable
@@ -547,43 +568,32 @@ object WalletIssuanceHandler {
547568
// 5. Issue each offered credential with a fresh nonce when proof is required.
548569
for (offeredCredential in offeredCredentials) {
549570
log.trace { "Issuing credential configId=${offeredCredential.credentialConfigurationId}, format=${offeredCredential.configuration.format}" }
550-
val proofs = if (offeredCredential.configuration.proofTypesSupported?.isNotEmpty() == true) {
551-
val nonce = requestProofNonce(httpClient, issuerMetadata)
552-
log.trace { "Building credential proof JWT" }
553-
val preferJwkBinding =
554-
shouldPreferJwkBinding(offeredCredential.configuration.cryptographicBindingMethodsSupported)
555-
if (did != null && !preferJwkBinding) {
556-
proofBuilder.buildJwtProof(key, offer.credentialIssuer, nonce, keyId = did)
557-
} else {
558-
proofBuilder.buildJwtProof(key, offer.credentialIssuer, nonce, includeJwk = true)
559-
}
560-
} else null
561-
log.trace { "Proof JWT present: ${proofs?.jwt?.isNotEmpty() == true}" }
562-
onEvent(WalletSessionEvent.issuance_proof_signed)
563-
564-
// Build the credential request JSON manually to ensure proofs serializes correctly.
565-
// Using DefaultCredentialRequest + setBody risks proofs being serialized as null
566-
// due to Proofs.additional: Map<String,JsonElement> interacting with encodeDefaults=false.
567-
val credentialRequestJson = buildJsonObject {
568-
put("credential_configuration_id", offeredCredential.credentialConfigurationId)
569-
proofs?.jwt?.firstOrNull()?.let { jwt ->
570-
putJsonObject("proofs") {
571-
put("jwt", buildJsonArray { add(JsonPrimitive(jwt)) })
571+
val buildProof: (suspend (String?) -> String?)? =
572+
if (offeredCredential.configuration.proofTypesSupported?.isNotEmpty() == true) {
573+
{ nonce ->
574+
log.trace { "Building credential proof JWT" }
575+
val preferJwkBinding = shouldPreferJwkBinding(
576+
offeredCredential.configuration.cryptographicBindingMethodsSupported
577+
)
578+
if (did != null && !preferJwkBinding) {
579+
proofBuilder.buildJwtProof(key, offer.credentialIssuer, nonce, keyId = did).jwt?.firstOrNull()
580+
} else {
581+
proofBuilder.buildJwtProof(key, offer.credentialIssuer, nonce, includeJwk = true).jwt?.firstOrNull()
582+
}
572583
}
573-
}
574-
}
575-
log.trace { "Posting credential request" }
576-
577-
val credentialResponse = postFollowingRedirects(httpClient, credentialEndpoint) {
578-
header(HttpHeaders.Authorization, "Bearer ${tokenResponse.access_token}")
579-
contentType(ContentType.Application.Json)
580-
setBody(credentialRequestJson.toString())
581-
}.let { response ->
582-
if (!response.status.isSuccess()) {
583-
error("Credential endpoint returned ${response.status}: ${response.bodyAsText()}")
584-
}
585-
response.body<CredentialResponse>()
586-
}
584+
} else null
585+
586+
val credentialResponse = requestCredentialWithNonceRetry(
587+
request = FetchCredentialRequest(
588+
credentialEndpoint = Url(credentialEndpoint),
589+
accessToken = tokenResponse.access_token,
590+
credentialConfigurationId = offeredCredential.credentialConfigurationId,
591+
),
592+
nonceEndpoint = issuerMetadata.nonceEndpoint,
593+
httpClient = httpClient,
594+
buildProof = buildProof,
595+
onProofGenerated = { onEvent(WalletSessionEvent.issuance_proof_signed) },
596+
)
587597
onEvent(WalletSessionEvent.issuance_credential_received)
588598

589599
val rawCredentials = credentialResponse.credentials
@@ -844,8 +854,24 @@ object WalletIssuanceHandler {
844854
return SignProofResult(proofJwt = proofs.jwt?.firstOrNull() ?: error("Proof signing produced no JWT"))
845855
}
846856

847-
suspend fun fetchCredential(request: FetchCredentialRequest): FetchCredentialResult {
857+
suspend fun fetchCredential(request: FetchCredentialRequest): FetchCredentialResult =
858+
fetchCredential(request, defaultHttpClient())
848859

860+
internal suspend fun fetchCredential(
861+
request: FetchCredentialRequest,
862+
httpClient: HttpClient,
863+
): FetchCredentialResult {
864+
val credentialResponse = requestCredential(request, httpClient)
865+
val rawCredentials = credentialResponse.credentials
866+
?.map { it.credential.let { c -> if (c is JsonPrimitive) c.content else c.toString() } }
867+
?: error("Credential response contained no credentials")
868+
return FetchCredentialResult(rawCredentials = rawCredentials)
869+
}
870+
871+
private suspend fun requestCredential(
872+
request: FetchCredentialRequest,
873+
httpClient: HttpClient,
874+
): CredentialResponse {
849875
// Build JSON manually to avoid Proofs serialization issue
850876
val credentialRequestJson = buildJsonObject {
851877
put("credential_configuration_id", request.credentialConfigurationId)
@@ -861,13 +887,47 @@ object WalletIssuanceHandler {
861887
setBody(credentialRequestJson.toString())
862888
}
863889
if (!response.status.isSuccess()) {
864-
error("Credential endpoint returned ${response.status}: ${response.bodyAsText()}")
890+
val credentialError = runCatching {
891+
lenientJson.decodeFromString<CredentialError>(response.bodyAsText())
892+
}.getOrNull()
893+
throw CredentialEndpointException(
894+
statusCode = response.status.value,
895+
credentialError = credentialError,
896+
)
897+
}
898+
return response.body()
899+
}
900+
901+
/**
902+
* Fetches a credential with a freshly generated proof, retrying once only when the issuer
903+
* explicitly rejects that proof with the OID4VCI `invalid_nonce` error.
904+
*
905+
* Isolated fetch callers deliberately do not use this helper: they own the separate
906+
* request-nonce and sign-proof steps and therefore must handle [CredentialEndpointException]
907+
* themselves.
908+
*/
909+
internal suspend fun requestCredentialWithNonceRetry(
910+
request: FetchCredentialRequest,
911+
nonceEndpoint: String?,
912+
httpClient: HttpClient,
913+
buildProof: (suspend (String?) -> String?)?,
914+
onProofGenerated: suspend () -> Unit = {},
915+
): CredentialResponse {
916+
suspend fun fetchWithFreshProof(): CredentialResponse {
917+
val proofJwt = buildProof?.invoke(requestProofNonce(httpClient, nonceEndpoint))
918+
onProofGenerated()
919+
return requestCredential(request.copy(proofJwt = proofJwt), httpClient)
920+
}
921+
922+
return try {
923+
fetchWithFreshProof()
924+
} catch (error: CredentialEndpointException) {
925+
if (!error.isInvalidNonce || buildProof == null || nonceEndpoint == null) {
926+
throw error
927+
}
928+
log.info { "Credential issuer rejected the proof nonce; obtaining a fresh nonce and retrying once" }
929+
fetchWithFreshProof()
865930
}
866-
val credentialResponse = response.body<CredentialResponse>()
867-
val rawCredentials = credentialResponse.credentials
868-
?.map { it.credential.let { c -> if (c is JsonPrimitive) c.content else c.toString() } }
869-
?: error("Credential response contained no credentials")
870-
return FetchCredentialResult(rawCredentials = rawCredentials)
871931
}
872932

873933
/**
@@ -976,10 +1036,14 @@ object WalletIssuanceHandler {
9761036
private suspend fun requestProofNonce(
9771037
httpClient: HttpClient,
9781038
issuerMetadata: CredentialIssuerMetadata,
979-
): String? = issuerMetadata.nonceEndpoint
980-
?.let {
981-
NonceRequestBuilder(httpClient).requestNonce(it).cNonce
982-
}
1039+
): String? = requestProofNonce(httpClient, issuerMetadata.nonceEndpoint)
1040+
1041+
private suspend fun requestProofNonce(
1042+
httpClient: HttpClient,
1043+
nonceEndpoint: String?,
1044+
): String? = nonceEndpoint?.let {
1045+
NonceRequestBuilder(httpClient).requestNonce(it).cNonce
1046+
}
9831047

9841048
private fun shouldPreferJwkBinding(
9851049
methods: Set<CryptographicBindingMethod>?
@@ -1220,24 +1284,28 @@ object WalletIssuanceHandler {
12201284
"Provided nonce endpoint does not match credential issuer metadata"
12211285
}
12221286
}
1223-
val cNonce = requestProofNonce(httpClient, issuerMetadata)
12241287
val proofBuilder = JwtProofBuilder()
1225-
val proofs = proofBuilder.buildProof(key, credentialIssuerBaseUrl, cNonce, did)
1226-
onEvent(WalletSessionEvent.issuance_proof_signed)
1227-
1228-
// Fetch credential
1229-
val fetchResult = fetchCredential(
1230-
FetchCredentialRequest(
1288+
val credentialResponse = requestCredentialWithNonceRetry(
1289+
request = FetchCredentialRequest(
12311290
credentialEndpoint = credentialEndpoint,
12321291
accessToken = tokenResult.accessToken,
12331292
credentialConfigurationId = credentialConfigurationId,
1234-
proofJwt = proofs.jwt?.firstOrNull(),
1235-
clientId = clientId
1236-
)
1293+
clientId = clientId,
1294+
),
1295+
nonceEndpoint = issuerMetadata.nonceEndpoint,
1296+
httpClient = httpClient,
1297+
buildProof = { nonce ->
1298+
proofBuilder.buildProof(key, credentialIssuerBaseUrl, nonce, did).jwt?.firstOrNull()
1299+
},
1300+
onProofGenerated = { onEvent(WalletSessionEvent.issuance_proof_signed) },
12371301
)
12381302
onEvent(WalletSessionEvent.issuance_credential_received)
12391303

1240-
for (rawString in fetchResult.rawCredentials) {
1304+
val rawCredentials = credentialResponse.credentials
1305+
?.map { it.credential.let { credential -> if (credential is JsonPrimitive) credential.content else credential.toString() } }
1306+
?: error("Credential response contained no credentials")
1307+
1308+
for (rawString in rawCredentials) {
12411309
val (_, parsed) = CredentialParser.detectAndParse(rawString)
12421310
val entry = StoredCredential(
12431311
id = Uuid.random().toString(),

0 commit comments

Comments
 (0)