diff --git a/core/src/main/kotlin/io/github/nefilim/kjwt/Serializers.kt b/core/src/main/kotlin/io/github/nefilim/kjwt/Serializers.kt index 3fe4256..ed4a168 100644 --- a/core/src/main/kotlin/io/github/nefilim/kjwt/Serializers.kt +++ b/core/src/main/kotlin/io/github/nefilim/kjwt/Serializers.kt @@ -9,6 +9,8 @@ import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder +class UnsupportedAlgorithmException(val alg: String): Exception("unsupported JWS algorithm: [$alg]") + private val AllJWSAlgorithmToHeaderIDs = AllAlgorithms.associateBy { it.headerID } @OptIn(ExperimentalSerializationApi::class) @@ -16,7 +18,8 @@ private val AllJWSAlgorithmToHeaderIDs = AllAlgorithms.associateBy { it.headerID object JWSAlgorithmSerializer: KSerializer { override fun deserialize(decoder: Decoder): JWSAlgorithm { - return AllJWSAlgorithmToHeaderIDs[decoder.decodeString().trim().uppercase()] ?: throw IllegalArgumentException() + val alg = decoder.decodeString().trim().uppercase() + return AllJWSAlgorithmToHeaderIDs[alg] ?: throw UnsupportedAlgorithmException("unknown JWSAlgorithm: [$alg]") } override fun serialize(encoder: Encoder, value: JWSAlgorithm) { diff --git a/core/src/test/kotlin/io/github/nefilim/kjwt/JWTSpec.kt b/core/src/test/kotlin/io/github/nefilim/kjwt/JWTSpec.kt index a7345b4..8fbc173 100644 --- a/core/src/test/kotlin/io/github/nefilim/kjwt/JWTSpec.kt +++ b/core/src/test/kotlin/io/github/nefilim/kjwt/JWTSpec.kt @@ -30,12 +30,15 @@ import io.kotest.assertions.arrow.core.shouldBeSome import io.kotest.assertions.arrow.core.shouldBeValid import io.kotest.core.spec.style.WordSpec import io.kotest.matchers.shouldBe +import mu.KotlinLogging import java.time.Instant import java.time.LocalDateTime import java.time.ZoneId import com.nimbusds.jwt.SignedJWT as NimbusSignedJWT class JWTSpec: WordSpec() { + private val logger = KotlinLogging.logger { } + init { "JWT" should { "build JWT claim set" { @@ -143,7 +146,7 @@ class JWTSpec: WordSpec() { val signedJWT = rawJWT.sign(privateKey) signedJWT.shouldBeRight().also { - println(it.rendered) + logger.info { it.rendered } NimbusSignedJWT.parse(it.rendered).verify(ECDSAVerifier(publicKey)) shouldBe true verifySignature(it.rendered, publicKey, pair.second).shouldBeRight() shouldBe it.jwt } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 54734f5..a13b38d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] -kotlin = "1.6.10" -kotlinXCoroutines = "1.6.0" +kotlin = "1.6.21" +kotlinXCoroutines = "1.6.1" kotlinXSerialization = "1.3.2" # third party diff --git a/jwks/build.gradle.kts b/jwks/build.gradle.kts index 614cdee..4bb2f39 100644 --- a/jwks/build.gradle.kts +++ b/jwks/build.gradle.kts @@ -12,6 +12,7 @@ dependencies { platform(libs.arrow.stack), libs.arrow.core, libs.kotlin.reflect, + libs.kotlinLogging, libs.kotlinx.coroutines.core, libs.kotlinx.serialization.json, ).map { @@ -22,7 +23,6 @@ dependencies { libs.kotest.runner, libs.kotest.assertions.core, libs.kotest.assertions.arrow, - libs.kotlinLogging, libs.logbackClassic, ).map { testImplementation(it) diff --git a/jwks/src/main/kotlin/io/github/nefilim/kjwt/jwks/JWKS.kt b/jwks/src/main/kotlin/io/github/nefilim/kjwt/jwks/JWKS.kt index b9752e4..8b04be2 100644 --- a/jwks/src/main/kotlin/io/github/nefilim/kjwt/jwks/JWKS.kt +++ b/jwks/src/main/kotlin/io/github/nefilim/kjwt/jwks/JWKS.kt @@ -1,16 +1,8 @@ package io.github.nefilim.kjwt.jwks -import arrow.core.Either +import arrow.core.* import arrow.core.computations.either -import arrow.core.flatMap -import arrow.core.left -import arrow.core.right -import arrow.core.sequenceEither -import io.github.nefilim.kjwt.JWSAlgorithm -import io.github.nefilim.kjwt.JWSAlgorithmSerializer -import io.github.nefilim.kjwt.JWSECDSAAlgorithm -import io.github.nefilim.kjwt.JWSRSAAlgorithm -import io.github.nefilim.kjwt.JWTKeyID +import io.github.nefilim.kjwt.* import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.currentCoroutineContext @@ -24,10 +16,14 @@ import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.isActive import kotlinx.coroutines.withContext -import kotlinx.serialization.PolymorphicSerializer -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable +import kotlinx.serialization.* +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.jsonArray import java.math.BigInteger import java.net.URL import java.security.AlgorithmParameters @@ -48,6 +44,9 @@ import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds import kotlin.time.DurationUnit import kotlin.time.ExperimentalTime +import mu.KotlinLogging + +private val logger = KotlinLogging.logger { } @Serializable data class JWK( @@ -139,10 +138,38 @@ data class JWK( } @Serializable -data class JWKS( - val keys: List> +data class JWKS( + @Serializable(JWKListSerializer::class) val keys: List> ) +object JWKListSerializer: KSerializer>> { + override fun deserialize(decoder: Decoder): List> { + return with(decoder as JsonDecoder) { + decodeJsonElement().jsonArray.mapNotNull { + try { + json.decodeFromJsonElement(JWK.serializer(PolymorphicSerializer(JWSAlgorithm::class)), it) + } catch (e: Exception) { + when (e) { + is SerializationException, is UnsupportedAlgorithmException -> { + logger.warn { "ignoring JWK with deserialization problem $e " } + null + } + else -> + throw e + } + } + } + } + } + + private val listSerializer = ListSerializer(JWK.serializer(PolymorphicSerializer(JWSAlgorithm::class))) + override val descriptor: SerialDescriptor = listSerializer.descriptor + + override fun serialize(encoder: Encoder, value: List>) { + listSerializer.serialize(encoder, value) + } +} + sealed interface JWKError { object AlgorithmKeyMismatch: JWKError data class InvalidKey(val cause: Throwable): JWKError @@ -207,7 +234,9 @@ object WellKnownJWKSProvider { either { val json = jwksJSONProvider(context, coroutineContext).bind() val jwks = Either.catch { - WellKnownJWKSProvider.json.decodeFromString(JWKS.serializer(PolymorphicSerializer(JWSAlgorithm::class)), json) +// WellKnownJWKSProvider.json.decodeFromString(JWKS.serializer(PolymorphicSerializer(JWSAlgorithm::class)), json) + WellKnownJWKSProvider.json.decodeFromString(JWKS.serializer(), json) +// WellKnownJWKSProvider.json.decodeFromString(JWKListSerializer, json) }.mapLeft { JWKError.Exceptionally(it) }.bind() jwks.keys.map { jwk -> jwk.build

().map { jwk.keyID to it } }.sequenceEither().bind().toMap() } diff --git a/jwks/src/test/kotlin/io/github/nefilim/kjwt/jwks/JWKSpec.kt b/jwks/src/test/kotlin/io/github/nefilim/kjwt/jwks/JWKSpec.kt index d8b29cd..21626d9 100644 --- a/jwks/src/test/kotlin/io/github/nefilim/kjwt/jwks/JWKSpec.kt +++ b/jwks/src/test/kotlin/io/github/nefilim/kjwt/jwks/JWKSpec.kt @@ -83,6 +83,9 @@ class JWKSSpec: WordSpec() { } }) } + "gracefully handle unsupported algorithms" { + WellKnownJWKSProvider.json.decodeFromString(JWKS.serializer(), UnsupportedAlgorithmInJWK).keys.size shouldBe 2 + } } "CachedJWKS" should { "refresh cache" { @@ -92,7 +95,7 @@ class JWKSSpec: WordSpec() { repeat(3) { this().shouldBeRight() this.getKey(keyID).shouldBeRight().shouldBeInstanceOf().also { - println("got key: $it") + logger.debug { "got key: $it" } it shouldNotBe (previousKey) previousKey = it } @@ -142,4 +145,47 @@ val RSAJWK = """ "MIIDDTCCAfWgAwIBAgIJIX+nIZf+r5uAMA0GCSqGSIb3DQEBCwUAMCQxIjAgBgNVBAMTGWRldi0xeGxqcjJhdC51cy5hdXRoMC5jb20wHhcNMjEwOTEzMTUyNzU3WhcNMzUwNTIzMTUyNzU3WjAkMSIwIAYDVQQDExlkZXYtMXhsanIyYXQudXMuYXV0aDAuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAyqjDsay4m+HI1Sx9P2kltQk98TDLfiX+RbdDhLvV/A+Ukj/Y3YwBhraR/WBBAu8U+LIpXwgCd8rypEiYeU3QQGtcUijFDuIA2LmP33WKRG36sSExiaiQA/4Xw+RdvdQuW1Av9PXcVKjLMJvQG+9zYnUUEKpZR49cPh0eBr2sk2Vh/z5end3Xe+5FVGkI1CoIlVeOprUENKDCYq68T7RXEA5GuA4zdRJYv7yt1ore5KundhBg3+TTFTk0HhzaM2qKOKO4Jb56NEsJiPAI5tfSqElpiyst1RDm9AtiwJRTkyJFE0pnLY692KFV0YMopOKJPLcG5eCr2L5Vui9se10eQQIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBSkI22sJou33JK1A5ya+aUmhJqNGTAOBgNVHQ8BAf8EBAMCAoQwDQYJKoZIhvcNAQELBQADggEBAGxr8smJmu/NcDPVsyeulnaWJZmUZ/8vz1bud/yn9jJCS/5F5E8UvU+o3yqY+vmbmBEVGJvqgAxsYX7/7sulYknI+ZPaVmOgFyLM2UpxwQ7TWoTu0i5Vx5/0f6J7UuGIktOySjJFJoKonDJI90bTxD3fwXzgUAzcaAfVQ/gVbjXkQhV+Xysbtotl3nB/lxHuQ0JtOYHljoXuGgXimPtZLIz+FTnkN7kgbwF9Clr/WJUyd/o1fa8CZYKD8MLiu7VFoNXB7wi4elWVPmnlgFDaxPzfBxmR6BXI1xLeEgJgbb2ZC3+vW9xJmgGzBxPknt3ufU1aqcVwRa7UsTY9iLu47LM=" ] } +""".trimIndent() + + +val UnsupportedAlgorithmInJWK = """ +{ +"keys": [ +{ +"kid": "5NVqLy00rEUpMYV7evCXdQuIZ4zEgId-5LfM470iBpU", +"kty": "RSA", +"alg": "RSA-OAEP", +"use": "enc", +"n": "gYRpL9-iFMgo2yb7kS0T9h3kxfeeWYfpHgeKgo2BBgvE6cQ2lJ9ltb0YJg9f6cGLLhyZTyiM_eDMVWwiOLKUS6wAG4pYozNdUXGRovdfEjXz6EhCteIk0AImth1SoNsv5Vb_HwAkMj32lKBnByb-SSAbgVGSZ2MnZHIqOZUU4MLmlFBkhC6CmeDtXnfUlUkrixf8T-EepizJphOCQWcfrzSoQQDRlZqvBYlIHIUHKPcoliolrL5xowYBRaymTjUs0-G8iaEJYzLt0cGdkt4Ni3Zb3F6EvvieiCFJUVVg-bDe_0Lj78YQFb2CqGqIFijdfDSeZgqxYbiq4Nh7lcB32Q", +"e": "AQAB", +"x5c": [ +"MIICrzCCAZcCBgGAPpFxtDANBgkqhkiG9w0BAQsFADAbMRkwFwYDVQQDDBBmaWd1cmUtZW1wbG95ZWVzMB4XDTIyMDQxODIxMjQyMFoXDTMyMDQxODIxMjYwMFowGzEZMBcGA1UEAwwQZmlndXJlLWVtcGxveWVlczCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAIGEaS/fohTIKNsm+5EtE/Yd5MX3nlmH6R4HioKNgQYLxOnENpSfZbW9GCYPX+nBiy4cmU8ojP3gzFVsIjiylEusABuKWKMzXVFxkaL3XxI18+hIQrXiJNACJrYdUqDbL+VW/x8AJDI99pSgZwcm/kkgG4FRkmdjJ2RyKjmVFODC5pRQZIQugpng7V531JVJK4sX/E/hHqYsyaYTgkFnH680qEEA0ZWarwWJSByFByj3KJYqJay+caMGAUWspk41LNPhvImhCWMy7dHBnZLeDYt2W9xehL74noghSVFVYPmw3v9C4+/GEBW9gqhqiBYo3Xw0nmYKsWG4quDYe5XAd9kCAwEAATANBgkqhkiG9w0BAQsFAAOCAQEARNqHxasLdYHlwd8IEWYQ4C+HLZKs/RhznVPh75B++1gVcnJHvbvR5op2OlKivqWPIqd2hqxUn5EqTyd1iBfpdV+Ksfk7t8gCkgR+njtrKH+2zjv0ogVVXsya2j/xS+NZYf11/ozZv4DTYrd/IOwHU1wK9trTWZ1BpiBlmS1/38xxQs1g4j0CxYIj9DR7Y53kX+WlXZ8ajsm3k27f0Rad2utYwdPMc3FVjIp23H8URJBB+VH3Ikdk0uNWGPvst88UVu4KqlJilodyhSxRNtT3GuwLkwjFA275fN4J2W+H9C31L8JqgbyLtIqMXIS91fnspMFbav+FZII0oNA0wWVQjg==" +], +"x5t": "Rph0qHDDb9K5EJE-GFPKheTa_88", +"x5t#S256": "1RgKt1HZm5sM82NxqTb8Kv4OOflELieeLcWNZLf8msg" +}, +{ +"kid": "xY5PjYZLe0YrI2T8_3-SouOTHC3TQjDHKLw15VQAL7M", +"kty": "RSA", +"alg": "RS256", +"use": "sig", +"n": "jr0Yu-Mwe8tuVP12KDydtsd2MfHT_dkCVhuaFZx_u7798IXGT9f_SUtzV4fmy2g5HIKqKrZ5C7aCAe6MzTwWBmmY2gzh2f4Yp3alMavyzVLBIoTnYC_Vx_NgAyYH3cZqt2O5hUz3MonKV8YQ1FpLgzvDIEA04tWi3dn3Ku3pr-ZHRwaRYZ9JGDJnj5fVM9Db8pjMpxLEb1YD0JSb8OGu4WwePHs2K_9vXJ9z3UKe1SFjMBhhvOnRxtawhm7DgS8CpPJBEhzlNE-VY4d0CasD42QUBXJJpS-r7VZjBBUD0oYYs_fsGyuIUOPvxRYLm8QODLSkIxP7R_KZ8k7djY4OBw", +"e": "AQAB", +"x5c": [ +"MIICrzCCAZcCBgGAPpFxFzANBgkqhkiG9w0BAQsFADAbMRkwFwYDVQQDDBBmaWd1cmUtZW1wbG95ZWVzMB4XDTIyMDQxODIxMjQyMFoXDTMyMDQxODIxMjYwMFowGzEZMBcGA1UEAwwQZmlndXJlLWVtcGxveWVlczCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAI69GLvjMHvLblT9dig8nbbHdjHx0/3ZAlYbmhWcf7u+/fCFxk/X/0lLc1eH5stoORyCqiq2eQu2ggHujM08FgZpmNoM4dn+GKd2pTGr8s1SwSKE52Av1cfzYAMmB93GardjuYVM9zKJylfGENRaS4M7wyBANOLVot3Z9yrt6a/mR0cGkWGfSRgyZ4+X1TPQ2/KYzKcSxG9WA9CUm/DhruFsHjx7Niv/b1yfc91CntUhYzAYYbzp0cbWsIZuw4EvAqTyQRIc5TRPlWOHdAmrA+NkFAVySaUvq+1WYwQVA9KGGLP37BsriFDj78UWC5vEDgy0pCMT+0fymfJO3Y2ODgcCAwEAATANBgkqhkiG9w0BAQsFAAOCAQEAeyrjsrt7Wudt0nCV0FQj/+7qrosuhKLMNmxjU87bXu9TgD1FtZNWxO9fGvraoR1wqqD8mH+hJL6ZF8nsq1Z3+fg2h1ckRkBX7zfF4v6B7h0EXfE2LPw9FoH39D75xPEdqlaA10URO/ExJxBUrv8nWuksjPnZ8TZMN7Ha+keZKbissVKNFBfXQ3MytQ31/79tjS1yUOcMuMmZ0WFJcI8CFnIXpxQoelxemN0hgSm1u0Z6wiD/ksKOk2FQeLpeuhejd6NfTpIQ6dVvFJZYpL+KGQhbwy7D98QxgatiC/6/g3o8C88G/9/rggaUnJg8msXDG8Bz2jc4X18t8leoLGJV2g==" +], +"x5t": "89b8Q_tnyMA9D6M_H5HyY51hbYc", +"x5t#S256": "w8sR66yFksSyqOJE9Aev6sX-stHbRaIyM1DhUe3zB_g" +}, +{ +"kid": "or06hiTwV9hLyp9giNGVs853jhHhEMRBbeD39fphP6w", +"kty": "EC", +"alg": "ES256", +"use": "sig", +"crv": "P-256", +"x": "y3jWqPjKVR_d-dzU-Zj5WEFClSd8uNKFew_MJ07HDTA", +"y": "WEVEjjM59EPhwCdI_X80Jzb8qJTV0Snlmnpgjqn2Y-o" +} +] +} """.trimIndent() \ No newline at end of file