Skip to content

Commit 55cb7bd

Browse files
committed
fix(openid4vp-wallet): advertise request-uri POST wallet capabilities
1 parent 3441c3a commit 55cb7bd

3 files changed

Lines changed: 168 additions & 19 deletions

File tree

waltid-libraries/protocols/waltid-openid4vp-clientidprefix/src/commonMain/kotlin/id/walt/openid4vp/clientidprefix/ClientIdPrefixParser.kt

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,20 @@ package id.walt.openid4vp.clientidprefix
33
import id.walt.openid4vp.clientidprefix.prefixes.*
44
import io.ktor.http.*
55

6+
enum class ClientIdPrefix(val value: String) {
7+
PRE_REGISTERED("pre-registered"),
8+
REDIRECT_URI("redirect_uri"),
9+
X509_SAN_DNS("x509_san_dns"),
10+
X509_HASH("x509_hash"),
11+
DECENTRALIZED_IDENTIFIER("decentralized_identifier"),
12+
VERIFIER_ATTESTATION("verifier_attestation"),
13+
OPENID_FEDERATION("openid_federation");
14+
15+
companion object {
16+
fun fromValue(value: String): ClientIdPrefix? = entries.find { it.value == value }
17+
}
18+
}
19+
620
object ClientIdPrefixParser {
721
fun parse(clientIdString: String): Result<ClientId> {
822
return runCatching {
@@ -12,16 +26,16 @@ object ClientIdPrefixParser {
1226
} else {
1327
val prefix = parts[0]
1428
val id = parts[1]
15-
when (prefix) {
16-
"redirect_uri" -> RedirectUri(Url(id), clientIdString)
17-
"x509_san_dns" -> X509SanDns(id, clientIdString)
18-
"x509_hash" -> X509Hash(id, clientIdString)
19-
"decentralized_identifier" -> DecentralizedIdentifier(id, clientIdString)
20-
"verifier_attestation" -> VerifierAttestation(id, clientIdString)
21-
"openid_federation" -> OpenIdFederation(id, clientIdString)
29+
when (ClientIdPrefix.fromValue(prefix)) {
30+
ClientIdPrefix.REDIRECT_URI -> RedirectUri(Url(id), clientIdString)
31+
ClientIdPrefix.X509_SAN_DNS -> X509SanDns(id, clientIdString)
32+
ClientIdPrefix.X509_HASH -> X509Hash(id, clientIdString)
33+
ClientIdPrefix.DECENTRALIZED_IDENTIFIER -> DecentralizedIdentifier(id, clientIdString)
34+
ClientIdPrefix.VERIFIER_ATTESTATION -> VerifierAttestation(id, clientIdString)
35+
ClientIdPrefix.OPENID_FEDERATION -> OpenIdFederation(id, clientIdString)
2236

23-
// The 'origin' prefix is reserved and MUST NOT be accepted.
24-
else -> Unsupported(prefix, clientIdString)
37+
// Pre-registered clients are represented by the absence of a prefix.
38+
ClientIdPrefix.PRE_REGISTERED, null -> Unsupported(prefix, clientIdString)
2539
}
2640
}
2741
}

waltid-libraries/protocols/waltid-openid4vp-wallet/src/commonMain/kotlin/id/waltid/openid4vp/wallet/request/AuthorizationRequestResolver.kt

Lines changed: 50 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,16 @@ import id.walt.credentials.utils.JwtUtils.isJwt
44
import id.walt.crypto.utils.UuidUtils
55
import id.walt.crypto.utils.JwsUtils.decodeJws
66
import id.walt.openid4vp.clientidprefix.ClientIdError
7+
import id.walt.openid4vp.clientidprefix.ClientIdPrefix
78
import id.walt.openid4vp.clientidprefix.ClientIdPrefixAuthenticator
89
import id.walt.openid4vp.clientidprefix.ClientIdPrefixParser
910
import id.walt.openid4vp.clientidprefix.ClientValidationResult
1011
import id.walt.openid4vp.clientidprefix.RequestContext
1112
import id.walt.verifier.openid.models.authorization.AuthorizationRequest
1213
import id.walt.verifier.openid.models.authorization.ClientMetadata
1314
import id.walt.verifier.openid.models.authorization.RequestUriHttpMethod
15+
import id.walt.verifier.openid.models.openid.OpenID4VPResponseMode
16+
import id.walt.verifier.openid.models.openid.OpenID4VPResponseType
1417
import id.walt.webdatafetching.WebDataFetcher
1518
import id.waltid.openid4vp.wallet.WalletPresentationFormatRegistry
1619
import io.github.oshai.kotlinlogging.KotlinLogging
@@ -19,7 +22,9 @@ import io.ktor.client.statement.*
1922
import io.ktor.http.*
2023
import kotlinx.serialization.ExperimentalSerializationApi
2124
import kotlinx.serialization.json.Json
25+
import kotlinx.serialization.json.JsonArray
2226
import kotlinx.serialization.json.JsonObject
27+
import kotlinx.serialization.json.JsonPrimitive
2328
import kotlinx.serialization.json.buildJsonObject
2429
import kotlinx.serialization.json.contentOrNull
2530
import kotlinx.serialization.json.jsonPrimitive
@@ -54,16 +59,51 @@ object AuthorizationRequestResolver {
5459
class UnsignedAuthorizationRequestNotAllowedException :
5560
IllegalArgumentException("Unsigned AuthorizationRequest object (alg=none) is not allowed")
5661

57-
private val defaultRequestUriPostWalletMetadata = buildRequestUriPostWalletMetadata(
58-
vpFormatsSupported = WalletPresentationFormatRegistry.buildVpFormatsSupported(),
59-
)
62+
private object RequestUriPostWalletMetadata {
63+
val default: String by lazy {
64+
build(WalletPresentationFormatRegistry.buildVpFormatsSupported())
65+
}
6066

61-
fun buildRequestUriPostWalletMetadata(vpFormatsSupported: JsonObject): String = json.encodeToString(
62-
JsonObject.serializer(),
63-
buildJsonObject {
64-
put("vp_formats_supported", vpFormatsSupported)
65-
},
66-
)
67+
private val unsupportedResponseTypes = setOf(
68+
// Authorization Code flow requires a wallet token endpoint; this flow only submits VP token responses.
69+
OpenID4VPResponseType.CODE,
70+
)
71+
72+
private val responseTypesSupported = (OpenID4VPResponseType.entries - unsupportedResponseTypes)
73+
.map { it.responseType }
74+
75+
// DC API response modes are protocol values, but this wallet flow does not submit through DC API yet.
76+
private val unsupportedResponseModes = OpenID4VPResponseMode.DC_API_RESPONSES
77+
78+
private val responseModesSupported = (OpenID4VPResponseMode.entries - unsupportedResponseModes)
79+
.map { json.encodeToJsonElement(OpenID4VPResponseMode.serializer(), it).jsonPrimitive.content }
80+
81+
private val unsupportedClientIdPrefixes = setOf(
82+
// Pre-registered clients require a verifier metadata provider; this resolver uses the authenticator default.
83+
ClientIdPrefix.PRE_REGISTERED,
84+
// OpenID Federation parsing exists, but trust chain resolution is not implemented yet.
85+
ClientIdPrefix.OPENID_FEDERATION,
86+
)
87+
88+
private val clientIdPrefixesSupported = (ClientIdPrefix.entries - unsupportedClientIdPrefixes)
89+
.map { it.value }
90+
91+
fun build(vpFormatsSupported: JsonObject): String = json.encodeToString(
92+
serializer = JsonObject.serializer(),
93+
value = buildJsonObject {
94+
put("response_types_supported", responseTypesSupported.toJsonArray())
95+
put("response_modes_supported", responseModesSupported.toJsonArray())
96+
put("client_id_prefixes_supported", clientIdPrefixesSupported.toJsonArray())
97+
put("vp_formats_supported", vpFormatsSupported)
98+
},
99+
)
100+
101+
private fun Iterable<String>.toJsonArray(): JsonArray =
102+
JsonArray(map(::JsonPrimitive))
103+
}
104+
105+
fun buildRequestUriPostWalletMetadata(vpFormatsSupported: JsonObject): String =
106+
RequestUriPostWalletMetadata.build(vpFormatsSupported)
67107

68108
/**
69109
* Shared transport mapping for retrieving Authorization Requests via `request_uri`.
@@ -87,7 +127,7 @@ object AuthorizationRequestResolver {
87127
accept(ContentType.parse("application/oauth-authz-req+jwt"))
88128
setBody(
89129
Parameters.build {
90-
append("wallet_metadata", requestUriPostWalletMetadata ?: defaultRequestUriPostWalletMetadata)
130+
append("wallet_metadata", requestUriPostWalletMetadata ?: RequestUriPostWalletMetadata.default)
91131
append("wallet_nonce", requireNotNull(walletNonce))
92132
}.formUrlEncode(),
93133
)

waltid-libraries/protocols/waltid-openid4vp-wallet/src/jvmTest/kotlin/id/waltid/openid4vp/wallet/request/AuthorizationRequestResolverJvmTest.kt

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,104 @@
11
package id.waltid.openid4vp.wallet.request
22

3+
import id.walt.verifier.openid.models.authorization.RequestUriHttpMethod
4+
import id.walt.webdatafetching.WebDataFetcher
5+
import io.ktor.client.HttpClient
6+
import io.ktor.client.engine.mock.MockEngine
7+
import io.ktor.client.engine.mock.respond
8+
import io.ktor.client.request.HttpRequestData
39
import io.ktor.http.URLBuilder
10+
import io.ktor.http.ContentType
11+
import io.ktor.http.HttpHeaders
12+
import io.ktor.http.HttpMethod
13+
import io.ktor.http.content.OutgoingContent
14+
import io.ktor.http.contentType
15+
import io.ktor.http.headersOf
16+
import io.ktor.http.parseQueryString
417
import kotlinx.coroutines.runBlocking
18+
import kotlinx.serialization.json.Json
19+
import kotlinx.serialization.json.jsonArray
20+
import kotlinx.serialization.json.jsonObject
21+
import kotlinx.serialization.json.jsonPrimitive
522
import java.util.Base64
623
import kotlin.test.Test
724
import kotlin.test.assertEquals
825
import kotlin.test.assertFailsWith
26+
import kotlin.test.assertFalse
927
import kotlin.test.assertIs
1028

1129
class AuthorizationRequestResolverJvmTest {
1230

31+
@Test
32+
fun `request uri post wallet metadata declares supported response and client id capabilities`() {
33+
val metadata = Json.parseToJsonElement(
34+
AuthorizationRequestResolver.buildRequestUriPostWalletMetadata(
35+
vpFormatsSupported = jsonObjectOf("dc+sd-jwt" to jsonObjectOf()),
36+
),
37+
).jsonObject
38+
39+
assertEquals(
40+
listOf("vp_token", "vp_token id_token"),
41+
metadata.getValue("response_types_supported").jsonArray.map { it.jsonPrimitive.content },
42+
)
43+
assertEquals(
44+
listOf("fragment", "query", "direct_post", "direct_post.jwt", "form_post"),
45+
metadata.getValue("response_modes_supported").jsonArray.map { it.jsonPrimitive.content },
46+
)
47+
val expectedClientIdPrefixes =
48+
listOf(
49+
"redirect_uri",
50+
"x509_san_dns",
51+
"x509_hash",
52+
"decentralized_identifier",
53+
"verifier_attestation",
54+
)
55+
assertFalse("client_id_schemes_supported" in metadata)
56+
assertEquals(
57+
expectedClientIdPrefixes,
58+
metadata.getValue("client_id_prefixes_supported").jsonArray.map { it.jsonPrimitive.content },
59+
)
60+
assertEquals(
61+
jsonObjectOf("dc+sd-jwt" to jsonObjectOf()),
62+
metadata.getValue("vp_formats_supported").jsonObject,
63+
)
64+
}
65+
66+
@Test
67+
fun `request uri post fetch sends wallet metadata and wallet nonce as form fields`() = runBlocking {
68+
var capturedRequest: HttpRequestData? = null
69+
val client = HttpClient(MockEngine) {
70+
engine {
71+
addHandler { request ->
72+
capturedRequest = request
73+
respond(
74+
content = "signed-request-object",
75+
headers = headersOf(HttpHeaders.ContentType, "application/oauth-authz-req+jwt"),
76+
)
77+
}
78+
}
79+
}
80+
val fetcher = WebDataFetcher.wrapping(client, id = "authorization-request-resolver-test")
81+
val walletMetadata = AuthorizationRequestResolver.buildRequestUriPostWalletMetadata(
82+
vpFormatsSupported = jsonObjectOf("dc+sd-jwt" to jsonObjectOf()),
83+
)
84+
85+
val response = AuthorizationRequestResolver.fetchRequestUriWithWebDataFetcher(
86+
webResolveAuthReq = fetcher,
87+
requestUri = "https://verifier.example/request.jwt",
88+
requestUriMethod = RequestUriHttpMethod.POST,
89+
requestUriPostWalletMetadata = walletMetadata,
90+
)
91+
val request = requireNotNull(capturedRequest)
92+
val form = parseQueryString(request.bodyText())
93+
94+
assertEquals(HttpMethod.Post, request.method)
95+
assertEquals(ContentType.Application.FormUrlEncoded, request.body.contentType)
96+
assertEquals(walletMetadata, form["wallet_metadata"])
97+
assertEquals(response.walletNonce, form["wallet_nonce"])
98+
assertFalse(response.walletNonce.isNullOrBlank())
99+
assertEquals("signed-request-object", response.body)
100+
}
101+
13102
@Test
14103
fun `unsigned request object is rejected when policy requires signed request objects`() {
15104
val requestObject = unsignedJwt(
@@ -71,4 +160,10 @@ class AuthorizationRequestResolverJvmTest {
71160
Base64.getUrlEncoder().withoutPadding().encodeToString(segment.toByteArray())
72161
} + "."
73162
}
163+
164+
private fun jsonObjectOf(vararg pairs: Pair<String, kotlinx.serialization.json.JsonElement>) =
165+
kotlinx.serialization.json.JsonObject(mapOf(*pairs))
166+
167+
private fun HttpRequestData.bodyText(): String =
168+
(body as OutgoingContent.ByteArrayContent).bytes().decodeToString()
74169
}

0 commit comments

Comments
 (0)