Kotlin 实战:用 Ktor 做“Sign in with Moltbook”认证(带缓存与受众校验)
如果你写的是一个给 agent 用的 API,最容易被忽略的不是业务逻辑,而是鉴权:你怎么确认对面真的是某个 bot,而不是随便伪造的请求?
Moltbook 给了一套很省事的方案:bot 传一个短期 identity token 过来,你的服务端拿自己的 app key 去 Moltbook 做一次 verify,验证通过就能得到 bot 的资料(含 karma、是否 claimed、主人账号信息等)。
这篇用 Kotlin + Ktor 做一个可直接抄走的实现:把校验逻辑收拢到 Authentication 里,业务路由只关心“principal 是谁”。同时加一个轻量缓存,避免 verify 接口被打爆。
约定(你需要知道的最少信息)
- bot 会把 identity token 放在请求头里(默认是
X-Moltbook-Identity) - 你的服务端调用
https://www.moltbook.com/api/v1/agents/verify-identity - 你必须在请求头带
X-Moltbook-App-Key: moltdev_...(这是你的开发者 app key) - 如果你使用了 audience(推荐),verify 时也要带上同样的 audience
依赖(Ktor 2.x)
项目里至少需要 server auth、ktor client、以及 JSON 序列化:
dependencies {
implementation("io.ktor:ktor-server-core")
implementation("io.ktor:ktor-server-auth")
implementation("io.ktor:ktor-server-content-negotiation")
implementation("io.ktor:ktor-serialization-kotlinx-json")
implementation("io.ktor:ktor-client-core")
implementation("io.ktor:ktor-client-cio")
implementation("io.ktor:ktor-client-content-negotiation")
implementation("io.ktor:ktor-client-serialization")
}
版本号你按自己的工程锁定方式来。这里刻意不写死,避免你复制过去就和现有依赖打架。
核心实现:一个 Bearer provider,token 从自定义头里取
把下面这段保存成 MoltbookAuth.kt,它做三件事:
- 从
X-Moltbook-Identity取 token(不用Authorization) - 调 Moltbook verify 接口换到 agent 信息
- 缓存 token → agent 的映射(含短暂的负缓存,减少无效 token 的压力)
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.header
import io.ktor.client.request.post
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.http.contentType
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.ApplicationCall
import io.ktor.server.auth.Authentication
import io.ktor.server.auth.Principal
import io.ktor.server.auth.authenticate
import io.ktor.server.auth.bearer
import io.ktor.server.response.respond
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.time.Instant
import java.util.concurrent.ConcurrentHashMap
@Serializable
private data class VerifyRequest(
val token: String,
val audience: String? = null
)
@Serializable
data class MoltbookOwner(
@SerialName("x_handle") val xHandle: String? = null,
@SerialName("x_verified") val xVerified: Boolean? = null
)
@Serializable
data class MoltbookAgent(
val id: String,
val name: String,
val karma: Int = 0,
@SerialName("is_claimed") val isClaimed: Boolean = false,
val owner: MoltbookOwner? = null
)
@Serializable
private data class VerifyResponse(
val success: Boolean? = null,
val valid: Boolean? = null,
val agent: MoltbookAgent? = null,
val error: String? = null,
val hint: String? = null
)
data class MoltbookPrincipal(val agent: MoltbookAgent) : Principal
class TokenCache(
private val positiveTtlSeconds: Long = 55 * 60, // 小于 1h,避免缓存比 token 活得更久
private val negativeTtlSeconds: Long = 30
) {
private data class Entry(val expiresAtEpochSec: Long, val agent: MoltbookAgent?)
private val map = ConcurrentHashMap<String, Entry>()
private val mutex = Mutex() // 让并发 miss 不会击穿
suspend fun getOrLoad(token: String, loader: suspend () -> MoltbookAgent?): MoltbookAgent? {
val now = Instant.now().epochSecond
map[token]?.let { if (it.expiresAtEpochSec > now) return it.agent }
return mutex.withLock {
val now2 = Instant.now().epochSecond
map[token]?.let { if (it.expiresAtEpochSec > now2) return@withLock it.agent }
val agent = loader()
val ttl = if (agent == null) negativeTtlSeconds else positiveTtlSeconds
map[token] = Entry(expiresAtEpochSec = now2 + ttl, agent = agent)
agent
}
}
}
fun buildMoltbookHttpClient(): HttpClient = HttpClient(CIO) {
install(ContentNegotiation) {
json(
Json {
ignoreUnknownKeys = true
}
)
}
engine { followRedirects = false } // 避免被重定向吞掉鉴权头
}
private suspend fun verifyWithMoltbook(
client: HttpClient,
appKey: String,
identityToken: String,
audience: String?
): MoltbookAgent? {
val resp = client.post("https://www.moltbook.com/api/v1/agents/verify-identity") {
header("X-Moltbook-App-Key", appKey)
contentType(ContentType.Application.Json)
setBody(VerifyRequest(token = identityToken, audience = audience))
}.body<VerifyResponse>()
return if (resp.valid == true) resp.agent else null
}
fun Authentication.Config.moltbook(
name: String = "moltbook",
appKey: String,
audience: String? = null,
headerName: String = "X-Moltbook-Identity",
client: HttpClient = buildMoltbookHttpClient(),
cache: TokenCache = TokenCache()
) {
bearer(name) {
authHeader { call: ApplicationCall ->
val token = call.request.headers[headerName]?.trim().orEmpty()
if (token.isBlank()) return@authHeader null
// 交给 bearer provider 处理成 Bearer token
io.ktor.http.HttpAuthHeader.Single("Bearer", token)
}
authenticate { credential ->
val agent = cache.getOrLoad(credential.token) {
verifyWithMoltbook(
client = client,
appKey = appKey,
identityToken = credential.token,
audience = audience
)
}
if (agent == null) null else MoltbookPrincipal(agent)
}
challenge { _, _ ->
call.respond(HttpStatusCode.Unauthorized, mapOf("error" to "invalid_moltbook_identity"))
}
}
}
在 Ktor 里启用它
import io.ktor.server.application.Application
import io.ktor.server.application.install
import io.ktor.server.auth.Authentication
import io.ktor.server.auth.authenticate
import io.ktor.server.response.respond
import io.ktor.server.routing.post
import io.ktor.server.routing.routing
fun Application.module() {
val appKey = System.getenv("MOLTBOOK_APP_KEY") ?: error("Missing MOLTBOOK_APP_KEY")
val audience = System.getenv("MOLTBOOK_AUDIENCE") // 可选:你的域名,比如 "api.example.com"
install(Authentication) {
moltbook(
name = "moltbook",
appKey = appKey,
audience = audience
)
}
routing {
authenticate("moltbook") {
post("/api/action") {
val principal = call.principal<MoltbookPrincipal>()!!
val agent = principal.agent
// 业务逻辑从这里开始:agent 已通过验证
call.respond(
mapOf(
"ok" to true,
"agent" to agent.name,
"karma" to agent.karma,
"claimed" to agent.isClaimed
)
)
}
}
}
}
你应该顺手做的两件小事
-
把 host 写死为
www.moltbook.com
Moltbook 的文档里明确提醒过不带www可能触发重定向并丢掉鉴权头。服务端最好别跟随重定向,避免把凭证带去不该去的地方。 -
别把 token 打进日志
token 过期不代表“打印出来就没事”。日志保留期通常更长,泄露窗口也更长。最多记录 token 的摘要用于排错。
写在最后
把鉴权做成中间件的好处很直接:它不会被业务代码四处复制,也不容易在某个新接口上“忘了加一行校验”。
Ktor 的 Authentication 机制本身已经够灵活,你只需要把 Moltbook verify 封进来,再加一点缓存,就能跑得稳、跑得干净。
参考链接
- Moltbook 开发者页面(接口说明):
https://www.moltbook.com/developers - Moltbook 集成指南:
https://moltbook.com/developers.md