Search code examples
kotlinktor

Cannot obtain jwks from url http://localhost:8080/.well-known/jwks.json when using RSA


I have set up my ktor backend to support RSA verification for JWT keys. While everything was working fine earlier, suddenly I am getting the following error:

Exception in thread "main" com.auth0.jwk.NetworkException: Cannot obtain jwks from url http://localhost:8080/.well-known/jwks.json at com.auth0.jwk.UrlJwkProvider.getJwks(UrlJwkProvider.java:139) at com.auth0.jwk.UrlJwkProvider.getAll(UrlJwkProvider.java:145) at com.auth0.jwk.UrlJwkProvider.get(UrlJwkProvider.java:163) at com.auth0.jwk.RateLimitedJwkProvider.get(RateLimitedJwkProvider.java:28) at com.auth0.jwk.GuavaCachedJwkProvider.lambda$get$0(GuavaCachedJwkProvider.java:62) at com.google.common.cache.LocalCache$LocalManualCache$1.load(LocalCache.java:4925) at com.google.common.cache.LocalCache$LoadingValueReference.loadFuture(LocalCache.java:3571) at com.google.common.cache.LocalCache$Segment.loadSync(LocalCache.java:2313) at com.google.common.cache.LocalCache$Segment.lockedGetOrLoad(LocalCache.java:2190) at com.google.common.cache.LocalCache$Segment.get(LocalCache.java:2080) at com.google.common.cache.LocalCache.get(LocalCache.java:4012) at com.google.common.cache.LocalCache$LocalManualCache.get(LocalCache.java:4920) at com.auth0.jwk.GuavaCachedJwkProvider.get(GuavaCachedJwkProvider.java:62) at app.joblink.services.JwtService.getRSAPublicKey(JwtService.kt:41) at app.joblink.services.JwtService.access$getRSAPublicKey(JwtService.kt:19) at app.joblink.services.JwtService$jwtVerifier$2.invoke(JwtService.kt:28) at app.joblink.services.JwtService$jwtVerifier$2.invoke(JwtService.kt:26) at kotlin.SynchronizedLazyImpl.getValue(LazyJVM.kt:74) at app.joblink.services.JwtService.getJwtVerifier(JwtService.kt:26) at app.joblink.plugins.SecurityKt$configureSecurity$1$1.invoke(Security.kt:15) at app.joblink.plugins.SecurityKt$configureSecurity$1$1.invoke(Security.kt:13) at io.ktor.server.auth.jwt.JWTAuthKt.jwt(JWTAuth.kt:324) at app.joblink.plugins.SecurityKt$configureSecurity$1.invoke(Security.kt:13) at app.joblink.plugins.SecurityKt$configureSecurity$1.invoke(Security.kt:12) at io.ktor.server.auth.Authentication$Companion.install(Authentication.kt:98) at io.ktor.server.auth.Authentication$Companion.install(Authentication.kt:94) at io.ktor.server.application.ApplicationPluginKt.install(ApplicationPlugin.kt:100) at app.joblink.plugins.SecurityKt.configureSecurity(Security.kt:12) at app.joblink.ApplicationKt.module(Application.kt:14) at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) at java.base/java.lang.reflect.Method.invoke(Method.java:578) at kotlin.reflect.jvm.internal.calls.CallerImpl$Method.callMethod(CallerImpl.kt:97) at kotlin.reflect.jvm.internal.calls.CallerImpl$Method$Static.call(CallerImpl.kt:106) at kotlin.reflect.jvm.internal.KCallableImpl.callDefaultMethod$kotlin_reflection(KCallableImpl.kt:188) at kotlin.reflect.jvm.internal.KCallableImpl.callBy(KCallableImpl.kt:111) at io.ktor.server.engine.internal.CallableUtilsKt.callFunctionWithInjection(CallableUtils.kt:119) at io.ktor.server.engine.internal.CallableUtilsKt.executeModuleFunction(CallableUtils.kt:36) at io.ktor.server.engine.ApplicationEngineEnvironmentReloading$launchModuleByName$1.invoke(ApplicationEngineEnvironmentReloading.kt:332) at io.ktor.server.engine.ApplicationEngineEnvironmentReloading$launchModuleByName$1.invoke(ApplicationEngineEnvironmentReloading.kt:331) at io.ktor.server.engine.ApplicationEngineEnvironmentReloading.avoidingDoubleStartupFor(ApplicationEngineEnvironmentReloading.kt:356) at io.ktor.server.engine.ApplicationEngineEnvironmentReloading.launchModuleByName(ApplicationEngineEnvironmentReloading.kt:331) at io.ktor.server.engine.ApplicationEngineEnvironmentReloading.access$launchModuleByName(ApplicationEngineEnvironmentReloading.kt:32) at io.ktor.server.engine.ApplicationEngineEnvironmentReloading$instantiateAndConfigureApplication$1.invoke(ApplicationEngineEnvironmentReloading.kt:312) at io.ktor.server.engine.ApplicationEngineEnvironmentReloading$instantiateAndConfigureApplication$1.invoke(ApplicationEngineEnvironmentReloading.kt:310) at io.ktor.server.engine.ApplicationEngineEnvironmentReloading.avoidingDoubleStartup(ApplicationEngineEnvironmentReloading.kt:338) at io.ktor.server.engine.ApplicationEngineEnvironmentReloading.instantiateAndConfigureApplication(ApplicationEngineEnvironmentReloading.kt:310) at io.ktor.server.engine.ApplicationEngineEnvironmentReloading.createApplication(ApplicationEngineEnvironmentReloading.kt:150) at io.ktor.server.engine.ApplicationEngineEnvironmentReloading.start(ApplicationEngineEnvironmentReloading.kt:277) at io.ktor.server.netty.NettyApplicationEngine.start(NettyApplicationEngine.kt:216) at io.ktor.server.netty.EngineMain.main(EngineMain.kt:23) at app.joblink.ApplicationKt.main(Application.kt:34) at app.joblink.ApplicationKt.main(Application.kt) Caused by: java.net.ConnectException: Connection refused at java.base/sun.nio.ch.Net.connect0(Native Method) at java.base/sun.nio.ch.Net.connect(Net.java:580) at java.base/sun.nio.ch.Net.connect(Net.java:569) at java.base/sun.nio.ch.NioSocketImpl.connect(NioSocketImpl.java:581) at java.base/java.net.Socket.connect(Socket.java:666) at java.base/java.net.Socket.connect(Socket.java:600) at java.base/sun.net.NetworkClient.doConnect(NetworkClient.java:183) at java.base/sun.net.www.http.HttpClient.openServer(HttpClient.java:532) at java.base/sun.net.www.http.HttpClient.openServer(HttpClient.java:637) at java.base/sun.net.www.http.HttpClient.(HttpClient.java:280) at java.base/sun.net.www.http.HttpClient.New(HttpClient.java:385) at java.base/sun.net.www.http.HttpClient.New(HttpClient.java:407) at java.base/sun.net.www.protocol.http.HttpURLConnection.getNewHttpClient(HttpURLConnection.java:1308) at java.base/sun.net.www.protocol.http.HttpURLConnection.plainConnect0(HttpURLConnection.java:1241) at java.base/sun.net.www.protocol.http.HttpURLConnection.plainConnect(HttpURLConnection.java:1127) at java.base/sun.net.www.protocol.http.HttpURLConnection.connect(HttpURLConnection.java:1056) at java.base/sun.net.www.protocol.http.HttpURLConnection.getInputStream0(HttpURLConnection.java:1657) at java.base/sun.net.www.protocol.http.HttpURLConnection.getInputStream(HttpURLConnection.java:1581) at com.auth0.jwk.UrlJwkProvider.getJwks(UrlJwkProvider.java:135) ... 51 more

