Skip to content

Commit 0554a19

Browse files
extend base wallet modules and wallet-api2 to support stateless isolated flows which mirror the mobile functionality
1 parent d932d32 commit 0554a19

9 files changed

Lines changed: 713 additions & 77 deletions

File tree

waltid-libraries/protocols/waltid-openid4vc-wallet-server/README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ routing {
127127
| Method | Path | Description |
128128
|--------|------|-------------|
129129
| `POST` | `/wallet/{walletId}/credentials/receive` | Full pre-authorized code flow |
130-
| `POST` | `/wallet/{walletId}/credentials/receive/resolve-offer` | Isolated: resolve offer |
130+
| `POST` | `/wallet/{walletId}/credentials/receive/resolve-offer` | Isolated: resolve offer (enriched with issuer/credential display metadata) |
131131
| `POST` | `/wallet/{walletId}/credentials/receive/request-token` | Isolated: exchange code for token |
132132
| `POST` | `/wallet/{walletId}/credentials/receive/sign-proof` | Isolated: sign proof-of-possession |
133133
| `POST` | `/wallet/{walletId}/credentials/receive/fetch-credential` | Isolated: fetch credential |
@@ -144,6 +144,10 @@ routing {
144144
| `POST` | `/wallet/{walletId}/credentials/present/resolve-request` | Isolated: resolve VP request |
145145
| `POST` | `/wallet/{walletId}/credentials/present/match-credentials` | Isolated: DCQL match inline credentials |
146146
| `POST` | `/wallet/{walletId}/credentials/present/match-credentials-from-store` | DCQL match from wallet stores |
147+
| `POST` | `/wallet/{walletId}/credentials/present/preview` | Stateless consent preview (returns authorizationRequest) |
148+
| `POST` | `/wallet/{walletId}/credentials/present/build-vp-token` | Build signed VP from selections |
149+
| `POST` | `/wallet/{walletId}/credentials/present/send-response` | Send authorization response to verifier |
150+
| `POST` | `/wallet/{walletId}/credentials/present/reject` | Reject via requestUrl (no preview handle) |
147151

148152
### Named Store Management
149153

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

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ import id.walt.wallet2.data.*
88
import id.walt.wallet2.handlers.*
99
import id.walt.verifier.openid.transactiondata.TransactionDataTypeRegistry
1010
import id.walt.wallet2.server.WalletResolver
11+
import id.walt.wallet2.server.models.PresentationPreviewResponse
12+
import id.walt.wallet2.server.models.ResolveOfferDetailedResponse
13+
import id.walt.wallet2.server.models.toDetailedResponse
14+
import id.walt.wallet2.server.models.toPreviewResponse
1115
import id.walt.wallet2.server.openapi.Wallet2OpenApiDocs
1216
import id.waltid.openid4vp.wallet.WalletPresentFunctionality2.WalletPresentResult
1317
import id.walt.wallet2.stores.inmemory.InMemoryCredentialStore
@@ -563,11 +567,16 @@ object Wallet2RouteHandler {
563567

564568
post("/resolve-offer", {
565569
summary = "Isolated: resolve a credential offer"
570+
description =
571+
"Resolves the credential offer and returns grant/endpoint summary together with " +
572+
"issuer display metadata and offered-credential display/claims for a " +
573+
"consent-first UI. Does not retain a preview handle; complete issuance by " +
574+
"re-sending the offer to the pre-authorized or authorization-code endpoints."
566575
request { pathParameter<String>("walletId"); body<ResolveOfferRequest>() }
567-
response { HttpStatusCode.OK to { body<ResolveOfferResult>() } }
576+
response { HttpStatusCode.OK to { body<ResolveOfferDetailedResponse>() } }
568577
}) {
569578
val req = call.receive<ResolveOfferRequest>()
570-
call.respond(WalletIssuanceHandler.resolveOffer(req))
579+
call.respond(WalletIssuanceHandler.resolveOfferDetailed(req).toDetailedResponse())
571580
}
572581

573582
post("/request-token", {
@@ -757,10 +766,9 @@ object Wallet2RouteHandler {
757766
post("/build-vp-token", {
758767
summary = "Build VP token from selected credentials"
759768
description =
760-
"Step 3 of the manual presentation flow. " +
761-
"Takes the resolved authorization request (from /resolve-request) and the " +
762-
"credential IDs selected by the user (from /match-credentials-from-store), " +
763-
"then builds and signs the vp_token. " +
769+
"Builds and signs the vp_token from an echoed authorizationRequest and the " +
770+
"credential options (and optional claim disclosures) selected after /preview, " +
771+
"or from selectedCredentialIds after /match-credentials-from-store. " +
764772
"Pass the result to /send-response to complete the flow."
765773
request { pathParameter<String>("walletId"); body<BuildVpTokenRequest>() }
766774
response { HttpStatusCode.OK to { body<BuildVpTokenResult>() } }
@@ -782,6 +790,40 @@ object Wallet2RouteHandler {
782790
val req = call.receive<SendAuthorizationResponseRequest>()
783791
call.respond(WalletPresentationHandler.sendAuthorizationResponse(req))
784792
}
793+
794+
post("/preview", {
795+
summary = "Preview an OpenID4VP request for consent"
796+
description =
797+
"Resolves and validates the VP request and returns the authorization request, " +
798+
"verifier metadata, transaction data, response-encryption state, and the wallet " +
799+
"credentials that satisfy each DCQL query with their per-claim selective-disclosure " +
800+
"options. Stateless: no preview handle is retained. Complete with /build-vp-token " +
801+
"+ /send-response, or /reject with the original requestUrl. If the request is " +
802+
"invalid but a response can still be sent, the result has valid=false and an error."
803+
request { pathParameter<String>("walletId"); body<PreviewPresentationRequest>() }
804+
response { HttpStatusCode.OK to { body<PresentationPreviewResponse>() } }
805+
}) {
806+
val wallet = call.resolveOrRespond(resolver, getAccountId) ?: return@post
807+
val req = call.receive<PreviewPresentationRequest>()
808+
val result = WalletPresentationHandler.previewPresentationStateless(
809+
wallet = wallet,
810+
request = req,
811+
transactionDataTypeRegistry = TransactionDataTypeRegistry(emptySet()),
812+
)
813+
call.respond(result.toPreviewResponse())
814+
}
815+
816+
post("/reject", {
817+
summary = "Reject a presentation request"
818+
description =
819+
"Re-resolves the OpenID4VP request from requestUrl and returns the wallet's error " +
820+
"response to the verifier when a response channel is available. No preview handle."
821+
request { pathParameter<String>("walletId"); body<RejectPresentationByRequestUrlRequest>() }
822+
response { HttpStatusCode.OK to { body<WalletPresentResult>() } }
823+
}) {
824+
val req = call.receive<RejectPresentationByRequestUrlRequest>()
825+
call.respond(WalletPresentationHandler.rejectPresentationByRequestUrl(req))
826+
}
785827
}
786828
}
787829
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package id.walt.wallet2.server.models
2+
3+
/**
4+
* BCP 47 aware selection of the best entry from a list, given the caller's preferred locales.
5+
* Falls back to the first locale-less entry, then to the first entry.
6+
*/
7+
internal fun <T> List<T>?.selectPreferredByLocale(
8+
preferredLocales: List<String>,
9+
locale: (T) -> String?,
10+
): T? {
11+
val entries = this.orEmpty()
12+
if (entries.isEmpty()) return null
13+
val preferences = preferredLocales.mapNotNull(::normalizeLocale).distinct()
14+
15+
preferences.forEach { preferred ->
16+
localeLookupTags(preferred).forEach { candidate ->
17+
entries.firstOrNull { normalizeLocale(locale(it)) == candidate }?.let { return it }
18+
}
19+
}
20+
return entries.firstOrNull { locale(it).isNullOrBlank() } ?: entries.first()
21+
}
22+
23+
internal fun normalizeLocale(locale: String?): String? = locale
24+
?.trim()
25+
?.replace('_', '-')
26+
?.lowercase()
27+
?.takeIf { it.isNotEmpty() }
28+
29+
internal fun localeLookupTags(locale: String): List<String> = buildList {
30+
val subtags = locale.split('-').filter(String::isNotEmpty).toMutableList()
31+
while (subtags.isNotEmpty()) {
32+
add(subtags.joinToString("-"))
33+
subtags.removeAt(subtags.lastIndex)
34+
if (subtags.lastOrNull()?.length == 1) subtags.removeAt(subtags.lastIndex)
35+
}
36+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
@file:OptIn(kotlinx.serialization.ExperimentalSerializationApi::class)
2+
3+
package id.walt.wallet2.server.models
4+
5+
import id.walt.verifier.openid.models.authorization.AuthorizationRequest
6+
import id.walt.verifier.openid.models.authorization.ClientMetadata
7+
import id.walt.wallet2.handlers.StatelessPreviewPresentationResult
8+
import id.waltid.openid4vp.wallet.response.ResponseEncryption
9+
import kotlinx.serialization.Serializable
10+
import kotlinx.serialization.json.JsonElement
11+
import kotlinx.serialization.json.JsonObject
12+
13+
/**
14+
* UI-facing result of resolving and validating an OpenID4VP request for consent review.
15+
*
16+
* Stateless: no preview handle is retained server-side. The caller holds [authorizationRequest]
17+
* and completes the flow with `credentials/present/build-vp-token` + `send-response`, or rejects
18+
* via `credentials/present/reject` with the original `requestUrl`.
19+
*
20+
* When [valid] is false, the detected [error] can be returned to the verifier via reject.
21+
*/
22+
@Serializable
23+
data class PresentationPreviewResponse(
24+
val authorizationRequest: AuthorizationRequest,
25+
val valid: Boolean,
26+
val clientId: String? = null,
27+
val verifier: PreviewVerifierMetadata? = null,
28+
val responseUri: String? = null,
29+
val state: String? = null,
30+
val nonce: String? = null,
31+
val responseEncryption: PreviewResponseEncryption? = null,
32+
val transactionData: List<PreviewTransactionDataItem> = emptyList(),
33+
val credentialOptions: List<PreviewCredentialOption> = emptyList(),
34+
val credentialRequirements: List<PreviewCredentialRequirement> = emptyList(),
35+
val error: PreviewError? = null,
36+
)
37+
38+
/** Localized verifier display metadata; supplied by the verifier and not on its own evidence of trust. */
39+
@Serializable
40+
data class PreviewVerifierMetadata(
41+
val name: String? = null,
42+
val locale: String? = null,
43+
val logoUri: String? = null,
44+
val clientUri: String? = null,
45+
val policyUri: String? = null,
46+
val termsOfServiceUri: String? = null,
47+
)
48+
49+
/** Response-encryption state selected for an OpenID4VP presentation request. */
50+
@Serializable
51+
data class PreviewResponseEncryption(
52+
val required: Boolean,
53+
val keyManagementAlgorithm: String? = null,
54+
val contentEncryptionAlgorithm: String? = null,
55+
val verifierKeyId: String? = null,
56+
val verifierKeyThumbprint: String? = null,
57+
)
58+
59+
/** A decoded transaction_data item attached to a presentation request. */
60+
@Serializable
61+
data class PreviewTransactionDataItem(
62+
val type: String,
63+
val credentialQueryIds: List<String> = emptyList(),
64+
val rawJson: JsonObject,
65+
val details: JsonObject,
66+
)
67+
68+
/** A wallet credential that satisfies one DCQL credential query in the presentation request. */
69+
@Serializable
70+
data class PreviewCredentialOption(
71+
val queryId: String,
72+
val credentialId: String,
73+
val multiple: Boolean = false,
74+
val format: String,
75+
val issuer: String? = null,
76+
val subject: String? = null,
77+
val label: String? = null,
78+
val credentialData: JsonObject,
79+
val disclosures: List<PreviewCredentialDisclosure> = emptyList(),
80+
)
81+
82+
/** A claim value that may be shared for a matched credential, with selective-disclosure flags. */
83+
@Serializable
84+
data class PreviewCredentialDisclosure(
85+
val path: String,
86+
val name: String? = null,
87+
val value: JsonElement,
88+
val selectivelyDisclosable: Boolean,
89+
val required: Boolean,
90+
val selectable: Boolean,
91+
)
92+
93+
/** A required DCQL credential-query combination; at least one option must be satisfied. */
94+
@Serializable
95+
data class PreviewCredentialRequirement(
96+
val options: List<List<String>>,
97+
)
98+
99+
/** Standard OpenID4VP error detected by the wallet while validating a request. */
100+
@Serializable
101+
data class PreviewError(
102+
val code: String,
103+
val message: String? = null,
104+
)
105+
106+
/**
107+
* Projects a [StatelessPreviewPresentationResult] into the serializable [PresentationPreviewResponse].
108+
*
109+
* @param preferredLocales BCP 47 language tags used to pick localized verifier display values.
110+
*/
111+
fun StatelessPreviewPresentationResult.toPreviewResponse(
112+
preferredLocales: List<String> = emptyList(),
113+
): PresentationPreviewResponse = when (this) {
114+
is StatelessPreviewPresentationResult.Invalid -> PresentationPreviewResponse(
115+
authorizationRequest = authorizationRequest,
116+
valid = false,
117+
clientId = authorizationRequest.clientId,
118+
verifier = authorizationRequest.clientMetadata?.toPreviewVerifierMetadata(preferredLocales),
119+
responseUri = authorizationRequest.responseUri,
120+
state = authorizationRequest.state,
121+
nonce = authorizationRequest.nonce,
122+
error = PreviewError(code = error.code.code, message = error.message),
123+
)
124+
125+
is StatelessPreviewPresentationResult.Ready -> PresentationPreviewResponse(
126+
authorizationRequest = authorizationRequest,
127+
valid = true,
128+
clientId = authorizationRequest.clientId,
129+
verifier = authorizationRequest.clientMetadata?.toPreviewVerifierMetadata(preferredLocales),
130+
responseUri = authorizationRequest.responseUri,
131+
state = authorizationRequest.state,
132+
nonce = authorizationRequest.nonce,
133+
responseEncryption = responseEncryption.toPreviewResponseEncryption(),
134+
transactionData = transactionData.map { item ->
135+
PreviewTransactionDataItem(
136+
type = item.type,
137+
credentialQueryIds = item.credentialQueryIds,
138+
rawJson = item.rawJson,
139+
details = item.details,
140+
)
141+
},
142+
credentialOptions = credentialOptions.map { option ->
143+
PreviewCredentialOption(
144+
queryId = option.queryId,
145+
credentialId = option.credentialId,
146+
multiple = option.multiple,
147+
format = option.format,
148+
issuer = option.issuer,
149+
subject = option.subject,
150+
label = option.label,
151+
credentialData = option.credentialData,
152+
disclosures = option.disclosures.map { disclosure ->
153+
PreviewCredentialDisclosure(
154+
path = disclosure.path,
155+
name = disclosure.name,
156+
value = disclosure.value,
157+
selectivelyDisclosable = disclosure.selectivelyDisclosable,
158+
required = disclosure.required,
159+
selectable = disclosure.selectable,
160+
)
161+
},
162+
)
163+
},
164+
credentialRequirements = credentialRequirements.map {
165+
PreviewCredentialRequirement(options = it.options)
166+
},
167+
)
168+
}
169+
170+
private fun ClientMetadata.toPreviewVerifierMetadata(
171+
preferredLocales: List<String>,
172+
): PreviewVerifierMetadata {
173+
val name = selectLocalizedValue(clientName, clientNameI18n, preferredLocales)
174+
val logo = selectLocalizedValue(logoUri, logoUriI18n, preferredLocales)
175+
return PreviewVerifierMetadata(
176+
name = name.value,
177+
locale = if (name.value != null) name.locale else logo.locale,
178+
logoUri = logo.value,
179+
clientUri = selectLocalizedValue(clientUri, clientUriI18n, preferredLocales).value,
180+
policyUri = selectLocalizedValue(policyUri, policyUriI18n, preferredLocales).value,
181+
termsOfServiceUri = selectLocalizedValue(tosUri, tosUriI18n, preferredLocales).value,
182+
)
183+
}
184+
185+
private fun ResponseEncryption.Metadata?.toPreviewResponseEncryption(): PreviewResponseEncryption =
186+
this?.let {
187+
PreviewResponseEncryption(
188+
required = true,
189+
keyManagementAlgorithm = keyManagementAlgorithm,
190+
contentEncryptionAlgorithm = contentEncryptionAlgorithm,
191+
verifierKeyId = verifierKeyId,
192+
verifierKeyThumbprint = verifierKeyThumbprint,
193+
)
194+
} ?: PreviewResponseEncryption(required = false)
195+
196+
// ---------------------------------------------------------------------------
197+
// Localized value selection (base value + language-tagged map)
198+
// ---------------------------------------------------------------------------
199+
200+
private data class LocalizedValue(val locale: String?, val value: String?)
201+
202+
private fun selectLocalizedValue(
203+
base: String?,
204+
localized: Map<String, String>,
205+
preferredLocales: List<String>,
206+
): LocalizedValue = buildList {
207+
base?.takeIf { it.isNotBlank() }?.let { add(LocalizedValue(locale = null, value = it)) }
208+
localized.forEach { (locale, value) ->
209+
value.takeIf { it.isNotBlank() }?.let { add(LocalizedValue(locale = locale, value = it)) }
210+
}
211+
}.selectPreferredByLocale(preferredLocales) { it.locale } ?: LocalizedValue(locale = null, value = null)

0 commit comments

Comments
 (0)