All Downloads are FREE. Search and download functionalities are using the official Maven repository.

eu.europa.ec.eudi.openid4vp.internal.request.RequestAuthenticator.kt Maven / Gradle / Ivy

There is a newer version: 0.5.0
Show newest version
/*
 * Copyright (c) 2023 European Commission
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package eu.europa.ec.eudi.openid4vp.internal.request

import com.nimbusds.jose.JOSEException
import com.nimbusds.jose.JOSEObjectType
import com.nimbusds.jose.jwk.AsymmetricJWK
import com.nimbusds.jose.jwk.JWK
import com.nimbusds.jose.jwk.JWKSet
import com.nimbusds.jose.jwk.source.ImmutableJWKSet
import com.nimbusds.jose.jwk.source.JWKSource
import com.nimbusds.jose.proc.*
import com.nimbusds.jose.shaded.gson.Gson
import com.nimbusds.jose.util.X509CertUtils
import com.nimbusds.jwt.JWTClaimsSet
import com.nimbusds.jwt.SignedJWT
import com.nimbusds.jwt.proc.BadJWTException
import com.nimbusds.jwt.proc.DefaultJWTProcessor
import com.nimbusds.jwt.proc.JWTClaimsSetVerifier
import com.nimbusds.jwt.util.DateUtils
import eu.europa.ec.eudi.openid4vp.*
import eu.europa.ec.eudi.openid4vp.SupportedClientIdScheme.Preregistered
import eu.europa.ec.eudi.openid4vp.internal.*
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.request.*
import io.ktor.http.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonObject
import java.net.URI
import java.security.PublicKey
import java.security.cert.X509Certificate
import java.time.Clock
import java.time.Instant
import java.util.*
import kotlin.time.Duration
import kotlin.time.toKotlinDuration

internal sealed interface AuthenticatedClient {
    data class Preregistered(val preregisteredClient: PreregisteredClient) : AuthenticatedClient
    data class RedirectUri(val clientId: URI) : AuthenticatedClient
    data class X509SanDns(val clientId: String, val chain: List) : AuthenticatedClient
    data class X509SanUri(val clientId: URI, val chain: List) : AuthenticatedClient
    data class DIDClient(val client: DID, val publicKey: PublicKey) : AuthenticatedClient
    data class Attested(val clientId: String, val claims: VerifierAttestationClaims) : AuthenticatedClient
}

internal data class AuthenticatedRequest(
    val client: AuthenticatedClient,
    val requestObject: UnvalidatedRequestObject,
)

internal class RequestAuthenticator(siopOpenId4VPConfig: SiopOpenId4VPConfig, httpClient: HttpClient) {
    private val clientAuthenticator = ClientAuthenticator(siopOpenId4VPConfig)
    private val signatureVerifier = JarJwtSignatureVerifier(siopOpenId4VPConfig, httpClient)

    suspend fun authenticate(request: FetchedRequest): AuthenticatedRequest = coroutineScope {
        val client = clientAuthenticator.authenticateClient(request)
        when (request) {
            is FetchedRequest.Plain -> {
                AuthenticatedRequest(client, request.requestObject)
            }

            is FetchedRequest.JwtSecured -> {
                with(signatureVerifier) { verifySignature(client, request.jwt) }
                AuthenticatedRequest(client, request.jwt.requestObject())
            }
        }
    }
}

internal class ClientAuthenticator(private val siopOpenId4VPConfig: SiopOpenId4VPConfig) {
    suspend fun authenticateClient(request: FetchedRequest): AuthenticatedClient {
        val requestObject = when (request) {
            is FetchedRequest.JwtSecured -> request.jwt.requestObject()
            is FetchedRequest.Plain -> request.requestObject
        }

        val (clientId, clientIdScheme) = clientIdAndScheme(requestObject)
        return when (clientIdScheme) {
            is Preregistered -> {
                val registeredClient = clientIdScheme.clients[clientId]
                ensureNotNull(registeredClient) { RequestValidationError.InvalidClientId.asException() }
                if (request is FetchedRequest.JwtSecured) {
                    ensureNotNull(registeredClient.jarConfig) {
                        invalidScheme("$registeredClient cannot place signed request")
                    }
                }
                AuthenticatedClient.Preregistered(registeredClient)
            }

            SupportedClientIdScheme.RedirectUri -> {
                ensure(request is FetchedRequest.Plain) {
                    invalidScheme("${clientIdScheme.scheme()} cannot be used in signed request")
                }
                val clientIdUri = clientId.asURI { RequestValidationError.InvalidClientId.asException() }.getOrThrow()
                AuthenticatedClient.RedirectUri(clientIdUri)
            }

            is SupportedClientIdScheme.X509SanDns -> {
                ensure(request is FetchedRequest.JwtSecured) {
                    invalidScheme("${clientIdScheme.scheme()} cannot be used in unsigned request")
                }
                val chain = x5c(request, clientIdScheme.trust) {
                    val dnsNames = sanOfDNSName().getOrNull()
                    ensureNotNull(dnsNames) { invalidJarJwt("Certificates misses DNS names") }
                }
                AuthenticatedClient.X509SanDns(request.clientId, chain)
            }

            is SupportedClientIdScheme.X509SanUri -> {
                ensure(request is FetchedRequest.JwtSecured) {
                    invalidScheme("${clientIdScheme.scheme()} cannot be used in unsigned request")
                }
                val chain = x5c(request, clientIdScheme.trust) {
                    val dnsNames = sanOfUniformResourceIdentifier().getOrNull()
                    ensureNotNull(dnsNames) { invalidJarJwt("Certificates misses URI names") }
                }
                val clientIdUri = clientId.asURI { RequestValidationError.InvalidClientId.asException() }.getOrThrow()
                AuthenticatedClient.X509SanUri(clientIdUri, chain)
            }

            is SupportedClientIdScheme.DID -> {
                ensure(request is FetchedRequest.JwtSecured) {
                    invalidScheme("${clientIdScheme.scheme()} cannot be used in unsigned request")
                }
                val clientIdAsDID =
                    ensureNotNull(
                        DID.parse(clientId).getOrNull(),
                    ) { RequestValidationError.InvalidClientId.asException() }
                val clientPubKey = lookupKeyByDID(request, clientIdAsDID, clientIdScheme.lookup)
                AuthenticatedClient.DIDClient(clientIdAsDID, clientPubKey)
            }

            is SupportedClientIdScheme.VerifierAttestation -> {
                ensure(request is FetchedRequest.JwtSecured) {
                    invalidScheme("${clientIdScheme.scheme()} cannot be used in unsigned request")
                }
                val attestedClaims = verifierAttestation(siopOpenId4VPConfig.clock, clientIdScheme, request, clientId)
                AuthenticatedClient.Attested(clientId, attestedClaims)
            }
        }
    }

    private fun clientIdAndScheme(requestObject: UnvalidatedRequestObject): Pair {
        val clientId = ensureNotNull(requestObject.clientId) { RequestValidationError.MissingClientId.asException() }
        val clientIdScheme = requestObject.clientIdScheme?.let { ClientIdScheme.make(it) }
        ensureNotNull(clientIdScheme) { invalidScheme("Missing or invalid client_id_scheme") }
        val supportedClientIdScheme = siopOpenId4VPConfig.supportedClientIdScheme(clientIdScheme)
        ensureNotNull(supportedClientIdScheme) { RequestValidationError.UnsupportedClientIdScheme.asException() }
        return clientId to supportedClientIdScheme
    }

    private fun x5c(
        request: FetchedRequest.JwtSecured,
        trust: X509CertificateTrust,
        subjectAlternativeNames: X509Certificate.() -> List,
    ): List {
        val x5c = request.jwt.header?.x509CertChain
        ensureNotNull(x5c) { invalidJarJwt("Missing x5c") }
        val pubCertChain = x5c.mapNotNull { runCatching { X509CertUtils.parse(it.decode()) }.getOrNull() }
        ensure(pubCertChain.isNotEmpty()) { invalidJarJwt("Invalid x5c") }

        val alternativeNames = pubCertChain[0].subjectAlternativeNames()
        ensure(request.clientId in alternativeNames) {
            invalidJarJwt("ClientId not found in certificate's subject alternative names")
        }
        ensure(trust.isTrusted(pubCertChain)) { invalidJarJwt("Untrusted x5c") }
        return pubCertChain
    }
}

private suspend fun lookupKeyByDID(
    request: FetchedRequest.JwtSecured,
    clientId: DID,
    lookupPublicKeyByDIDUrl: LookupPublicKeyByDIDUrl,
): PublicKey = withContext(Dispatchers.IO) {
    val keyUrl: AbsoluteDIDUrl = run {
        val kid = ensureNotNull(request.jwt.header?.keyID) {
            invalidJarJwt("Missing kid for client_id $clientId")
        }
        ensureNotNull(AbsoluteDIDUrl.parse(kid).getOrNull()) {
            invalidJarJwt("kid should be DID URL")
        }
    }
    ensure(keyUrl.toString().startsWith(clientId.toString())) {
        invalidJarJwt("kid should be DID URL sub-resource of $clientId but is $keyUrl")
    }
    val key = runCatching { lookupPublicKeyByDIDUrl.resolveKey(keyUrl.uri) }.getOrNull()
    ensureNotNull(key) {
        RequestValidationError.DIDResolutionFailed(keyUrl.toString()).asException()
    }
}

private fun verifierAttestation(
    clock: Clock,
    supportedScheme: SupportedClientIdScheme.VerifierAttestation,
    request: FetchedRequest.JwtSecured,
    clientId: String,
): VerifierAttestationClaims {
    val (trust, skew) = supportedScheme
    fun invalidVerifierAttestationJwt(cause: String?) =
        invalidJarJwt("Invalid VerifierAttestation JWT. Details: $cause")

    val verifierAttestationJwt = run {
        val jwtString = request.jwt.header.customParams["jwt"]
        ensureNotNull(jwtString) { invalidJarJwt("Missing jwt JOSE Header") }
        ensure(jwtString is String) { invalidJarJwt("jwt JOSE Header doesn't contain a JWT") }

        val parsedJwt = runCatching { SignedJWT.parse(jwtString) }.getOrElse { error ->
            throw invalidVerifierAttestationJwt("Cannot be parsed  $error")
        }
        val expectedType = "verifier-attestation+jwt"
        ensure(parsedJwt.header.type == JOSEObjectType(expectedType)) {
            invalidVerifierAttestationJwt("typ is not $expectedType ")
        }
        parsedJwt.apply {
            runCatching { verify(trust) }.getOrElse { throw invalidVerifierAttestationJwt("Not trusted. $it") }
        }
    }

    val verifierAttestationClaimSet = try {
        TimeChecks(clock, skew.toKotlinDuration()).verify(verifierAttestationJwt.jwtClaimsSet, null)
        verifierAttestationJwt.verifierAttestationClaims()
    } catch (t: Throwable) {
        throw invalidVerifierAttestationJwt(t.message)
    }
    ensure(verifierAttestationClaimSet.sub == clientId) {
        invalidVerifierAttestationJwt("sub claim and authorization's request client_id don't match")
    }

    return verifierAttestationClaimSet
}

/**
 * Validates a JWT that represents an Authorization Request according to RFC9101
 *
 * @param siopOpenId4VPConfig wallet's configuration
 */