The trace points to my JwtService class, but I don't understand why. My .well-known/jwks.json path is set up in my Routing plugin and used to be accessible just fine.

Could this be an issue with D.I. and the order that everything is processed? Here is the service class:

class JwtService(
    private val jwtAudience: String,
    private val jwtIssuer: String,
    private val jwtRealm: String,
    private val jwtPK: String,
    private val userDao: UserDao
) {
    val jwtVerifier: JWTVerifier by lazy {
        JWT
            .require(Algorithm.RSA256(getRSAPublicKey(), getRSAPrivateKey()))
            .withAudience(jwtAudience)
            .withIssuer(jwtIssuer)
            .build()
    }

    private val jwkProvider by lazy {
        JwkProviderBuilder(jwtIssuer)
            .cached(10, 24, TimeUnit.HOURS)
            .rateLimited(10, 1, TimeUnit.MINUTES)
            .build()
    }

    private fun getRSAPublicKey() = jwkProvider["some-key"].publicKey as RSAPublicKey

    private fun getRSAPrivateKey(): RSAPrivateKey {
        val keySpecPKCS8 = PKCS8EncodedKeySpec(Base64.getDecoder().decode(jwtPK))
        return KeyFactory.getInstance("RSA").generatePrivate(keySpecPKCS8) as RSAPrivateKey
    }

    fun createAccessToken(email: String): String = createJwtToken(email, AUTH_CONSTS.ACCESS_TOKEN_EXPIRES_IN)

    fun createRefreshToken(email: String): String = createJwtToken(email, AUTH_CONSTS.REFRESH_TOKEN_EXPIRES_IN)

    private fun createJwtToken(email: String, expireIn: Int): String =
        JWT.create()
            .withAudience(jwtAudience)
            .withIssuer(jwtIssuer)
            .withClaim("email", email)
            .withExpiresAt(Date(System.currentTimeMillis() + expireIn))
            .sign(Algorithm.RSA256(getRSAPublicKey(), getRSAPrivateKey()))


    suspend fun customValidator(
        credential: JWTCredential
    ): JWTPrincipal? {
        val email: String? = extractEmail(credential)
        val userFoundWithEmail: Boolean = email?.let { userDao.userExistsWithEmail(it) } ?: false

        return if (userFoundWithEmail && audienceMatches(credential)) {
            JWTPrincipal(credential.payload)
        } else null
    }

    private fun audienceMatches(
        credential: JWTCredential
    ): Boolean = credential.payload.audience.contains(jwtAudience)

    fun audienceMatches(audience: String): Boolean = jwtAudience == audience

    private fun extractEmail(credential: JWTCredential): String? = credential.payload.getClaim("email").asString()
    fun getRealm(): String = jwtRealm
}

