Skip to content

Commit e083669

Browse files
committed
fix(openid4vci): align wallet nonce handling with 1.0
1 parent c70da10 commit e083669

16 files changed

Lines changed: 635 additions & 86 deletions

File tree

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -585,6 +585,15 @@ object Wallet2RouteHandler {
585585
)
586586
}
587587

588+
post("/request-nonce", {
589+
summary = "Isolated: obtain a fresh credential proof nonce"
590+
request { pathParameter<String>("walletId"); body<RequestNonceRequest>() }
591+
response { HttpStatusCode.OK to { body<RequestNonceResult>() } }
592+
}) {
593+
val req = call.receive<RequestNonceRequest>()
594+
call.respond(WalletIssuanceHandler.requestNonce(req))
595+
}
596+
588597
post("/sign-proof", {
589598
summary = "Isolated: sign a proof-of-possession JWT"
590599
request { pathParameter<String>("walletId"); body<SignProofRequest>() }

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

Lines changed: 140 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import id.waltid.openid4vci.wallet.attestation.ClientAttestationAssembler
2222
import id.waltid.openid4vci.wallet.attestation.ClientAttestationHeaders
2323
import id.waltid.openid4vci.wallet.metadata.IssuerMetadataResolver
2424
import id.waltid.openid4vci.wallet.metadata.OfferedCredentialResolver
25+
import id.waltid.openid4vci.wallet.nonce.NonceRequestBuilder
2526
import id.waltid.openid4vci.wallet.oauth.ClientConfiguration
2627
import id.waltid.openid4vci.wallet.offer.CredentialOfferParser
2728
import 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
204206
data 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
211227
data 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

Comments
 (0)