Skip to content

Commit 6dedd02

Browse files
authored
Merge pull request #1934 from walt-id/feat/issuer-signed
Feat/issuer signed metadata + DPoP
2 parents cd8b291 + cdcfd00 commit 6dedd02

42 files changed

Lines changed: 1683 additions & 114 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

waltid-libraries/protocols/waltid-openid4vci/src/commonMain/kotlin/id/walt/openid4vci/core/Config.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package id.walt.openid4vci.core
33
import id.walt.openid4vci.clientauth.ClientAuthenticationServiceConfig
44
import id.walt.openid4vci.clientauth.ClientAuthenticationServiceResolver
55
import id.walt.openid4vci.clientauth.attestation.ClientAttestationConfig
6+
import id.walt.openid4vci.dpop.DPoPProofVerifier
67
import id.walt.openid4vci.handlers.endpoints.authorization.AuthorizationEndpointHandlers
78
import id.walt.openid4vci.handlers.endpoints.credential.CredentialEndpointHandlers
89
import id.walt.openid4vci.handlers.endpoints.par.PushedAuthorizationEndpointHandlers
@@ -60,6 +61,7 @@ data class OAuth2ProviderConfig(
6061
val tokenEndpointHandlers: TokenEndpointHandlers,
6162
val accessTokenIssuer: AccessTokenIssuer,
6263
val accessTokenVerifier: AccessTokenVerifier? = null,
64+
val dpopProofVerifier: DPoPProofVerifier? = null,
6365
val refreshTokenIssuer: RefreshTokenIssuer,
6466
val refreshTokenVerifier: RefreshTokenVerifier,
6567
val refreshTokenRepository: RefreshTokenRepository,

waltid-libraries/protocols/waltid-openid4vci/src/commonMain/kotlin/id/walt/openid4vci/core/DefaultOAuth2Provider.kt

Lines changed: 157 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import id.walt.openid4vci.clientauth.ClientAuthenticationResult
1010
import id.walt.openid4vci.clientauth.ClientAuthenticationService
1111
import id.walt.openid4vci.clientauth.ClientAuthenticationServiceResolution
1212
import id.walt.openid4vci.clientauth.isAnonymousPreAuthorizedCodeTokenRequest
13+
import id.walt.openid4vci.dpop.DPoPConstants
14+
import id.walt.openid4vci.dpop.DPoPProofVerificationRequest
1315
import id.walt.openid4vci.errors.CredentialErrorCodes
1416
import id.walt.openid4vci.errors.OAuthError
1517
import id.walt.openid4vci.errors.OAuthErrorCodes
@@ -42,9 +44,12 @@ import id.walt.openid4vci.metadata.issuer.CredentialDisplay
4244
import id.walt.mdoc.dataelement.json.JsonObjectToCborMappingConfig as LegacyMdocJsonObjectToCborMappingConfig
4345
import id.walt.crypto.keys.Key
4446
import id.walt.mdoc.objects.mso.Status
45-
import id.walt.openid4vci.tokens.access.AccessTokenContext
47+
import id.walt.openid4vci.tokens.access.AccessTokenAuthorizationScheme
48+
import id.walt.openid4vci.tokens.access.CredentialAccessTokenContext
49+
import id.walt.openid4vci.tokens.access.dpopJwkThumbprint
4650
import id.walt.sdjwt.SDMap
4751
import id.walt.x509.CertificateDer
52+
import kotlinx.coroutines.CancellationException
4853
import kotlinx.serialization.json.JsonElement
4954
import kotlinx.serialization.json.JsonNull
5055
import kotlinx.serialization.json.JsonObject
@@ -272,7 +277,8 @@ class DefaultOAuth2Provider(
272277
override suspend fun createAccessTokenRequest(
273278
parameters: Map<String, List<String>>,
274279
headers: Map<String, List<String>>,
275-
session: Session?
280+
session: Session?,
281+
tokenEndpointUri: String?,
276282
): AccessTokenRequestResult {
277283
if (config.clientAuthenticationServiceResolver == null &&
278284
isAnonymousPreAuthorizedCodeTokenRequest(parameters, headers) &&
@@ -311,9 +317,60 @@ class DefaultOAuth2Provider(
311317
authenticatedClient = authenticatedClient,
312318
)
313319

314-
return validationResult
320+
return when (validationResult) {
321+
is AccessTokenRequestResult.Success -> bindTokenRequestToDPoP(
322+
result = validationResult,
323+
headers = headers,
324+
tokenEndpointUri = tokenEndpointUri,
325+
)
326+
327+
is AccessTokenRequestResult.Failure -> validationResult
328+
}
315329
}
316330

331+
private suspend fun bindTokenRequestToDPoP(
332+
result: AccessTokenRequestResult.Success,
333+
headers: Map<String, List<String>>,
334+
tokenEndpointUri: String?,
335+
): AccessTokenRequestResult {
336+
val proofHeaders = headers.entries
337+
.asSequence()
338+
.filter { (name, _) -> name.equals(DPoPConstants.HEADER_NAME, ignoreCase = true) }
339+
.flatMap { (_, values) -> values.asSequence() }
340+
.toList()
341+
if (proofHeaders.isEmpty()) return result
342+
if (proofHeaders.size != 1 || proofHeaders.single().isBlank()) {
343+
return invalidDPoPTokenRequest("Token request must contain exactly one DPoP proof")
344+
}
345+
346+
val verifier = config.dpopProofVerifier
347+
?: return invalidDPoPTokenRequest("DPoP proof verification is not configured")
348+
val targetUri = tokenEndpointUri?.takeIf { it.isNotBlank() }
349+
?: return invalidDPoPTokenRequest("Token endpoint URI is required for DPoP verification")
350+
351+
return try {
352+
val verified = verifier.verify(
353+
DPoPProofVerificationRequest(
354+
proofJwt = proofHeaders.single(),
355+
method = HTTP_POST,
356+
targetUri = targetUri,
357+
),
358+
)
359+
AccessTokenRequestResult.Success(
360+
result.request.withDpopJwkThumbprint(verified.jwkThumbprint),
361+
)
362+
} catch (e: CancellationException) {
363+
throw e
364+
} catch (e: Exception) {
365+
invalidDPoPTokenRequest(e.message ?: "Invalid DPoP proof")
366+
}
367+
}
368+
369+
private fun invalidDPoPTokenRequest(description: String): AccessTokenRequestResult.Failure =
370+
AccessTokenRequestResult.Failure(
371+
OAuthError(OAuthErrorCodes.INVALID_DPOP_PROOF, description),
372+
)
373+
317374
private fun canSkipTokenClientAuthentication(
318375
parameters: Map<String, List<String>>,
319376
headers: Map<String, List<String>>,
@@ -433,6 +490,8 @@ class DefaultOAuth2Provider(
433490
}
434491

435492
private companion object {
493+
const val HTTP_POST = "POST"
494+
const val WWW_AUTHENTICATE_HEADER = "WWW-Authenticate"
436495
val TOKEN_RESPONSE_HEADERS = mapOf(
437496
"Cache-Control" to "no-store",
438497
"Pragma" to "no-cache",
@@ -442,7 +501,7 @@ class DefaultOAuth2Provider(
442501
override suspend fun createCredentialRequest(
443502
parameters: Map<String, List<String>>,
444503
session: Session?,
445-
accessTokenContext: AccessTokenContext?
504+
accessTokenContext: CredentialAccessTokenContext?
446505
): CredentialRequestResult {
447506
verifyCredentialAccessToken(accessTokenContext)?.let { return it }
448507
return when (val result = config.credentialRequestValidator.validate(parameters, session ?: DefaultSession())) {
@@ -465,7 +524,7 @@ class DefaultOAuth2Provider(
465524
override suspend fun createCredentialRequest(
466525
encryptedCredentialRequest: String,
467526
session: Session?,
468-
accessTokenContext: AccessTokenContext?
527+
accessTokenContext: CredentialAccessTokenContext?
469528
): CredentialRequestResult {
470529
verifyCredentialAccessToken(accessTokenContext)?.let { return it }
471530
val decryptor = config.credentialRequestDecryptor
@@ -530,11 +589,24 @@ class DefaultOAuth2Provider(
530589

531590
override fun writeCredentialError(error: OAuthError): CredentialResponseHttp =
532591
CredentialResponseHttp(
533-
status = 400,
592+
status = when (error.error) {
593+
CredentialErrorCodes.INVALID_TOKEN,
594+
OAuthErrorCodes.INVALID_DPOP_PROOF -> 401
595+
596+
else -> 400
597+
},
534598
payload = buildMap {
535599
put("error", JsonPrimitive(error.error))
536600
error.description?.let { put("error_description", JsonPrimitive(it)) }
537601
},
602+
headers = when (error.error) {
603+
CredentialErrorCodes.INVALID_TOKEN,
604+
OAuthErrorCodes.INVALID_DPOP_PROOF -> mapOf(
605+
WWW_AUTHENTICATE_HEADER to dpopAuthenticationChallenge(error),
606+
)
607+
608+
else -> emptyMap()
609+
},
538610
)
539611

540612
override fun writeCredentialError(request: CredentialRequest, error: OAuthError): CredentialResponseHttp =
@@ -562,24 +634,98 @@ class DefaultOAuth2Provider(
562634
)
563635
}
564636

565-
private suspend fun verifyCredentialAccessToken(accessTokenContext: AccessTokenContext?): CredentialRequestResult.Failure? {
637+
private suspend fun verifyCredentialAccessToken(
638+
accessTokenContext: CredentialAccessTokenContext?,
639+
): CredentialRequestResult.Failure? {
566640
if (accessTokenContext == null) return null
567641
val verifier = config.accessTokenVerifier
568642
?: return CredentialRequestResult.Failure(
569643
OAuthError("invalid_request", "access token verifier not configured")
570644
)
571645
return try {
572-
verifier.verify(
573-
token = accessTokenContext.token,
646+
val claims = verifier.verify(
647+
token = accessTokenContext.authorization.token,
574648
expectedIssuer = accessTokenContext.expectedIssuer,
575649
expectedAudience = accessTokenContext.expectedAudience,
576650
)
577-
null
651+
verifyCredentialAccessTokenBinding(accessTokenContext, claims)
652+
} catch (e: CancellationException) {
653+
throw e
578654
} catch (e: Exception) {
579-
CredentialRequestResult.Failure(OAuthError("invalid_request", e.message))
655+
invalidCredentialAccessToken(e.message ?: "Access token is invalid")
580656
}
581657
}
582658

659+
private suspend fun verifyCredentialAccessTokenBinding(
660+
context: CredentialAccessTokenContext,
661+
claims: JsonObject,
662+
): CredentialRequestResult.Failure? {
663+
val boundJwkThumbprint = claims.dpopJwkThumbprint()
664+
665+
if (boundJwkThumbprint == null) {
666+
return if (context.authorization.scheme == AccessTokenAuthorizationScheme.BEARER) {
667+
null
668+
} else {
669+
invalidCredentialAccessToken("Access token is not DPoP-bound")
670+
}
671+
}
672+
673+
if (context.authorization.scheme != AccessTokenAuthorizationScheme.DPOP) {
674+
return invalidCredentialAccessToken("DPoP-bound access token must use the DPoP authorization scheme")
675+
}
676+
if (context.dpopProofHeaderValues.size != 1 || context.dpopProofHeaderValues.single().isBlank()) {
677+
return invalidCredentialDPoPProof("Credential request must contain exactly one DPoP proof")
678+
}
679+
680+
val dpopVerifier = config.dpopProofVerifier
681+
?: return invalidCredentialDPoPProof("DPoP proof verification is not configured")
682+
val targetUri = context.credentialEndpointUri?.takeIf { it.isNotBlank() }
683+
?: return invalidCredentialDPoPProof("Credential endpoint URI is required for DPoP verification")
684+
val verified = try {
685+
dpopVerifier.verify(
686+
DPoPProofVerificationRequest(
687+
proofJwt = context.dpopProofHeaderValues.single(),
688+
method = HTTP_POST,
689+
targetUri = targetUri,
690+
accessToken = context.authorization.token,
691+
),
692+
)
693+
} catch (e: CancellationException) {
694+
throw e
695+
} catch (e: Exception) {
696+
return invalidCredentialDPoPProof(e.message ?: "Invalid DPoP proof")
697+
}
698+
699+
return if (verified.jwkThumbprint == boundJwkThumbprint) {
700+
null
701+
} else {
702+
invalidCredentialAccessToken("Invalid DPoP key binding")
703+
}
704+
}
705+
706+
private fun invalidCredentialAccessToken(description: String): CredentialRequestResult.Failure =
707+
CredentialRequestResult.Failure(
708+
OAuthError(CredentialErrorCodes.INVALID_TOKEN, description),
709+
)
710+
711+
private fun invalidCredentialDPoPProof(description: String): CredentialRequestResult.Failure =
712+
CredentialRequestResult.Failure(
713+
OAuthError(OAuthErrorCodes.INVALID_DPOP_PROOF, description),
714+
)
715+
716+
private fun dpopAuthenticationChallenge(error: OAuthError): String = buildString {
717+
append(TOKEN_TYPE_DPOP)
718+
append(" error=\"").append(error.error.escapeAuthenticationParameter()).append('"')
719+
error.description?.let { description ->
720+
append(", error_description=\"")
721+
.append(description.escapeAuthenticationParameter())
722+
.append('"')
723+
}
724+
}
725+
726+
private fun String.escapeAuthenticationParameter(): String =
727+
replace("\\", "\\\\").replace("\"", "\\\"")
728+
583729
private fun JsonObject.toParametersMap(): Map<String, List<String>> =
584730
entries.associate { (key, value) ->
585731
val encoded = if (value is JsonPrimitive && value.isString) {

waltid-libraries/protocols/waltid-openid4vci/src/commonMain/kotlin/id/walt/openid4vci/core/OAuth2Provider.kt

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import id.walt.openid4vci.responses.credential.CredentialResponse
2525
import id.walt.openid4vci.responses.credential.CredentialResponseHttp
2626
import id.walt.crypto.keys.Key
2727
import id.walt.mdoc.objects.mso.Status
28-
import id.walt.openid4vci.tokens.access.AccessTokenContext
28+
import id.walt.openid4vci.tokens.access.CredentialAccessTokenContext
2929
import id.walt.openid4vci.metadata.issuer.CredentialDisplay
3030
import id.walt.sdjwt.SDMap
3131
import id.walt.x509.CertificateDer
@@ -103,6 +103,7 @@ interface OAuth2Provider {
103103
parameters: Map<String, List<String>>,
104104
headers: Map<String, List<String>>,
105105
session: Session? = null,
106+
tokenEndpointUri: String? = null,
106107
): AccessTokenRequestResult
107108

108109
suspend fun createAccessTokenResponse(
@@ -120,13 +121,13 @@ interface OAuth2Provider {
120121
suspend fun createCredentialRequest(
121122
parameters: Map<String, List<String>>,
122123
session: Session? = null,
123-
accessTokenContext: AccessTokenContext? = null,
124+
accessTokenContext: CredentialAccessTokenContext? = null,
124125
): CredentialRequestResult
125126

126127
suspend fun createCredentialRequest(
127128
encryptedCredentialRequest: String,
128129
session: Session? = null,
129-
accessTokenContext: AccessTokenContext? = null,
130+
accessTokenContext: CredentialAccessTokenContext? = null,
130131
): CredentialRequestResult
131132

132133
suspend fun createCredentialResponse(

waltid-libraries/protocols/waltid-openid4vci/src/commonMain/kotlin/id/walt/openid4vci/core/OAuthConstants.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ package id.walt.openid4vci.core
22

33
/** Common OAuth/OIDC constants shared across responses. */
44
const val TOKEN_TYPE_BEARER: String = "bearer"
5+
const val TOKEN_TYPE_DPOP: String = "DPoP"
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package id.walt.openid4vci.dpop
2+
3+
object DPoPConstants {
4+
const val HEADER_NAME = "DPoP"
5+
const val JWT_TYPE = "dpop+jwt"
6+
const val HTTP_METHOD_CLAIM = "htm"
7+
const val HTTP_URI_CLAIM = "htu"
8+
const val ACCESS_TOKEN_HASH_CLAIM = "ath"
9+
const val ES256 = "ES256"
10+
11+
val SUPPORTED_SIGNING_ALGORITHMS: Set<String> = setOf(ES256)
12+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package id.walt.openid4vci.dpop
2+
3+
/**
4+
* Inputs needed to verify one RFC 9449 DPoP proof. [targetUri] must come from
5+
* trusted server configuration, not from a client-controlled forwarding header.
6+
*/
7+
data class DPoPProofVerificationRequest(
8+
val proofJwt: String,
9+
val method: String,
10+
val targetUri: String,
11+
val accessToken: String? = null,
12+
)
13+
14+
data class VerifiedDPoPProof(
15+
val jwkThumbprint: String,
16+
)
17+
18+
/** Verifies possession of the DPoP key used to protect an OAuth request. */
19+
fun interface DPoPProofVerifier {
20+
suspend fun verify(request: DPoPProofVerificationRequest): VerifiedDPoPProof
21+
}

0 commit comments

Comments
 (0)