My Application:

fun Application.module() {
    configureHTTP()
    configureDependencyInjection()
    configureSerialization()
    configureSecurity()
    configureRouting()
    install(DefaultHeaders)
    install(Compression) {
        gzip() {
            priority = 0.9
            minimumSize(1024)
        }
        deflate() {
            priority = 1.0
            matchContentType(
                ContentType.Text.Any,
                ContentType.Application.Json
            )
            minimumSize(1024)
        }
    }
}

fun main() {
    EngineMain.main(emptyArray())
}

The Routing module contains:

staticFiles("/.well-known/jwks.json", File("certs"), index = "jwks.json").

and for the koin appModule, the definition is:

val appModule = module {
    // services
    single {
        val applicationConfig = ConfigFactory.load()
        JwtService(
            applicationConfig.getString("jwt.audience"),
            applicationConfig.getString("jwt.issuer"),
            applicationConfig.getString("jwt.realm"),
            applicationConfig.getString("jwt.privateKey"),
            get()
        )
    }
    single { UserService(get(), get(), get(), get(), get()) }
}

The offending line seems to be the commented-out one that you can see below in my configureSecurity function:

fun Application.configureSecurity() {
    val jwtService: JwtService by inject()

    install(Authentication) {
        jwt("auth-jwt") {
            realm = jwtService.getRealm()
//            verifier(jwtService.jwtVerifier)

            validate { credential ->
                jwtService.customValidator(credential)
            }
        }
    }
}

Solution

  • Although I am still unsure, as to what the actual is may be, I have managed to find a fix.

    Instead of relying on the JwtService to provide the verifier instance, I went with the approach of defining it within the module, as seen in the ktor docs. Now, my configureSecurity() looks like this:

    fun Application.configureSecurity() {
        val jwtService: JwtService by inject()
    
        val issuer = environment.config.property("jwt.issuer").getString()
        val audience = environment.config.property("jwt.audience").getString()
        val myRealm = environment.config.property("jwt.realm").getString()
        val jwkProvider = JwkProviderBuilder(issuer)
            .cached(10, 24, TimeUnit.HOURS)
            .rateLimited(10, 1, TimeUnit.MINUTES)
            .build()
    
        install(Authentication) {
            jwt("auth-jwt") {
                realm = myRealm
                verifier(jwkProvider, issuer) {
                    withAudience(audience)
                    withIssuer(issuer)
                    acceptLeeway(3)
                }
    
                validate { credential ->
                    jwtService.customValidator(credential)
                }
            }
        }
    }
    

    This seems to fix the crash and everything works just fine. As to the root cause of the original issue, I have only managed to pinpoint that it was being caused by the jwtService.jwtVerifier instance being used. That seemed to be calling the getRSAPublicKey() which would then attempt to create the jwkProvider lazily, and then crash.

    private val jwkProvider by lazy {
        JwkProviderBuilder(jwtIssuer)
            .cached(10, 24, TimeUnit.HOURS)
            .rateLimited(10, 1, TimeUnit.MINUTES)
            .build()
    }
    
     private fun getRSAPublicKey() = jwkProvider["3nGxSDQMSbeyMhuFT79exJ2hfnP8am"].publicKey as RSAPublicKey
    

    If someone figures out a fix for the issue while still using the JwtService class, I will accept that answer.