@@ -5,6 +5,8 @@ import id.walt.crypto.keys.DirectSerializedKey
55import id.walt.crypto.keys.Key
66import id.walt.openid4vci.CryptographicBindingMethod
77import id.walt.openid4vci.clientauth.ClientAuthenticationMethods
8+ import id.walt.openid4vci.errors.CredentialError
9+ import id.walt.openid4vci.errors.CredentialErrorCodes
810import id.walt.openid4vci.metadata.issuer.CredentialIssuerMetadata
911import id.walt.openid4vci.metadata.oauth.AuthorizationServerMetadata
1012import 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