private class JarJwtSignatureVerifier(
    private val siopOpenId4VPConfig: SiopOpenId4VPConfig,
    private val httpClient: HttpClient,
) {

    @Throws(AuthorizationRequestException::class)
    suspend fun verifySignature(client: AuthenticatedClient, signedJwt: SignedJWT) {
        try {
            val jwtProcessor = DefaultJWTProcessor().apply {
                // see also: DefaultJOSEObjectTypeVerifier.JWT
                jwsTypeVerifier =
                    DefaultJOSEObjectTypeVerifier(
                        JOSEObjectType("oauth-authz-req+jwt"),
                        JOSEObjectType.JWT,
                        JOSEObjectType(""),
                        null,
                    )
                jwsKeySelector = jwsKeySelector(client)
                jwtClaimsSetVerifier = TimeChecks(siopOpenId4VPConfig.clock, siopOpenId4VPConfig.jarClockSkew.toKotlinDuration())
            }
            jwtProcessor.process(signedJwt, null)
        } catch (e: JOSEException) {
            throw RuntimeException(e)
        } catch (e: BadJOSEException) {
            throw invalidJarJwt("Invalid signature ${e.message}")
        }
    }

    @Throws(AuthorizationRequestException::class)
    private suspend fun jwsKeySelector(client: AuthenticatedClient): JWSKeySelector =
        when (client) {
            is AuthenticatedClient.Preregistered ->
                getPreRegisteredClientJwsSelector(client)

            is AuthenticatedClient.X509SanUri ->
                JWSKeySelector { _, _ -> listOf(client.chain[0].publicKey) }

            is AuthenticatedClient.X509SanDns ->
                JWSKeySelector { _, _ -> listOf(client.chain[0].publicKey) }

            is AuthenticatedClient.RedirectUri ->
                throw RequestValidationError.UnsupportedClientIdScheme.asException()

            is AuthenticatedClient.DIDClient ->
                JWSKeySelector { _, _ -> listOf(client.publicKey) }

            is AuthenticatedClient.Attested ->
                JWSKeySelector { _, _ -> listOf(client.claims.verifierPubJwk.toPublicKey()) }
        }

    @Throws(AuthorizationRequestException::class)
    private suspend fun getPreRegisteredClientJwsSelector(
        preregistered: AuthenticatedClient.Preregistered,
    ): JWSVerificationKeySelector {
        val trustedClient = preregistered.preregisteredClient
        val jarConfig = checkNotNull(trustedClient.jarConfig)

        val (jarSigningAlg, jwkSetSource) = jarConfig
        suspend fun getJWKSource(): JWKSource {
            val jwkSet = when (jwkSetSource) {
                is JwkSetSource.ByValue -> JWKSet.parse(jwkSetSource.jwks.toString())
                is JwkSetSource.ByReference -> {
                    val unparsed = httpClient.get(jwkSetSource.jwksUri.toURL()).body()
                    JWKSet.parse(unparsed)
                }
            }
            return ImmutableJWKSet(jwkSet)
        }

        val jwkSource = getJWKSource()
        return JWSVerificationKeySelector(jarSigningAlg, jwkSource)
    }
}

