@@ -10,6 +10,8 @@ import id.walt.openid4vci.clientauth.ClientAuthenticationResult
1010import id.walt.openid4vci.clientauth.ClientAuthenticationService
1111import id.walt.openid4vci.clientauth.ClientAuthenticationServiceResolution
1212import id.walt.openid4vci.clientauth.isAnonymousPreAuthorizedCodeTokenRequest
13+ import id.walt.openid4vci.dpop.DPoPConstants
14+ import id.walt.openid4vci.dpop.DPoPProofVerificationRequest
1315import id.walt.openid4vci.errors.CredentialErrorCodes
1416import id.walt.openid4vci.errors.OAuthError
1517import id.walt.openid4vci.errors.OAuthErrorCodes
@@ -42,9 +44,12 @@ import id.walt.openid4vci.metadata.issuer.CredentialDisplay
4244import id.walt.mdoc.dataelement.json.JsonObjectToCborMappingConfig as LegacyMdocJsonObjectToCborMappingConfig
4345import id.walt.crypto.keys.Key
4446import 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
4650import id.walt.sdjwt.SDMap
4751import id.walt.x509.CertificateDer
52+ import kotlinx.coroutines.CancellationException
4853import kotlinx.serialization.json.JsonElement
4954import kotlinx.serialization.json.JsonNull
5055import 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) {
0 commit comments