diff --git a/README.md b/README.md index 54814394..b9926688 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ and requires the use of a suitable OAUTH2 server. | [Credential Issuer MetaData](#credential-issuer-metadata) | Yes, using `scopes` | | Batch Endpoint | ❌ | | Deferred Endpoint | ✅ | -| Proof | ✅ JWT (`jwk`, `x5c`) , ❌ CWT | +| Proof | ✅ JWT (`jwk`, `x5c`) , ❌ CWT | ## How to use docker diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 458d8b0e..070e478e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,14 +1,14 @@ [versions] coroutines = "1.7.3" dokka = "1.9.10" -kotlin = "1.9.20" +kotlin = "1.9.21" arrow = "1.2.1" foojay = "0.7.0" -springboot = "3.1.5" +springboot = "3.2.0" springDependencyManagement = "1.1.4" spotless = "6.22.0" java = "17" -kotlinxSerialization = "1.6.0" +kotlinxSerialization = "1.6.1" ktlint = "0.50.0" nimbusJoseJwt = "9.37.1" nimbusOAuth2 = "11.6" diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 8838ba97..e6aba251 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-all.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/pid-issuer.http b/pid-issuer.http index c0085ba7..9a17d7d1 100644 --- a/pid-issuer.http +++ b/pid-issuer.http @@ -49,7 +49,7 @@ GET {{issuer_publicUrl}}/.well-known/openid-credential-issuer client.global.set("deferred_credential_endpoint", response.body.deferred_credential_endpoint) %} -### Get sample credentilas offer +### Get sample credentials offer GET {{issuer_publicUrl}}/issuer/credentialsOffer diff --git a/src/main/kotlin/eu/europa/ec/eudi/pidissuer/PidIssuerApplication.kt b/src/main/kotlin/eu/europa/ec/eudi/pidissuer/PidIssuerApplication.kt index 157326df..0257d798 100644 --- a/src/main/kotlin/eu/europa/ec/eudi/pidissuer/PidIssuerApplication.kt +++ b/src/main/kotlin/eu/europa/ec/eudi/pidissuer/PidIssuerApplication.kt @@ -67,6 +67,7 @@ import org.springframework.security.web.server.authentication.HttpStatusServerEn import org.springframework.web.reactive.config.EnableWebFlux import org.springframework.web.reactive.config.WebFluxConfigurer import org.springframework.web.reactive.function.client.WebClient +import org.springframework.web.util.UriComponentsBuilder import reactor.netty.http.client.HttpClient import java.time.Clock import java.time.Duration @@ -158,7 +159,7 @@ fun beans(clock: Clock) = beans { // Specific Issuers // bean { - val issuerPublicUrl = env.readRequiredUrl("issuer.publicUrl") + val issuerPublicUrl = env.readRequiredUrl("issuer.publicUrl", removeTrailingSlash = true) bean { EncodePidInCborWithMicroService(env.readRequiredUrl("issuer.pid.mso_mdoc.encoderUrl"), ref()) @@ -166,14 +167,9 @@ fun beans(clock: Clock) = beans { CredentialIssuerMetaData( id = issuerPublicUrl, - credentialEndPoint = env.readRequiredUrl("issuer.publicUrl").run { - HttpsUrl.unsafe("${this.value}${WalletApi.CREDENTIAL_ENDPOINT}") - }, - deferredCredentialEndpoint = env.readRequiredUrl("issuer.publicUrl").run { - HttpsUrl.unsafe("${this.value}${WalletApi.DEFERRED_ENDPOINT}") - }, - authorizationServer = env.readRequiredUrl("issuer.authorizationServer"), - + credentialEndPoint = issuerPublicUrl.appendPath(WalletApi.CREDENTIAL_ENDPOINT), + deferredCredentialEndpoint = issuerPublicUrl.appendPath(WalletApi.DEFERRED_ENDPOINT), + authorizationServers = listOf(env.readRequiredUrl("issuer.authorizationServer")), credentialResponseEncryption = env.credentialResponseEncryption(), specificCredentialIssuers = buildList { val enableMsoMdocPid = env.getProperty("issuer.pid.mso_mdoc.enabled") ?: true @@ -341,10 +337,20 @@ private fun Environment.credentialResponseEncryption(): CredentialResponseEncryp ) } -private fun Environment.readRequiredUrl(key: String): HttpsUrl = - getRequiredProperty(key).let { url -> - HttpsUrl.of(url) ?: HttpsUrl.unsafe(url) - } +private fun Environment.readRequiredUrl(key: String, removeTrailingSlash: Boolean = false): HttpsUrl = + getRequiredProperty(key) + .let { url -> + fun String.normalize() = + if (removeTrailingSlash) { + this.removeSuffix("/") + } else { + this + } + + fun String.toHttpsUrl(): HttpsUrl = HttpsUrl.of(this) ?: HttpsUrl.unsafe(this) + + url.normalize().toHttpsUrl() + } private fun Environment.readNonEmptySet(key: String, f: (String) -> T?): NonEmptySet { val nonEmptySet = getRequiredProperty>(key) @@ -353,6 +359,14 @@ private fun Environment.readNonEmptySet(key: String, f: (String) -> T?): Non return checkNotNull(nonEmptySet) { "Missing or incorrect values values for key `$key`" } } +private fun HttpsUrl.appendPath(path: String): HttpsUrl = + HttpsUrl.unsafe( + UriComponentsBuilder.fromHttpUrl(externalForm) + .path(path) + .build() + .toUriString(), + ) + fun BeanDefinitionDsl.initializer(): ApplicationContextInitializer = ApplicationContextInitializer { initialize(it) } diff --git a/src/main/kotlin/eu/europa/ec/eudi/pidissuer/adapter/out/pid/IssueMsoMdocPid.kt b/src/main/kotlin/eu/europa/ec/eudi/pidissuer/adapter/out/pid/IssueMsoMdocPid.kt index 1f71afa4..bec8f576 100644 --- a/src/main/kotlin/eu/europa/ec/eudi/pidissuer/adapter/out/pid/IssueMsoMdocPid.kt +++ b/src/main/kotlin/eu/europa/ec/eudi/pidissuer/adapter/out/pid/IssueMsoMdocPid.kt @@ -138,6 +138,7 @@ val PidMsoMdocV1: MsoMdocMetaData = run { JWSAlgorithm.ES256, ) MsoMdocMetaData( + id = CredentialUniqueId(PidMsoMdocScope.value), docType = pidDocType(1), display = pidDisplay, msoClaims = mapOf(pidAttributes), diff --git a/src/main/kotlin/eu/europa/ec/eudi/pidissuer/adapter/out/pid/IssueSdJwtVcPid.kt b/src/main/kotlin/eu/europa/ec/eudi/pidissuer/adapter/out/pid/IssueSdJwtVcPid.kt index dfd373b3..8dc4ef7d 100644 --- a/src/main/kotlin/eu/europa/ec/eudi/pidissuer/adapter/out/pid/IssueSdJwtVcPid.kt +++ b/src/main/kotlin/eu/europa/ec/eudi/pidissuer/adapter/out/pid/IssueSdJwtVcPid.kt @@ -86,6 +86,7 @@ private object Attributes { } val PidSdJwtVcV1: SdJwtVcMetaData = SdJwtVcMetaData( + id = CredentialUniqueId(PidSdJwtVcScope.value), type = SdJwtVcType(pidDocType(1)), display = pidDisplay, claims = Attributes.pidAttributes, diff --git a/src/main/kotlin/eu/europa/ec/eudi/pidissuer/domain/CredentialIssuerMetaData.kt b/src/main/kotlin/eu/europa/ec/eudi/pidissuer/domain/CredentialIssuerMetaData.kt index f8c108a7..d86de893 100644 --- a/src/main/kotlin/eu/europa/ec/eudi/pidissuer/domain/CredentialIssuerMetaData.kt +++ b/src/main/kotlin/eu/europa/ec/eudi/pidissuer/domain/CredentialIssuerMetaData.kt @@ -60,8 +60,8 @@ fun CredentialResponseEncryption.fold( /** * @param id The Credential Issuer's identifier - * @param authorizationServer Identifier of the OAuth 2.0 Authorization - * Server (as defined in [RFC8414]) the Credential Issuer relies on for authorization + * @param authorizationServers Identifiers of the OAuth 2.0 Authorization + * Servers (as defined in [RFC8414]) the Credential Issuer relies on for authorization * @param credentialEndPoint URL of the Credential Issuer's Credential Endpoint. * This URL MUST use the https scheme and MAY contain port, path, * and query parameter components @@ -79,7 +79,7 @@ fun CredentialResponseEncryption.fold( */ data class CredentialIssuerMetaData( val id: CredentialIssuerId, - val authorizationServer: HttpsUrl, + val authorizationServers: List, val credentialEndPoint: HttpsUrl, val batchCredentialEndpoint: HttpsUrl? = null, val deferredCredentialEndpoint: HttpsUrl? = null, diff --git a/src/main/kotlin/eu/europa/ec/eudi/pidissuer/domain/CredentialsOffer.kt b/src/main/kotlin/eu/europa/ec/eudi/pidissuer/domain/CredentialsOffer.kt index d2f50aa0..23e3750a 100644 --- a/src/main/kotlin/eu/europa/ec/eudi/pidissuer/domain/CredentialsOffer.kt +++ b/src/main/kotlin/eu/europa/ec/eudi/pidissuer/domain/CredentialsOffer.kt @@ -18,28 +18,25 @@ package eu.europa.ec.eudi.pidissuer.domain import arrow.core.Ior import java.time.Duration -/** - * A - */ -sealed interface CredentialOffer { - @JvmInline - value class ByScope(val value: Scope) : CredentialOffer - data class ByMetaData(val value: CredentialMetaData) : CredentialOffer -} - -data class AuthorizationCodeGrant(val issuerState: String? = null) +data class AuthorizationCodeGrant( + val issuerState: String? = null, + val authorizationServer: HttpsUrl? = null, +) @JvmInline value class PreAuthorizedCode(val value: String) + data class PreAuthorizedCodeGrant( val preAuthorizedCode: PreAuthorizedCode, val userPinRequired: Boolean = false, val interval: Duration, + val authorizationServer: HttpsUrl? = null, ) + typealias Grants = Ior data class CredentialsOffer( val credentialIssuer: CredentialIssuerId, - val grants: Grants, - val credentials: List, + val grants: Grants? = null, + val credentials: List, ) diff --git a/src/main/kotlin/eu/europa/ec/eudi/pidissuer/domain/JwtVcJsonProfile.kt b/src/main/kotlin/eu/europa/ec/eudi/pidissuer/domain/JwtVcJsonProfile.kt index 857a4b17..29a92284 100644 --- a/src/main/kotlin/eu/europa/ec/eudi/pidissuer/domain/JwtVcJsonProfile.kt +++ b/src/main/kotlin/eu/europa/ec/eudi/pidissuer/domain/JwtVcJsonProfile.kt @@ -29,7 +29,7 @@ const val JWT_VS_JSON_FORMAT = "jwt_vc_json" * W3C VC signed as a JWT, not using JSON-LD (jwt_vc_json) */ data class JwtVcJsonMetaData( - val id: String, + override val id: CredentialUniqueId, override val scope: Scope? = null, val cryptographicSuitesSupported: NonEmptySet, override val display: List, diff --git a/src/main/kotlin/eu/europa/ec/eudi/pidissuer/domain/MsoMdocProfile.kt b/src/main/kotlin/eu/europa/ec/eudi/pidissuer/domain/MsoMdocProfile.kt index 5695c1ba..0a323d6e 100644 --- a/src/main/kotlin/eu/europa/ec/eudi/pidissuer/domain/MsoMdocProfile.kt +++ b/src/main/kotlin/eu/europa/ec/eudi/pidissuer/domain/MsoMdocProfile.kt @@ -34,6 +34,7 @@ typealias MsoClaims = Map> * @param docType string identifying the credential type as defined in ISO.18013-5. */ data class MsoMdocMetaData( + override val id: CredentialUniqueId, val docType: MsoDocType, override val cryptographicBindingMethodsSupported: List, override val scope: Scope? = null, diff --git a/src/main/kotlin/eu/europa/ec/eudi/pidissuer/domain/SdJwtVcProfile.kt b/src/main/kotlin/eu/europa/ec/eudi/pidissuer/domain/SdJwtVcProfile.kt index f4f83362..86798e04 100644 --- a/src/main/kotlin/eu/europa/ec/eudi/pidissuer/domain/SdJwtVcProfile.kt +++ b/src/main/kotlin/eu/europa/ec/eudi/pidissuer/domain/SdJwtVcProfile.kt @@ -31,6 +31,7 @@ value class SdJwtVcType(val value: String) * @param type As defined in https://datatracker.ietf.org/doc/html/draft-ietf-oauth-sd-jwt-vc-00#type-claim */ data class SdJwtVcMetaData( + override val id: CredentialUniqueId, val type: SdJwtVcType, override val scope: Scope? = null, override val cryptographicBindingMethodsSupported: List = emptyList(), diff --git a/src/main/kotlin/eu/europa/ec/eudi/pidissuer/domain/Types.kt b/src/main/kotlin/eu/europa/ec/eudi/pidissuer/domain/Types.kt index ee3a68b3..c6af7d34 100644 --- a/src/main/kotlin/eu/europa/ec/eudi/pidissuer/domain/Types.kt +++ b/src/main/kotlin/eu/europa/ec/eudi/pidissuer/domain/Types.kt @@ -113,11 +113,18 @@ enum class ProofType { CWT, } +/** + * The unique identifier of an offered Credential. + */ +@JvmInline +value class CredentialUniqueId(val value: String) + /** * Representing metadata about a separate credential type * that the Credential Issuer can issue */ sealed interface CredentialMetaData { + val id: CredentialUniqueId val format: Format val scope: Scope? val display: List diff --git a/src/main/kotlin/eu/europa/ec/eudi/pidissuer/port/input/GetCredentialIssuerMetaData.kt b/src/main/kotlin/eu/europa/ec/eudi/pidissuer/port/input/GetCredentialIssuerMetaData.kt index b655e1bd..6ba2e245 100644 --- a/src/main/kotlin/eu/europa/ec/eudi/pidissuer/port/input/GetCredentialIssuerMetaData.kt +++ b/src/main/kotlin/eu/europa/ec/eudi/pidissuer/port/input/GetCredentialIssuerMetaData.kt @@ -30,8 +30,8 @@ class GetCredentialIssuerMetaData(private val credentialIssuerMetaData: Credenti data class CredentialIssuerMetaDataTO( @Required @SerialName("credential_issuer") val credentialIssuer: String, - @SerialName("authorization_server") - val authorizationServer: String? = null, + @SerialName("authorization_servers") + val authorizationServers: List? = null, @Required @SerialName("credential_endpoint") val credentialEndpoint: String, @SerialName("batch_credential_endpoint") @@ -44,12 +44,12 @@ data class CredentialIssuerMetaDataTO( val encryptionMethods: List = emptyList(), @SerialName("require_credential_response_encryption") val encryptionRequired: Boolean = false, - @Required @SerialName("credentials_supported") val credentialsSupported: List, + @Required @SerialName("credentials_supported") val credentialsSupported: JsonObject, ) private fun CredentialIssuerMetaData.toTransferObject(): CredentialIssuerMetaDataTO = CredentialIssuerMetaDataTO( credentialIssuer = id.externalForm, - authorizationServer = authorizationServer.externalForm, + authorizationServers = authorizationServers.map { it.externalForm }, credentialEndpoint = credentialEndPoint.externalForm, batchCredentialEndpoint = batchCredentialEndpoint?.externalForm, deferredCredentialEndpoint = deferredCredentialEndpoint?.externalForm, @@ -60,7 +60,7 @@ private fun CredentialIssuerMetaData.toTransferObject(): CredentialIssuerMetaDat required.encryptionMethods.map { it.name } }, encryptionRequired = credentialResponseEncryption.fold(false) { _ -> true }, - credentialsSupported = credentialsSupported.map { credentialMetaDataJson(it) }, + credentialsSupported = JsonObject(credentialsSupported.associate { it.id.value to credentialMetaDataJson(it) }), ) @OptIn(ExperimentalSerializationApi::class) diff --git a/src/main/kotlin/eu/europa/ec/eudi/pidissuer/port/input/RequestCredentialsOffer.kt b/src/main/kotlin/eu/europa/ec/eudi/pidissuer/port/input/RequestCredentialsOffer.kt index 0167d0f9..c6d36a91 100644 --- a/src/main/kotlin/eu/europa/ec/eudi/pidissuer/port/input/RequestCredentialsOffer.kt +++ b/src/main/kotlin/eu/europa/ec/eudi/pidissuer/port/input/RequestCredentialsOffer.kt @@ -15,16 +15,11 @@ */ package eu.europa.ec.eudi.pidissuer.port.input -import arrow.core.Either -import arrow.core.leftIor -import arrow.core.right +import arrow.core.* import eu.europa.ec.eudi.pidissuer.adapter.out.pid.PidMsoMdocV1 import eu.europa.ec.eudi.pidissuer.domain.* import kotlinx.serialization.* import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.put import java.net.URI import java.net.URLEncoder import java.nio.charset.StandardCharsets @@ -32,25 +27,29 @@ import java.nio.charset.StandardCharsets @Serializable data class AuthorizationCodeGrantTO( @SerialName("issuer_state") val issuerState: String?, + @SerialName("authorization_server") val authorizationServer: String? = null, ) @Serializable data class PreAuthorizedCodeGrantTO( @Required @SerialName("pre-authorized_code") val preAuthorizedCode: String, @SerialName("user_pin_required") val userPinRequired: Boolean = false, + @SerialName("authorization_server") val authorizationServer: String? = null, ) @Serializable data class GrantsTO( - @SerialName("authorization_code") val authorizedCodeGrant: AuthorizationCodeGrantTO? = null, - @SerialName("urn:ietf:params:oauth:grant-type:pre-authorized_code") val preAuthorizedCodeGrant: PreAuthorizedCodeGrantTO? = null, + @SerialName("authorization_code") + val authorizedCodeGrant: AuthorizationCodeGrantTO? = null, + @SerialName("urn:ietf:params:oauth:grant-type:pre-authorized_code") + val preAuthorizedCodeGrant: PreAuthorizedCodeGrantTO? = null, ) @Serializable data class CredentialsOfferTO( @Required @SerialName("credential_issuer") val credentialIssuer: String, - val grants: GrantsTO, - val credentials: List, + val grants: GrantsTO? = null, + val credentials: List, ) @Serializable @@ -78,16 +77,21 @@ class RequestCredentialsOffer( // TODO This is a dummy method // To be removed fun dummyOffer(): CredentialsOffer { - val metaData = credentialIssuerMetaData - .credentialsSupported - .filterIsInstance() - .find { it.docType == PidMsoMdocV1.docType }!! - val credentialOffer = metaData.scope?.let { CredentialOffer.ByScope(it) } - ?: CredentialOffer.ByMetaData(metaData) + // send the user to the first authorization server, in case we advertise multiple + val authorizationServer = + credentialIssuerMetaData.authorizationServers + .takeIf { it.size > 1 } + ?.first() + val credentials = + credentialIssuerMetaData.credentialsSupported + .filterIsInstance() + .firstOrNone { it.docType == PidMsoMdocV1.docType } + .map { listOf(it.id) } + .getOrElse { emptyList() } return CredentialsOffer( credentialIssuer = credentialIssuerMetaData.id, - grants = AuthorizationCodeGrant().leftIor(), - credentials = listOf(credentialOffer), + grants = AuthorizationCodeGrant(authorizationServer = authorizationServer).leftIor(), + credentials = credentials, ) } } @@ -97,10 +101,14 @@ class RequestCredentialsOffer( // private fun Grants.toTransferObject(): GrantsTO { - fun AuthorizationCodeGrant.toTransferObject() = AuthorizationCodeGrantTO(issuerState) + fun AuthorizationCodeGrant.toTransferObject() = AuthorizationCodeGrantTO( + issuerState = issuerState, + authorizationServer = authorizationServer?.externalForm, + ) fun PreAuthorizedCodeGrant.toTransferObject() = PreAuthorizedCodeGrantTO( preAuthorizedCode = preAuthorizedCode.value, userPinRequired = userPinRequired, + authorizationServer = authorizationServer?.externalForm, ) return fold( fa = { GrantsTO(it.toTransferObject(), null) }, @@ -109,28 +117,10 @@ private fun Grants.toTransferObject(): GrantsTO { ) } -private fun CredentialOffer.toTransferObject(): JsonElement = - when (this) { - is CredentialOffer.ByScope -> buildJsonObject { - val scope = this@toTransferObject.value - put("scope", scope.value) - } - - is CredentialOffer.ByMetaData -> buildJsonObject { - val metaData = this@toTransferObject.value - put("format", metaData.format.value) - when (metaData) { - is JwtVcJsonMetaData -> TODO() - is MsoMdocMetaData -> metaData.toTransferObject(true)(this) - is SdJwtVcMetaData -> metaData.toTransferObject(true)(this) - } - } - } - internal fun CredentialsOffer.toTransferObject(): CredentialsOfferTO = CredentialsOfferTO( credentialIssuer = credentialIssuer.externalForm, - grants = grants.toTransferObject(), - credentials = credentials.map { it.toTransferObject() }, + grants = grants?.toTransferObject(), + credentials = credentials.map { it.value }, ) @OptIn(ExperimentalSerializationApi::class)