private fun invalidScheme(cause: String): AuthorizationRequestException =
    RequestValidationError.InvalidClientIdScheme(cause).asException()

private fun invalidJarJwt(cause: String): AuthorizationRequestException =
    RequestValidationError.InvalidJarJwt(cause).asException()

private fun SignedJWT.requestObject(): UnvalidatedRequestObject {
    fun Map.asJsonObject(): JsonObject {
        val jsonStr = Gson().toJson(this)
        return Json.parseToJsonElement(jsonStr).jsonObject
    }

    return with(jwtClaimsSet) {
        UnvalidatedRequestObject(
            responseType = getStringClaim("response_type"),
            presentationDefinition = getJSONObjectClaim("presentation_definition")?.asJsonObject(),
            presentationDefinitionUri = getStringClaim("presentation_definition_uri"),
            scope = getStringClaim("scope"),
            nonce = getStringClaim("nonce"),
            responseMode = getStringClaim("response_mode"),
            clientIdScheme = getStringClaim("client_id_scheme"),
            clientMetaData = getJSONObjectClaim("client_metadata")?.asJsonObject(),
            clientId = getStringClaim("client_id"),
            responseUri = getStringClaim("response_uri"),
            redirectUri = getStringClaim("redirect_uri"),
            state = getStringClaim("state"),
            supportedAlgorithm = getStringClaim("supported_algorithm"),
            idTokenType = getStringClaim("id_token_type"),
        )
    }
}

