Skip to content

Commit 9efad0a

Browse files
committed
Introduced ReceivedRequest abstraction to accommodate single and multi-signed authorization requests.
1 parent 6ccc72a commit 9efad0a

File tree

4 files changed

+143
-48
lines changed

4 files changed

+143
-48
lines changed

src/main/kotlin/eu/europa/ec/eudi/openid4vp/internal/request/DefaultAuthorizationRequestResolver.kt

Lines changed: 86 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@
1616
package eu.europa.ec.eudi.openid4vp.internal.request
1717

1818
import com.eygraber.uri.Uri
19+
import com.nimbusds.jose.JWSObject
20+
import com.nimbusds.jose.JWSObjectJSON
21+
import com.nimbusds.jose.util.Base64URL
22+
import com.nimbusds.jose.util.JSONObjectUtils
1923
import com.nimbusds.jwt.JWTClaimsSet
2024
import com.nimbusds.jwt.SignedJWT
2125
import eu.europa.ec.eudi.openid4vp.*
@@ -171,6 +175,85 @@ internal sealed interface FetchedRequest {
171175
data class JwtSecured(val clientId: String, val jwt: SignedJWT) : FetchedRequest
172176
}
173177

178+
internal sealed interface ReceivedRequest {
179+
180+
data class Unsigned(val requestObject: UnvalidatedRequestObject) : ReceivedRequest
181+
182+
data class Signed(
183+
val payload: Base64URL,
184+
val signatures: List<RequestSignature>,
185+
) : ReceivedRequest {
186+
187+
init {
188+
require(!signatures.isEmpty()) { "At least one signature is required" }
189+
}
190+
191+
companion object {
192+
193+
/**
194+
* Decomposes a Nimbus [SignedJWT] into a [ReceivedRequest.Signed] request.
195+
*/
196+
fun from(signedJwt: SignedJWT): Result<Signed> = runCatching {
197+
require(signedJwt.state == JWSObject.State.SIGNED) { "JWS is not signed" }
198+
val header = signedJwt.header.toBase64URL()
199+
val payload = signedJwt.payload.toBase64URL()
200+
val signature = signedJwt.signature
201+
202+
val signatures = listOf(RequestSignature(Header(header), signature))
203+
Signed(payload, signatures)
204+
}
205+
206+
/**
207+
* Parses an input [JsonObject] representing a JWS in JSON serialization into a [ReceivedRequest.Signed] request.
208+
*/
209+
fun from(jwsJsonObject: JsonObject): Result<Signed> = runCatching {
210+
require(jwsJsonObject.containsKey("payload")) { "No payload found for the passed request" }
211+
require(jwsJsonObject.containsKey("signatures") || jwsJsonObject.containsKey("signature")) {
212+
"No signatures found for the passed request"
213+
}
214+
val parsed = JWSObjectJSON.parse(jwsJsonObject)
215+
val signatures = parsed.getSignatures()
216+
val requestSignatures = signatures?.map {
217+
val unprotectedHeader = it.unprotectedHeader?.let {
218+
val str = JSONObjectUtils.toJSONString(it.toJSONObject())
219+
jsonSupport.decodeFromString<JsonObject>(str)
220+
}
221+
RequestSignature(
222+
header = Header(it.header.toBase64URL(), unprotectedHeader),
223+
signature = it.signature,
224+
)
225+
}
226+
require(requestSignatures != null) { "No signatures found for the passed request" }
227+
228+
Signed(parsed.payload.toBase64URL(), requestSignatures)
229+
}
230+
}
231+
}
232+
}
233+
234+
internal data class RequestSignature(
235+
val header: Header,
236+
val signature: Signature,
237+
)
238+
239+
internal data class Header(
240+
val protected: Base64URL,
241+
val unProtected: JsonObject? = null,
242+
)
243+
244+
internal typealias Signature = Base64URL
245+
246+
internal fun ReceivedRequest.Signed.toSignedJwts(): List<SignedJWT> =
247+
signatures.map {
248+
SignedJWT.parse("${it.header.protected}.$payload.${it.signature}")
249+
}
250+
251+
internal fun FetchedRequest.toReceivedRequest(): ReceivedRequest =
252+
when (this) {
253+
is FetchedRequest.Plain -> ReceivedRequest.Unsigned(requestObject)
254+
is FetchedRequest.JwtSecured -> ReceivedRequest.Signed.from(jwt).getOrThrow()
255+
}
256+
174257
internal class DefaultAuthorizationRequestResolver(
175258
private val siopOpenId4VPConfig: SiopOpenId4VPConfig,
176259
private val httpKtorHttpClientFactory: KtorHttpClientFactory,
@@ -193,7 +276,7 @@ internal class DefaultAuthorizationRequestResolver(
193276

194277
val authenticatedRequest =
195278
try {
196-
authenticateRequest(fetchedRequest)
279+
authenticateRequest(fetchedRequest.toReceivedRequest())
197280
} catch (e: AuthorizationRequestException) {
198281
val dispatchDetails =
199282
when (siopOpenId4VPConfig.errorDispatchPolicy) {
@@ -225,9 +308,9 @@ internal class DefaultAuthorizationRequestResolver(
225308
return requestFetcher.fetchRequest(unvalidatedRequest)
226309
}
227310

228-
private suspend fun HttpClient.authenticateRequest(fetchedRequest: FetchedRequest): AuthenticatedRequest {
311+
private suspend fun HttpClient.authenticateRequest(receivedRequest: ReceivedRequest): AuthenticatedRequest {
229312
val requestAuthenticator = RequestAuthenticator(siopOpenId4VPConfig, this)
230-
return requestAuthenticator.authenticate(fetchedRequest)
313+
return requestAuthenticator.authenticate(receivedRequest)
231314
}
232315
}
233316

src/main/kotlin/eu/europa/ec/eudi/openid4vp/internal/request/RequestAuthenticator.kt

Lines changed: 33 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -73,34 +73,37 @@ internal class RequestAuthenticator(siopOpenId4VPConfig: SiopOpenId4VPConfig, ht
7373
private val clientAuthenticator = ClientAuthenticator(siopOpenId4VPConfig)
7474
private val signatureVerifier = JarJwtSignatureVerifier(siopOpenId4VPConfig, httpClient)
7575

76-
suspend fun authenticate(request: FetchedRequest): AuthenticatedRequest = coroutineScope {
76+
suspend fun authenticate(request: ReceivedRequest): AuthenticatedRequest = coroutineScope {
7777
val client = clientAuthenticator.authenticateClient(request)
7878
when (request) {
79-
is FetchedRequest.Plain -> {
79+
is ReceivedRequest.Unsigned -> {
8080
AuthenticatedRequest(client, request.requestObject)
8181
}
8282

83-
is FetchedRequest.JwtSecured -> {
84-
with(signatureVerifier) { verifySignature(client, request.jwt) }
85-
AuthenticatedRequest(client, request.jwt.requestObject())
83+
is ReceivedRequest.Signed -> {
84+
val signedJwt = request.ensureSingleSignedRequest()
85+
with(signatureVerifier) { verifySignature(client, signedJwt) }
86+
AuthenticatedRequest(client, signedJwt.requestObject())
8687
}
8788
}
8889
}
8990
}
9091

9192
internal class ClientAuthenticator(private val siopOpenId4VPConfig: SiopOpenId4VPConfig) {
92-
suspend fun authenticateClient(request: FetchedRequest): AuthenticatedClient {
93+
suspend fun authenticateClient(request: ReceivedRequest): AuthenticatedClient {
9394
val requestObject = when (request) {
94-
is FetchedRequest.JwtSecured -> request.jwt.requestObject()
95-
is FetchedRequest.Plain -> request.requestObject
95+
is ReceivedRequest.Signed -> {
96+
request.ensureSingleSignedRequest().requestObject()
97+
}
98+
is ReceivedRequest.Unsigned -> request.requestObject
9699
}
97100

98101
val (originalClientId, clientIdScheme) = originalClientIdAndScheme(requestObject)
99102
return when (clientIdScheme) {
100103
is Preregistered -> {
101104
val registeredClient = clientIdScheme.clients[originalClientId]
102105
ensureNotNull(registeredClient) { RequestValidationError.InvalidClientId.asException() }
103-
if (request is FetchedRequest.JwtSecured) {
106+
if (request is ReceivedRequest.Signed) {
104107
ensureNotNull(registeredClient.jarConfig) {
105108
invalidScheme("$registeredClient cannot place signed request")
106109
}
@@ -109,7 +112,7 @@ internal class ClientAuthenticator(private val siopOpenId4VPConfig: SiopOpenId4V
109112
}
110113

111114
SupportedClientIdScheme.RedirectUri -> {
112-
ensure(request is FetchedRequest.Plain) {
115+
ensure(request is ReceivedRequest.Unsigned) {
113116
invalidScheme("${clientIdScheme.scheme()} cannot be used in signed request")
114117
}
115118
val originalClientIdAsUri =
@@ -118,7 +121,7 @@ internal class ClientAuthenticator(private val siopOpenId4VPConfig: SiopOpenId4V
118121
}
119122

120123
is SupportedClientIdScheme.X509SanDns -> {
121-
ensure(request is FetchedRequest.JwtSecured) {
124+
ensure(request is ReceivedRequest.Signed) {
122125
invalidScheme("${clientIdScheme.scheme()} cannot be used in unsigned request")
123126
}
124127
val chain = x5c(originalClientId, request, clientIdScheme.trust) {
@@ -129,7 +132,7 @@ internal class ClientAuthenticator(private val siopOpenId4VPConfig: SiopOpenId4V
129132
}
130133

131134
is SupportedClientIdScheme.X509SanUri -> {
132-
ensure(request is FetchedRequest.JwtSecured) {
135+
ensure(request is ReceivedRequest.Signed) {
133136
invalidScheme("${clientIdScheme.scheme()} cannot be used in unsigned request")
134137
}
135138
val chain = x5c(originalClientId, request, clientIdScheme.trust) {
@@ -142,7 +145,7 @@ internal class ClientAuthenticator(private val siopOpenId4VPConfig: SiopOpenId4V
142145
}
143146

144147
is SupportedClientIdScheme.DID -> {
145-
ensure(request is FetchedRequest.JwtSecured) {
148+
ensure(request is ReceivedRequest.Signed) {
146149
invalidScheme("${clientIdScheme.scheme()} cannot be used in unsigned request")
147150
}
148151
val originalClientIdAsDID = ensureNotNull(DID.parse(originalClientId).getOrNull()) {
@@ -153,7 +156,7 @@ internal class ClientAuthenticator(private val siopOpenId4VPConfig: SiopOpenId4V
153156
}
154157

155158
is SupportedClientIdScheme.VerifierAttestation -> {
156-
ensure(request is FetchedRequest.JwtSecured) {
159+
ensure(request is ReceivedRequest.Signed) {
157160
invalidScheme("${clientIdScheme.scheme()} cannot be used in unsigned request")
158161
}
159162
val attestedClaims =
@@ -174,11 +177,12 @@ internal class ClientAuthenticator(private val siopOpenId4VPConfig: SiopOpenId4V
174177

175178
private fun x5c(
176179
originalClientId: OriginalClientId,
177-
request: FetchedRequest.JwtSecured,
180+
request: ReceivedRequest.Signed,
178181
trust: X509CertificateTrust,
179182
subjectAlternativeNames: X509Certificate.() -> List<String>,
180183
): List<X509Certificate> {
181-
val x5c = request.jwt.header?.x509CertChain
184+
val jwt = request.ensureSingleSignedRequest()
185+
val x5c = jwt.header?.x509CertChain
182186
ensureNotNull(x5c) { invalidJarJwt("Missing x5c") }
183187
val pubCertChain = x5c.mapNotNull { runCatching { X509CertUtils.parse(it.decode()) }.getOrNull() }
184188
ensure(pubCertChain.isNotEmpty()) { invalidJarJwt("Invalid x5c") }
@@ -192,13 +196,21 @@ internal class ClientAuthenticator(private val siopOpenId4VPConfig: SiopOpenId4V
192196
}
193197
}
194198

199+
private fun ReceivedRequest.Signed.ensureSingleSignedRequest(): SignedJWT {
200+
val signedJwts = toSignedJwts()
201+
return ensure(signedJwts.size == 1) {
202+
invalidJarJwt("Multi-signed authorization requests are not yet supported")
203+
}.let { signedJwts[0] }
204+
}
205+
195206
private suspend fun lookupKeyByDID(
196-
request: FetchedRequest.JwtSecured,
207+
request: ReceivedRequest.Signed,
197208
clientId: DID,
198209
lookupPublicKeyByDIDUrl: LookupPublicKeyByDIDUrl,
199210
): PublicKey = withContext(Dispatchers.IO) {
200211
val keyUrl: AbsoluteDIDUrl = run {
201-
val kid = ensureNotNull(request.jwt.header?.keyID) {
212+
val jwt = request.ensureSingleSignedRequest()
213+
val kid = ensureNotNull(jwt.header?.keyID) {
202214
invalidJarJwt("Missing kid for client_id $clientId")
203215
}
204216
ensureNotNull(AbsoluteDIDUrl.parse(kid).getOrNull()) {
@@ -217,15 +229,16 @@ private suspend fun lookupKeyByDID(
217229
private fun verifierAttestation(
218230
clock: Clock,
219231
supportedScheme: SupportedClientIdScheme.VerifierAttestation,
220-
request: FetchedRequest.JwtSecured,
232+
request: ReceivedRequest.Signed,
221233
originalClientId: OriginalClientId,
222234
): VerifierAttestationClaims {
223235
val (trust, skew) = supportedScheme
224236
fun invalidVerifierAttestationJwt(cause: String?) =
225237
invalidJarJwt("Invalid VerifierAttestation JWT. Details: $cause")
226238

227239
val verifierAttestationJwt = run {
228-
val jwtString = request.jwt.header.customParams["jwt"]
240+
val jwt = request.ensureSingleSignedRequest()
241+
val jwtString = jwt.header.customParams["jwt"]
229242
ensureNotNull(jwtString) { invalidJarJwt("Missing jwt JOSE Header") }
230243
ensure(jwtString is String) { invalidJarJwt("jwt JOSE Header doesn't contain a JWT") }
231244

src/test/kotlin/eu/europa/ec/eudi/openid4vp/Example.kt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,8 @@ import eu.europa.ec.eudi.openid4vp.dcql.DCQL as DCQLQuery
5151
* https://github.com/eu-digital-identity-wallet/eudi-srv-web-verifier-endpoint-23220-4-kt
5252
*/
5353
fun main(): Unit = runBlocking {
54-
val verifierApi = URL("https://dev.verifier-backend.eudiw.dev")
54+
// val verifierApi = URL("https://dev.verifier-backend.eudiw.dev")
55+
val verifierApi = URL("http://localhost:8080")
5556
val walletKeyPair = SiopIdTokenBuilder.randomKey()
5657
val wallet = Wallet(
5758
walletKeyPair = walletKeyPair,
@@ -193,7 +194,7 @@ class Verifier private constructor(
193194
put("dcql_query", jsonSupport.encodeToJsonElement(presentationQuery.query))
194195
}
195196
put("response_mode", "direct_post.jwt")
196-
put("presentation_definition_mode", "by_reference")
197+
put("presentation_definition_mode", "by_value")
197198
put("jar_mode", "by_reference")
198199
put("wallet_response_redirect_uri_template", "https://foo?response_code={RESPONSE_CODE}")
199200
if (!transactionData.isNullOrEmpty()) {

src/test/kotlin/eu/europa/ec/eudi/openid4vp/internal/request/RequestAuthenticatorTest.kt

Lines changed: 21 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ class ClientAuthenticatorTest {
6060

6161
@Test
6262
fun `if client_id is missing, authentication fails`() = runTest {
63-
val request = UnvalidatedRequestObject(clientId = null).plain()
63+
val request = UnvalidatedRequestObject(clientId = null).unsigned()
6464
assertFailsWithError<RequestValidationError.MissingClientId> {
6565
clientAuthenticator.authenticateClient(request)
6666
}
@@ -71,7 +71,7 @@ class ClientAuthenticatorTest {
7171
val request = UnvalidatedRequestObject(
7272
clientId = "bar:foo",
7373
responseMode = "bar",
74-
).plain()
74+
).unsigned()
7575

7676
assertFailsWithError<RequestValidationError.InvalidClientIdScheme> {
7777
clientAuthenticator.authenticateClient(request)
@@ -98,7 +98,7 @@ class ClientAuthenticatorTest {
9898
runTest {
9999
val request = UnvalidatedRequestObject(
100100
clientId = "redirect_uri:$clientId",
101-
).plain()
101+
).unsigned()
102102

103103
val client = clientAuthenticator.authenticateClient(request)
104104
assertEquals(AuthenticatedClient.RedirectUri(clientId), client)
@@ -142,7 +142,7 @@ class ClientAuthenticatorTest {
142142

143143
@Test
144144
fun `if request is not signed, authentication fails`() = runTest {
145-
val request = requestObject.plain()
145+
val request = requestObject.unsigned()
146146

147147
val error = assertFailsWithError<RequestValidationError.InvalidClientIdScheme> {
148148
clientAuthenticator.authenticateClient(request)
@@ -248,7 +248,7 @@ class ClientAuthenticatorTest {
248248

249249
@Test
250250
fun `if request is unsigned, authentication fails`() = runTest {
251-
val request = requestObject.plain()
251+
val request = requestObject.unsigned()
252252

253253
val error = assertFailsWithError<RequestValidationError.InvalidClientIdScheme> {
254254
clientAuthenticator.authenticateClient(request)
@@ -336,36 +336,34 @@ private inline fun <reified E : AuthorizationRequestError> assertFailsWithError(
336336
return assertIs<E>(exception.error)
337337
}
338338

339-
private fun UnvalidatedRequestObject.plain(): FetchedRequest.Plain =
340-
FetchedRequest.Plain(this)
339+
private fun UnvalidatedRequestObject.unsigned(): ReceivedRequest.Unsigned =
340+
ReceivedRequest.Unsigned(this)
341341

342342
private fun UnvalidatedRequestObject.signedWithAttestation(
343343
alg: JWSAlgorithm,
344344
key: JWK,
345345
attestation: SignedJWT,
346-
): FetchedRequest.JwtSecured = signed(alg, key) {
346+
): ReceivedRequest.Signed = signed(alg, key) {
347347
this.customParam("jwt", attestation.serialize())
348348
}
349349

350350
private fun UnvalidatedRequestObject.signed(
351351
alg: JWSAlgorithm,
352352
key: JWK,
353353
headerCustomization: (JWSHeader.Builder).() -> Unit = {},
354-
): FetchedRequest.JwtSecured = FetchedRequest.JwtSecured(
355-
clientId = checkNotNull(clientId),
356-
jwt = run {
357-
val header = with(JWSHeader.Builder(alg)) {
358-
type(JOSEObjectType(OpenId4VPSpec.AUTHORIZATION_REQUEST_OBJECT_TYPE))
359-
headerCustomization()
360-
build()
361-
}
362-
val claimsSet = toJWTClaimSet()
363-
SignedJWT(header, claimsSet).apply {
364-
val signer = DefaultJWSSignerFactory().createJWSSigner(key, alg)
365-
sign(signer)
366-
}
367-
},
368-
)
354+
): ReceivedRequest.Signed {
355+
val header = with(JWSHeader.Builder(alg)) {
356+
type(JOSEObjectType(OpenId4VPSpec.AUTHORIZATION_REQUEST_OBJECT_TYPE))
357+
headerCustomization()
358+
build()
359+
}
360+
val claimsSet = toJWTClaimSet()
361+
val jwt = SignedJWT(header, claimsSet).apply {
362+
val signer = DefaultJWSSignerFactory().createJWSSigner(key, alg)
363+
sign(signer)
364+
}
365+
return ReceivedRequest.Signed.from(jwt).getOrThrow()
366+
}
369367

370368
private fun UnvalidatedRequestObject.toJWTClaimSet(): JWTClaimsSet {
371369
val json = Json.encodeToString(this)

0 commit comments

Comments
 (0)