@@ -22,6 +22,7 @@ import id.waltid.openid4vci.wallet.attestation.ClientAttestationAssembler
2222import id.waltid.openid4vci.wallet.attestation.ClientAttestationHeaders
2323import id.waltid.openid4vci.wallet.metadata.IssuerMetadataResolver
2424import id.waltid.openid4vci.wallet.metadata.OfferedCredentialResolver
25+ import id.waltid.openid4vci.wallet.nonce.NonceRequestBuilder
2526import id.waltid.openid4vci.wallet.oauth.ClientConfiguration
2627import id.waltid.openid4vci.wallet.offer.CredentialOfferParser
2728import id.waltid.openid4vci.wallet.offer.CredentialOfferResolver
@@ -169,6 +170,7 @@ data class ResolveOfferResult(
169170 val credentialEndpoint : Url ,
170171 val offeredCredentials : List <String >,
171172 val tokenEndpoint : Url ? = null ,
173+ val nonceEndpoint : Url ? = null ,
172174)
173175
174176/* *
@@ -203,14 +205,28 @@ data class RequestTokenRequest(
203205@Serializable
204206data class RequestTokenResult (
205207 val accessToken : String ,
206- val cNonce : String? = null ,
207208 val expiresIn : Long? = null
209+ ) {
210+ override fun toString (): String =
211+ " RequestTokenResult(accessToken=<redacted>, expiresIn=$expiresIn )"
212+ }
213+
214+ @Serializable
215+ data class RequestNonceRequest (
216+ val credentialIssuer : Url ,
208217)
209218
219+ @Serializable
220+ data class RequestNonceResult (
221+ val nonce : String? ,
222+ ) {
223+ override fun toString (): String = " RequestNonceResult(nonce=<redacted>)"
224+ }
225+
210226@Serializable
211227data class SignProofRequest (
212228 val issuerUrl : Url ,
213- val nonce : String ,
229+ val nonce : String? = null ,
214230 /* * Inline key to sign the proof with; takes precedence over [keyId]. */
215231 val key : DirectSerializedKey ? = null ,
216232 val keyId : String? = null ,
@@ -273,6 +289,7 @@ data class GenerateAuthorizationUrlResult(
273289 val codeVerifier : String? = null ,
274290 val credentialConfigurationId : String ,
275291 val credentialIssuerBaseUrl : String ,
292+ val nonceEndpoint : Url ? = null ,
276293)
277294
278295@Serializable
@@ -345,7 +362,7 @@ object WalletIssuanceHandler {
345362 * Builds a proof JWT, choosing DID-based binding when [did] is non-null, or JWK-inclusion
346363 * otherwise. Extracted to eliminate three identical `if (did != null)` proof-building blocks.
347364 */
348- private suspend fun JwtProofBuilder.buildProof (key : Key , issuer : String , nonce : String , did : String? ) =
365+ private suspend fun JwtProofBuilder.buildProof (key : Key , issuer : String , nonce : String? , did : String? ) =
349366 if (did != null ) buildJwtProof(key, issuer, nonce, keyId = did)
350367 else buildJwtProof(key, issuer, nonce, includeJwk = true )
351368
@@ -371,6 +388,24 @@ object WalletIssuanceHandler {
371388 * [transactionId] should be stored and passed to [pollDeferredFlow] later.
372389 */
373390 onDeferredTransactionId : suspend (credentialConfigurationId: String , transactionId: String ) -> Unit = { _, _ -> },
391+ ): Flow <StoredCredential > = receiveCredentialFlowInternal(
392+ wallet = wallet,
393+ request = request,
394+ attestationAssembler = attestationAssembler,
395+ onEvent = onEvent,
396+ httpClient = httpClient,
397+ onDeferredTransactionId = onDeferredTransactionId,
398+ allowInsecureHttpForTests = false ,
399+ )
400+
401+ private fun receiveCredentialFlowInternal (
402+ wallet : Wallet ,
403+ request : ReceiveCredentialRequest ,
404+ attestationAssembler : ClientAttestationAssembler ? ,
405+ onEvent : suspend (WalletSessionEvent ) -> Unit ,
406+ httpClient : HttpClient ,
407+ onDeferredTransactionId : suspend (credentialConfigurationId: String , transactionId: String ) -> Unit ,
408+ allowInsecureHttpForTests : Boolean ,
374409 ): Flow <StoredCredential > = channelFlow {
375410 val key = wallet.resolveKey(request.key, request.keyId)
376411 ? : error(" No key available: wallet has no keyStores, no staticKey, no inline key, and no keyId was specified" )
@@ -433,25 +468,18 @@ object WalletIssuanceHandler {
433468 attestationHeaders = attestationHeaders,
434469 anonymous = anonymousPreAuthorizedCode,
435470 )
436- log.trace { " Token obtained, c_nonce= ${tokenResponse.c_nonce} " }
471+ log.trace { " Token obtained" }
437472 onEvent(WalletSessionEvent .issuance_token_obtained)
438473
439- // 5. Fetch c_nonce
440- val cNonce = issuerMetadata.nonceEndpoint
441- ?.let { fetchCNonce(httpClient, it) }
442- ? : tokenResponse.c_nonce
443- log.trace { " Using nonce: $cNonce (from ${if (issuerMetadata.nonceEndpoint != null ) " nonce endpoint" else " token response" } )" }
444-
445474 val credentialEndpoint = issuerMetadata.credentialEndpoint
446475 log.trace { " Credential endpoint: $credentialEndpoint " }
447476
448- // 6 . Issue each offered credential
477+ // 5 . Issue each offered credential with a fresh nonce when proof is required.
449478 for (offeredCredential in offeredCredentials) {
450479 log.trace { " Issuing credential configId=${offeredCredential.credentialConfigurationId} , format=${offeredCredential.configuration.format} " }
451480 val proofs = if (offeredCredential.configuration.proofTypesSupported?.isNotEmpty() == true ) {
452- val nonce = cNonce
453- ? : error(" Issuer requires proof but did not provide a c_nonce" )
454- log.trace { " Building proof JWT, did=$did , nonce=$nonce " }
481+ val nonce = requestProofNonce(httpClient, issuerMetadata, allowInsecureHttpForTests)
482+ log.trace { " Building credential proof JWT" }
455483 val preferJwkBinding =
456484 shouldPreferJwkBinding(offeredCredential.configuration.cryptographicBindingMethodsSupported)
457485 if (did != null && ! preferJwkBinding) {
@@ -460,7 +488,7 @@ object WalletIssuanceHandler {
460488 proofBuilder.buildJwtProof(key, offer.credentialIssuer, nonce, includeJwk = true )
461489 }
462490 } else null
463- log.trace { " Proof JWT present: ${proofs?.jwt?.firstOrNull()?.take( 40 )} ... " }
491+ log.trace { " Proof JWT present: ${proofs?.jwt?.isNotEmpty() == true } " }
464492 onEvent(WalletSessionEvent .issuance_proof_signed)
465493
466494 // Build the credential request JSON manually to ensure proofs serializes correctly.
@@ -529,16 +557,37 @@ object WalletIssuanceHandler {
529557 attestationAssembler : ClientAttestationAssembler ? = null,
530558 onEvent : suspend (WalletSessionEvent ) -> Unit = {},
531559 httpClient : HttpClient = defaultHttpClient()
560+ ): ReceiveCredentialResult = receiveCredential(
561+ wallet = wallet,
562+ request = request,
563+ attestationAssembler = attestationAssembler,
564+ onEvent = onEvent,
565+ httpClient = httpClient,
566+ allowInsecureHttpForTests = false ,
567+ )
568+
569+ /* *
570+ * Test-only variant for local issuer fixtures that cannot expose an HTTPS nonce endpoint.
571+ * Production callers must use the strict overload above.
572+ */
573+ suspend fun receiveCredential (
574+ wallet : Wallet ,
575+ request : ReceiveCredentialRequest ,
576+ attestationAssembler : ClientAttestationAssembler ? = null,
577+ onEvent : suspend (WalletSessionEvent ) -> Unit = {},
578+ httpClient : HttpClient = defaultHttpClient(),
579+ allowInsecureHttpForTests : Boolean ,
532580 ): ReceiveCredentialResult {
533581 val ids = mutableListOf<String >()
534582 val deferredIds = mutableMapOf<String , String >()
535- receiveCredentialFlow(
536- wallet,
537- request,
538- attestationAssembler,
539- onEvent,
540- httpClient,
541- onDeferredTransactionId = { configId, txId -> deferredIds[configId] = txId }
583+ receiveCredentialFlowInternal(
584+ wallet = wallet,
585+ request = request,
586+ attestationAssembler = attestationAssembler,
587+ onEvent = onEvent,
588+ httpClient = httpClient,
589+ onDeferredTransactionId = { configId, txId -> deferredIds[configId] = txId },
590+ allowInsecureHttpForTests = allowInsecureHttpForTests,
542591 ).collect { ids + = it.id }
543592 return ReceiveCredentialResult (credentialIds = ids, deferredTransactionIds = deferredIds)
544593 }
@@ -590,6 +639,7 @@ object WalletIssuanceHandler {
590639 tokenEndpoint = asMetadata.tokenEndpoint?.let { Url (it) },
591640 credentialEndpoint = Url (issuerMetadata.credentialEndpoint),
592641 offeredCredentials = offeredCredentials.map { it.credentialConfigurationId },
642+ nonceEndpoint = issuerMetadata.nonceEndpoint?.let { Url (it) },
593643 ),
594644 offer = offer,
595645 issuerMetadata = issuerMetadata,
@@ -690,11 +740,19 @@ object WalletIssuanceHandler {
690740 )
691741 return RequestTokenResult (
692742 accessToken = tokenResponse.access_token,
693- cNonce = tokenResponse.c_nonce,
694743 expiresIn = tokenResponse.expires_in
695744 )
696745 }
697746
747+ suspend fun requestNonce (
748+ request : RequestNonceRequest ,
749+ httpClient : HttpClient = defaultHttpClient(),
750+ ): RequestNonceResult {
751+ val issuerMetadata = IssuerMetadataResolver (httpClient)
752+ .resolveCredentialIssuerMetadata(request.credentialIssuer.toString())
753+ return RequestNonceResult (nonce = requestProofNonce(httpClient, issuerMetadata))
754+ }
755+
698756 suspend fun signProof (wallet : Wallet , request : SignProofRequest ): SignProofResult {
699757 val key = wallet.resolveKey(request.key, request.keyId)
700758 ? : error(" No key available for signing proof" )
@@ -832,16 +890,17 @@ object WalletIssuanceHandler {
832890 return response
833891 }
834892
835- private suspend fun fetchCNonce (httpClient : HttpClient , nonceEndpoint : String ): String? =
836- runCatching {
837- val response = postFollowingRedirects(httpClient, nonceEndpoint) {
838- contentType(ContentType .Application .Json )
839- }
840- if (response.status.isSuccess()) {
841- response.body<JsonObject >()[" c_nonce" ]
842- ?.let { (it as ? JsonPrimitive )?.content }
843- } else null
844- }.getOrNull()
893+ private suspend fun requestProofNonce (
894+ httpClient : HttpClient ,
895+ issuerMetadata : CredentialIssuerMetadata ,
896+ allowInsecureHttpForTests : Boolean = false,
897+ ): String? = issuerMetadata.nonceEndpoint
898+ ?.let {
899+ NonceRequestBuilder (
900+ httpClient = httpClient,
901+ allowInsecureHttpForTests = allowInsecureHttpForTests,
902+ ).requestNonce(it).cNonce
903+ }
845904
846905 private fun shouldPreferJwkBinding (
847906 methods : Set <CryptographicBindingMethod >?
@@ -895,6 +954,7 @@ object WalletIssuanceHandler {
895954 codeVerifier = authRequest.pkceData?.codeVerifier,
896955 credentialConfigurationId = credentialConfigurationId,
897956 credentialIssuerBaseUrl = offer.credentialIssuer,
957+ nonceEndpoint = issuerMetadata.nonceEndpoint?.let { Url (it) },
898958 )
899959 }
900960
@@ -962,7 +1022,6 @@ object WalletIssuanceHandler {
9621022 )
9631023 return RequestTokenResult (
9641024 accessToken = tokenResponse.access_token,
965- cNonce = tokenResponse.c_nonce,
9661025 expiresIn = tokenResponse.expires_in
9671026 )
9681027 }
@@ -1052,6 +1111,44 @@ object WalletIssuanceHandler {
10521111 attestationAssembler : ClientAttestationAssembler ? = null,
10531112 onEvent : suspend (WalletSessionEvent ) -> Unit = {},
10541113 httpClient : HttpClient = defaultHttpClient()
1114+ ): Flow <StoredCredential > = receiveCredentialAuthCodeFlow(
1115+ wallet = wallet,
1116+ code = code,
1117+ codeVerifier = codeVerifier,
1118+ credentialIssuerBaseUrl = credentialIssuerBaseUrl,
1119+ credentialEndpoint = credentialEndpoint,
1120+ credentialConfigurationId = credentialConfigurationId,
1121+ nonceEndpoint = nonceEndpoint,
1122+ clientId = clientId,
1123+ redirectUri = redirectUri,
1124+ inlineKey = inlineKey,
1125+ inlineDid = inlineDid,
1126+ attestationAssembler = attestationAssembler,
1127+ onEvent = onEvent,
1128+ httpClient = httpClient,
1129+ allowInsecureHttpForTests = false ,
1130+ )
1131+
1132+ /* *
1133+ * Test-only variant for local issuer fixtures that cannot expose an HTTPS nonce endpoint.
1134+ * Production callers must use the strict overload above.
1135+ */
1136+ fun receiveCredentialAuthCodeFlow (
1137+ wallet : Wallet ,
1138+ code : String ,
1139+ codeVerifier : String? ,
1140+ credentialIssuerBaseUrl : String ,
1141+ credentialEndpoint : Url ,
1142+ credentialConfigurationId : String ,
1143+ nonceEndpoint : String? = null,
1144+ clientId : String = DEFAULT_CLIENT_ID ,
1145+ redirectUri : Url = Url ("openid://"),
1146+ inlineKey : DirectSerializedKey ? = null,
1147+ inlineDid : String? = null,
1148+ attestationAssembler : ClientAttestationAssembler ? = null,
1149+ onEvent : suspend (WalletSessionEvent ) -> Unit = {},
1150+ httpClient : HttpClient = defaultHttpClient(),
1151+ allowInsecureHttpForTests : Boolean ,
10551152 ): Flow <StoredCredential > = channelFlow {
10561153 val key = wallet.resolveKey(inlineKey)
10571154 ? : error(" No key available for proof-of-possession" )
@@ -1074,10 +1171,15 @@ object WalletIssuanceHandler {
10741171 )
10751172 onEvent(WalletSessionEvent .issuance_token_obtained)
10761173
1077- // Sign proof — nonce from nonce endpoint or token response (never fall back to access_token)
1078- val cNonce = nonceEndpoint?.let { fetchCNonce(httpClient, it) }
1079- ? : tokenResult.cNonce
1080- ? : error(" Issuer did not provide a c_nonce (neither via nonce endpoint nor token response)" )
1174+ // Resolve issuer metadata again at continuation time and use only its advertised nonce endpoint.
1175+ val issuerMetadata = IssuerMetadataResolver (httpClient)
1176+ .resolveCredentialIssuerMetadata(credentialIssuerBaseUrl)
1177+ nonceEndpoint?.let { expected ->
1178+ require(expected == issuerMetadata.nonceEndpoint) {
1179+ " Provided nonce endpoint does not match credential issuer metadata"
1180+ }
1181+ }
1182+ val cNonce = requestProofNonce(httpClient, issuerMetadata, allowInsecureHttpForTests)
10811183 val proofBuilder = JwtProofBuilder ()
10821184 val proofs = proofBuilder.buildProof(key, credentialIssuerBaseUrl, cNonce, did)
10831185 onEvent(WalletSessionEvent .issuance_proof_signed)
0 commit comments