internal data class VerifierAttestationClaims(
    val iss: String,
    val sub: String,
    val iat: Instant?,
    val exp: Instant,
    val nbf: Instant?,
    val verifierPubJwk: AsymmetricJWK,
    val redirectUris: List?,
    val responseUris: List?,
)

private fun SignedJWT.verifierAttestationClaims(): VerifierAttestationClaims =
    with(jwtClaimsSet) {
        VerifierAttestationClaims(
            iss = requireNotNull(issuer) { "Missing iss" },
            sub = requireNotNull(subject) { "Missing sub" },
            iat = issueTime?.toInstant(),
            exp = requireNotNull(expirationTime?.toInstant()) { "Missing exp" },
            nbf = notBeforeTime?.toInstant(),
            verifierPubJwk = run {
                val cnf = requireNotNull(getJSONObjectClaim("cnf")) { "Missing cnf" }
                val jwk = runCatching {
                    val jwkObj = requireNotNull(cnf["jwk"]) { "Missing jwk" }
                    JWK.parse(Gson().toJson(jwkObj))
                }.getOrNull()
                requireNotNull(jwk) { "Missing jwk" }
                require(!jwk.isPrivate) { "Not a public JWK" }
                require(jwk is AsymmetricJWK) { "Not a valid JWK" }
                jwk
            },
            redirectUris = getStringListClaim("redirect_uris")?.toList(),
            responseUris = getStringListClaim("response_uris")?.toList(),
        )
    }

private class TimeChecks(
    private val clock: Clock,
    private val skew: Duration,
) : JWTClaimsSetVerifier {

    @Throws(BadJWTException::class)
    override fun verify(claimsSet: JWTClaimsSet, context: SecurityContext?) {
        val now = Date.from(clock.instant())
        val skewInSeconds = skew.inWholeSeconds

        val exp = claimsSet.expirationTime
        if (exp != null) {
            if (!DateUtils.isAfter(exp, now, skewInSeconds)) {
                throw BadJWTException("Expired JWT")
            }
        }

        val nbf = claimsSet.notBeforeTime
        if (nbf != null) {
            if (!DateUtils.isBefore(nbf, now, skewInSeconds)) {
                throw BadJWTException("JWT before use time")
            }
        }
    }
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy