diff --git a/.gitignore b/.gitignore index e4798d7c..ca0f159a 100644 --- a/.gitignore +++ b/.gitignore @@ -36,4 +36,5 @@ bin/ .vscode/ ### Mac OS ### -.DS_Store \ No newline at end of file +.DS_Store +local.properties diff --git a/build.gradle.kts b/build.gradle.kts index 134b9b2a..e8335ec9 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -18,15 +18,11 @@ plugins { alias(libs.plugins.sonarqube) alias(libs.plugins.dependency.check) alias(libs.plugins.maven.publish) + id("maven-publish") } -repositories { - mavenCentral() - mavenLocal() - maven { - url = uri("https://s01.oss.sonatype.org/content/repositories/snapshots/") - mavenContent { snapshotsOnly() } - } +configurations.all { + resolutionStrategy.cacheChangingModulesFor(0, TimeUnit.SECONDS) } dependencies { @@ -47,9 +43,33 @@ dependencies { testImplementation(libs.logback.classic) testImplementation(libs.cbor) } +signing { + setRequired(project.hasProperty("signing.keyId")) + sign { + publishing.publications + } +} + +afterEvaluate { + publishing { + publications { + create("mavenJava") { + groupId = "com.github.TICESoftware" + artifactId = "eudi-lib-jvm-openid4vci-kt" + version = "0.0.1" + + from(components["java"]) + } + } + } +} java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(libs.versions.java.get())) + } sourceCompatibility = JavaVersion.toVersion(libs.versions.java.get()) + targetCompatibility = JavaVersion.toVersion(libs.versions.java.get()) } kotlin { @@ -103,9 +123,15 @@ tasks.withType().configureEach { // contains descriptions for the module and the packages includes.from("Module.md") - documentedVisibilities.set(setOf(DokkaConfiguration.Visibility.PUBLIC, DokkaConfiguration.Visibility.PROTECTED)) + documentedVisibilities.set( + setOf( + DokkaConfiguration.Visibility.PUBLIC, + DokkaConfiguration.Visibility.PROTECTED + ) + ) - val remoteSourceUrl = System.getenv()["GIT_REF_NAME"]?.let { URL("${Meta.BASE_URL}/tree/$it/src") } + val remoteSourceUrl = + System.getenv()["GIT_REF_NAME"]?.let { URL("${Meta.BASE_URL}/tree/$it/src") } remoteSourceUrl ?.let { sourceLink { diff --git a/jitpack.yml b/jitpack.yml new file mode 100644 index 00000000..f29a6676 --- /dev/null +++ b/jitpack.yml @@ -0,0 +1,5 @@ +jdk: + - openjdk17 +before_install: + - sdk install java 17.0.1-open + - sdk use java 17.0.1-open \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index be71120b..5684a328 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,4 +1,19 @@ plugins { - id("org.gradle.toolchains.foojay-resolver-convention") version("0.6.0") + id("org.gradle.toolchains.foojay-resolver-convention") version ("0.6.0") } -rootProject.name = "eudi-lib-jvm-openid4vci-kt" + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + mavenLocal() + google() + mavenCentral() + maven { + url = uri("https://s01.oss.sonatype.org/content/repositories/snapshots/") + mavenContent { snapshotsOnly() } + } + maven { url = uri("https://jitpack.io") } + } +} + +rootProject.name = "eudi-lib-jvm-openid4vci-kt" \ No newline at end of file diff --git a/src/main/kotlin/eu/europa/ec/eudi/openid4vci/AuthorizeIssuance.kt b/src/main/kotlin/eu/europa/ec/eudi/openid4vci/AuthorizeIssuance.kt index 56e89f8c..afc76d8f 100644 --- a/src/main/kotlin/eu/europa/ec/eudi/openid4vci/AuthorizeIssuance.kt +++ b/src/main/kotlin/eu/europa/ec/eudi/openid4vci/AuthorizeIssuance.kt @@ -58,7 +58,7 @@ sealed interface AuthorizedRequest : java.io.Serializable { * @return The new state of the request. */ fun withCNonce(cNonce: CNonce): ProofRequired = - ProofRequired(accessToken, refreshToken, cNonce, credentialIdentifiers, timestamp) + ProofRequired(accessToken, refreshToken, cNonce, credentialIdentifiers, timestamp, "no dpopNonce") fun withRefreshedAccessToken( refreshedAccessToken: AccessToken, @@ -110,6 +110,7 @@ sealed interface AuthorizedRequest : java.io.Serializable { val cNonce: CNonce, override val credentialIdentifiers: Map>?, override val timestamp: Instant, + val dpopNonce: String ) : AuthorizedRequest } @@ -143,6 +144,7 @@ interface AuthorizeIssuance { suspend fun AuthorizationRequestPrepared.authorizeWithAuthorizationCode( authorizationCode: AuthorizationCode, serverState: String, + dpopNonce: String ): Result /** diff --git a/src/main/kotlin/eu/europa/ec/eudi/openid4vci/Issuance.kt b/src/main/kotlin/eu/europa/ec/eudi/openid4vci/Issuance.kt index 31031807..13da9392 100644 --- a/src/main/kotlin/eu/europa/ec/eudi/openid4vci/Issuance.kt +++ b/src/main/kotlin/eu/europa/ec/eudi/openid4vci/Issuance.kt @@ -174,6 +174,7 @@ interface RequestIssuance { suspend fun AuthorizedRequest.ProofRequired.requestSingle( requestPayload: IssuanceRequestPayload, proofSigner: PopSigner, + dpopNonce: String ): Result /** diff --git a/src/main/kotlin/eu/europa/ec/eudi/openid4vci/internal/AuthorizeIssuanceImpl.kt b/src/main/kotlin/eu/europa/ec/eudi/openid4vci/internal/AuthorizeIssuanceImpl.kt index 6628308c..38ea70fa 100644 --- a/src/main/kotlin/eu/europa/ec/eudi/openid4vci/internal/AuthorizeIssuanceImpl.kt +++ b/src/main/kotlin/eu/europa/ec/eudi/openid4vci/internal/AuthorizeIssuanceImpl.kt @@ -30,6 +30,7 @@ internal data class TokenResponse( val cNonce: CNonce?, val authorizationDetails: Map> = emptyMap(), val timestamp: Instant, + val dpopNonce: String ) internal class AuthorizeIssuanceImpl( @@ -81,11 +82,12 @@ internal class AuthorizeIssuanceImpl( override suspend fun AuthorizationRequestPrepared.authorizeWithAuthorizationCode( authorizationCode: AuthorizationCode, serverState: String, + dpopNonce: String ): Result = runCatching { ensure(serverState == state) { InvalidAuthorizationState() } val tokenResponse = - tokenEndpointClient.requestAccessTokenAuthFlow(authorizationCode, pkceVerifier).getOrThrow() + tokenEndpointClient.requestAccessTokenAuthFlow(authorizationCode, pkceVerifier, dpopNonce).getOrThrow() authorizedRequest(credentialOffer, tokenResponse) } @@ -128,7 +130,7 @@ internal fun TxCode.validate(txCode: String?) { internal fun authorizedRequest( offer: CredentialOffer, - tokenResponse: TokenResponse, + tokenResponse: TokenResponse ): AuthorizedRequest { val offerRequiresProofs = offer.credentialConfigurationIdentifiers.any { val credentialConfiguration = offer.credentialIssuerMetadata.credentialConfigurationsSupported[it] @@ -137,7 +139,7 @@ internal fun authorizedRequest( val (accessToken, refreshToken, cNonce, authorizationDetails, timestamp) = tokenResponse return when { cNonce != null && offerRequiresProofs -> - ProofRequired(accessToken, refreshToken, cNonce, authorizationDetails, timestamp) + ProofRequired(accessToken, refreshToken, cNonce, authorizationDetails, timestamp, tokenResponse.dpopNonce) else -> NoProofRequired(accessToken, refreshToken, authorizationDetails, timestamp) diff --git a/src/main/kotlin/eu/europa/ec/eudi/openid4vci/internal/DPoPJwtFactory.kt b/src/main/kotlin/eu/europa/ec/eudi/openid4vci/internal/DPoPJwtFactory.kt index 399bc97a..890e98aa 100644 --- a/src/main/kotlin/eu/europa/ec/eudi/openid4vci/internal/DPoPJwtFactory.kt +++ b/src/main/kotlin/eu/europa/ec/eudi/openid4vci/internal/DPoPJwtFactory.kt @@ -138,6 +138,7 @@ internal fun HttpRequestBuilder.bearerOrDPoPAuth( htu: URL, htm: Htm, accessToken: AccessToken, + dpopNonce: String? = null ) { when (accessToken) { is AccessToken.Bearer -> { @@ -146,7 +147,7 @@ internal fun HttpRequestBuilder.bearerOrDPoPAuth( is AccessToken.DPoP -> { if (factory != null) { - dpop(factory, htu, htm, accessToken, nonce = null) + dpop(factory, htu, htm, accessToken, dpopNonce) dpopAuth(accessToken) } else { bearerAuth(AccessToken.Bearer(accessToken.accessToken, accessToken.expiresIn)) diff --git a/src/main/kotlin/eu/europa/ec/eudi/openid4vci/internal/RequestIssuanceImpl.kt b/src/main/kotlin/eu/europa/ec/eudi/openid4vci/internal/RequestIssuanceImpl.kt index 8b487b77..1d48d9ae 100644 --- a/src/main/kotlin/eu/europa/ec/eudi/openid4vci/internal/RequestIssuanceImpl.kt +++ b/src/main/kotlin/eu/europa/ec/eudi/openid4vci/internal/RequestIssuanceImpl.kt @@ -53,8 +53,9 @@ internal class RequestIssuanceImpl( override suspend fun AuthorizedRequest.ProofRequired.requestSingle( requestPayload: IssuanceRequestPayload, proofSigner: PopSigner, + dpopNonce: String ): Result = runCatching { - placeIssuanceRequest(accessToken) { + placeIssuanceRequest(accessToken, dpopNonce) { singleRequest(requestPayload, proofFactory(proofSigner, cNonce), credentialIdentifiers) } } @@ -175,13 +176,14 @@ internal class RequestIssuanceImpl( private suspend fun placeIssuanceRequest( token: AccessToken, + dpopNonce: String? = null, issuanceRequestSupplier: suspend () -> CredentialIssuanceRequest, ): SubmissionOutcome { fun handleIssuanceFailure(error: Throwable): SubmissionOutcome.Errored = submitRequestFromError(error) ?: throw error return when (val credentialRequest = issuanceRequestSupplier()) { is CredentialIssuanceRequest.SingleRequest -> { - credentialEndpointClient.placeIssuanceRequest(token, credentialRequest).fold( + credentialEndpointClient.placeIssuanceRequest(token, credentialRequest, dpopNonce).fold( onSuccess = { SubmissionOutcome.Success(it.credentials, it.cNonce) }, onFailure = { handleIssuanceFailure(it) }, ) diff --git a/src/main/kotlin/eu/europa/ec/eudi/openid4vci/internal/http/CredentialEndpointClient.kt b/src/main/kotlin/eu/europa/ec/eudi/openid4vci/internal/http/CredentialEndpointClient.kt index f30cb96f..96d61ebe 100644 --- a/src/main/kotlin/eu/europa/ec/eudi/openid4vci/internal/http/CredentialEndpointClient.kt +++ b/src/main/kotlin/eu/europa/ec/eudi/openid4vci/internal/http/CredentialEndpointClient.kt @@ -45,11 +45,12 @@ internal class CredentialEndpointClient( suspend fun placeIssuanceRequest( accessToken: AccessToken, request: CredentialIssuanceRequest.SingleRequest, + dpopNonce: String? = null ): Result = runCatching { ktorHttpClientFactory().use { client -> val url = credentialEndpoint.value val response = client.post(url) { - bearerOrDPoPAuth(dPoPJwtFactory, url, Htm.POST, accessToken) + bearerOrDPoPAuth(dPoPJwtFactory, url, Htm.POST, accessToken, dpopNonce) contentType(ContentType.Application.Json) setBody(CredentialRequestTO.from(request)) } diff --git a/src/main/kotlin/eu/europa/ec/eudi/openid4vci/internal/http/TokenEndpointClient.kt b/src/main/kotlin/eu/europa/ec/eudi/openid4vci/internal/http/TokenEndpointClient.kt index 5461d6b0..52757b1e 100644 --- a/src/main/kotlin/eu/europa/ec/eudi/openid4vci/internal/http/TokenEndpointClient.kt +++ b/src/main/kotlin/eu/europa/ec/eudi/openid4vci/internal/http/TokenEndpointClient.kt @@ -15,18 +15,29 @@ */ package eu.europa.ec.eudi.openid4vci.internal.http -import eu.europa.ec.eudi.openid4vci.* +import eu.europa.ec.eudi.openid4vci.AccessToken +import eu.europa.ec.eudi.openid4vci.AuthorizationCode +import eu.europa.ec.eudi.openid4vci.CIAuthorizationServerMetadata +import eu.europa.ec.eudi.openid4vci.CNonce +import eu.europa.ec.eudi.openid4vci.ClientId +import eu.europa.ec.eudi.openid4vci.CredentialConfigurationIdentifier +import eu.europa.ec.eudi.openid4vci.CredentialIdentifier import eu.europa.ec.eudi.openid4vci.CredentialIssuanceError.AccessTokenRequestFailed import eu.europa.ec.eudi.openid4vci.Grants.PreAuthorizedCode +import eu.europa.ec.eudi.openid4vci.KtorHttpClientFactory +import eu.europa.ec.eudi.openid4vci.OpenId4VCIConfig +import eu.europa.ec.eudi.openid4vci.PKCEVerifier +import eu.europa.ec.eudi.openid4vci.RefreshToken import eu.europa.ec.eudi.openid4vci.internal.DPoP import eu.europa.ec.eudi.openid4vci.internal.DPoPJwtFactory import eu.europa.ec.eudi.openid4vci.internal.GrantedAuthorizationDetailsSerializer import eu.europa.ec.eudi.openid4vci.internal.Htm import eu.europa.ec.eudi.openid4vci.internal.TokenResponse import eu.europa.ec.eudi.openid4vci.internal.dpop -import io.ktor.client.call.* -import io.ktor.client.request.forms.* -import io.ktor.http.* +import io.ktor.client.call.body +import io.ktor.client.request.forms.submitForm +import io.ktor.http.Parameters +import io.ktor.http.isSuccess import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import java.net.URI @@ -59,6 +70,7 @@ internal sealed interface TokenResponseTO { @SerialName( "authorization_details", ) val authorizationDetails: Map>? = null, + var dpopNonce: String? = null ) : TokenResponseTO /** @@ -86,6 +98,7 @@ internal sealed interface TokenResponseTO { cNonce = cNonce?.let { CNonce(it, cNonceExpiresIn) }, authorizationDetails = authorizationDetails ?: emptyMap(), timestamp = clock.instant(), + dpopNonce = dpopNonce ?: "no dpopNonce" ) } @@ -128,6 +141,7 @@ internal class TokenEndpointClient( suspend fun requestAccessTokenAuthFlow( authorizationCode: AuthorizationCode, pkceVerifier: PKCEVerifier, + dpopNonce: String ): Result = runCatching { val params = TokenEndpointForm.authCodeFlow( authorizationCode = authorizationCode, @@ -135,7 +149,7 @@ internal class TokenEndpointClient( clientId = clientId, pkceVerifier = pkceVerifier, ) - requestAccessToken(params).tokensOrFail(clock) + requestAccessToken(params, dpopNonce).tokensOrFail(clock) } /** @@ -167,13 +181,15 @@ internal class TokenEndpointClient( * @return the token end point response, which will include a new [TokenResponse.accessToken] and possibly * a new [TokenResponse.refreshToken] */ - suspend fun refreshAccessToken(refreshToken: RefreshToken): Result = runCatching { - val params = TokenEndpointForm.refreshAccessToken(clientId, refreshToken) - requestAccessToken(params).tokensOrFail(clock = clock) - } + suspend fun refreshAccessToken(refreshToken: RefreshToken): Result = + runCatching { + val params = TokenEndpointForm.refreshAccessToken(clientId, refreshToken) + requestAccessToken(params).tokensOrFail(clock = clock) + } private suspend fun requestAccessToken( params: Map, + dpopNonce: String? = null ): TokenResponseTO = ktorHttpClientFactory().use { client -> val formParameters = Parameters.build { @@ -181,13 +197,17 @@ internal class TokenEndpointClient( } val response = client.submitForm(tokenEndpoint.toString(), formParameters) { dPoPJwtFactory?.let { factory -> - dpop(factory, tokenEndpoint, Htm.POST, accessToken = null, nonce = null) + dpop(factory, tokenEndpoint, Htm.POST, accessToken = null, nonce = dpopNonce) } } - if (response.status.isSuccess()) response.body() + if (response.status.isSuccess()) response.body().also { + val dPoPNonce = response.headers["DPoP-Nonce"] ?: throw IllegalStateException("No DPoP-Nonce received") + it.dpopNonce = dPoPNonce + } else response.body() } } + internal object TokenEndpointForm { const val AUTHORIZATION_CODE_GRANT = "authorization_code" const val PRE_AUTHORIZED_CODE_GRANT = "urn:ietf:params:oauth:grant-type:pre-authorized_code"