banner
Hi my new friend!

测试使用 JwkProvider 的 Ktor 应用

Scroll down

为了追求新技术装13,我们决定使用 Ktor 来开发我们的后端服务。

不得不说,Ktor 的 DSL 式路由确实很直观,协程写起了也很爽。Kotlin 的letapplyrunwith直接让我们全员变身魔法师,Result让 Rust 来的小朋友宾至如归。然而,就在我们写完一个模块的 routing,准备测试的时候,我们遇到了问题。

不听话的 JwkProvider

Ktor 的Authentication模块提供了JWT认证,并且支持JwkProviderBuilder来动态获取JWK。这样我们只需要对/well-known/jwks.json进行路由,就可以动态获取JWK,然后愉快地使用JWT进行认证了。

测试的小伙伴把测试代码 push 到了仓库,拉取下来之后,发现测试一直报错。经过一番排查,发现是因为JwkProvider在测试的时候,会去请求/well-known/jwks.json,而测试环境并没有这个路由,所以报错了。

于是我尝试使用 Ktor 的 Mock 机制,构建一个externalService来模拟这个请求。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

@Test
fun testLogin() = testApplication {
application {
configureAuth()
}
externalService {
routing {
get("/.well-known/jwks.json") {
call.respondText("""{
"keys": //...
// ...
}""")
}
}
}
client {
// ...
}
}

但是,问题依旧没有解决。为什么嘞?

看看官方怎么做

众所周知,Ktor 有非常详尽的示例代码大雾,我开始在ktorio/ktor-documentationauth-jwt-rs256项目中寻找答案。

结果发现,这个示例项目是整个 Authentication 一节中唯一一个没有测试的项目……

Ktor 良心大大滴坏

社区讨论

查找 Yourtrack,发现我们不是孤例,社区里也有开发者遇到了这个问题。然而,并没有找到解决方案。

自己动手,丰衣足食

开发者确实告诉了我们 Mock 失败的原因:JwkProvider是来自 auth0 的 Java 库,而 Ktor 的 Mock 机制仅仅是对测试中使用的 Ktor-client 的请求进行 Mock,并不能对所有的网络请求进行模拟。我们或许应该想想其他办法。

众所周知,JwkProvider是一个接口,只要我们绕过JwkProviderBuilder,直接实现这个接口,就可以自己控制对应密钥串的获取了。

通过 IDEA 的反汇编机制,我们看到接口JwkProvider的定义如下:

1
2
3
4
5
package com.auth0.jwk;

public interface JwkProvider {
Jwk get(String var1) throws JwkException;
}

我们只需要实现这个接口,然后返回我们自定义的Jwk即可。

自定义 JwkProvider

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
object MockJwkProvider : JwkProvider {
override fun get(keyId: String): Jwk? {
// If 'null' values are not allowed, provide defaults (empty list, empty string, etc.)
return Jwk(
"6f8856ed-9189-488f-9011-0ff4b6c08edc",
"RSA",
"RSA256", // Provide a default algorithm if needed
null, // Provide a default usage if needed
emptyList<String>(), // Provide a default list
null, // Provide an empty string if it's allowed
emptyList<String>(), // Provide an empty list
null, // Provide an empty string
mapOf(
"e" to "AQAB",
"n" to "tfJaLrzXILUg1U3N1KV8yJr92GHn5OtYZR7qWk1Mc4cy4JGjklYup7weMjBD9f3bBVoIsiUVX6xNcYIr0Ie0AQ"
)
)
}
}

反正是 Ktor 样例中的密钥对,这里不做保密处理。

使用自定义的 JwkProvider

这里我们可以使用配置文件进行配置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
object Config {
private lateinit var environment: ApplicationEnvironment

object Database {
val url by lazy { environment.config.property("database.url").getString() }
val user by lazy { environment.config.property("database.user").getString() }
val password by lazy { environment.config.property("database.password").getString() }
val driver by lazy { environment.config.property("database.driver").getString() }
}

object Jwt {
val domain by lazy { environment.config.property("jwt.domain").getString() }
val audience by lazy { environment.config.property("jwt.audience").getString() }
val issuer by lazy { environment.config.property("jwt.issuer").getString() }
val realm by lazy { environment.config.property("jwt.realm").getString() }
val privateKey by lazy { environment.config.property("jwt.privateKey").getString() }
val jwkProvider: JwkProvider by lazy {
if (Debug.enabled == "true") { // Use a mock JWK provider for testing
MockJwkProvider
} else {
JwkProviderBuilder(domain)
.cached(10, 24, TimeUnit.HOURS)
.rateLimited(10, 1, TimeUnit.MINUTES)
.build()
}
}
}

在测试用到的 application.yaml

1
2
3
4
...
debug:
enabled: true
...

测试时加载对应配置文件

1
2
3
4
5
6
7
8
9
10
11
12
@Test
fun testLogin() = testApplication {
application {
configureAuth()
}
environment {
config = ApplicationConfig("application.yaml")
}
client {
// ...
}
}

测试通过

1
$ ./gradlew test

总结

测试果然比开发难呀,这个坑确实比较少见,不过通过查阅资料,还是可以找到解决方案的。

其他文章