auth: своя Bearer-авторизация agentik (сервер + клиент + standalone)
ci / JVM build + tests (push) Successful in 6m11s
release / Publish KMP libraries → caffeine Nexus (release) Successful in 33s

Добавлена собственная авторизация по токену. Это ОТДЕЛЬНАЯ подсистема:
библиотека A2A (pw.binom.a2a) имеет свой независимый token, общих типов
и общей логики не вводится.

Поведение по умолчанию не меняется: token = null -> авторизация выключена,
сервер открыт (обратная совместимость), CLI/TUI не затронуты.

Сервер (:server):
- новый route-scoped плагин BearerTokenPlugin (BearerTokenConfig);
- agentikAgent(agent, path, token) ставит плагин на всё поддерево /agentik,
  когда token != null; иначе плагин не устанавливается;
- при несовпадении заголовка Authorization: Bearer <token> -> 401 Unauthorized;
- /health всегда открыт (liveness для балансировщика).

Клиент (:client):
- defaultAgentikHttpClient(token) навешивает Authorization: Bearer <token>
  через DefaultRequest на весь HttpClient -> накрывает все 10 вызовов и оба SSE;
- AgentikAgent(id, baseUrl, token, httpClient) — token необязательный,
  9 существующих мест создания агента не тронуты.

Standalone:
- AgentSection.authToken (env AGENTIK_TOKEN) -> /agentik;
- AgentSection.a2aToken (env AGENTIK_A2A_TOKEN) -> /a2a;
- два независимых значения, связи между ними нет.

Тесты: BearerTokenTest (5), BearerHeaderTest (3) — 401 без токена и с чужим,
200 с верным, /health открыт, null -> открыто. Мутационная проверка пройдена.
This commit is contained in:
Porfiry
2026-09-19 20:58:52 +03:00
parent 9d310c5fd0
commit c486c7f9ab
10 changed files with 307 additions and 5 deletions
+2
View File
@@ -37,6 +37,8 @@ kotlin {
commonTest.dependencies {
implementation(libs.kotlinx.coroutines.core)
implementation(kotlin("test"))
implementation(libs.ktor.server.test.host)
implementation(libs.ktor.server.cio)
}
}
}
@@ -0,0 +1,42 @@
package pw.binom.agentik.server
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.createRouteScopedPlugin
import io.ktor.server.request.path
import io.ktor.server.response.respondText
/**
* Конфиг плагина проверки `Authorization: Bearer <token>` для роута `agentikAgent`.
*
* По умолчанию [token] == null → плагин пропускает все запросы (см. [Module.kt]).
*/
internal class BearerTokenConfig {
var token: String? = null
}
/**
* Route-scoped плагин: если в конфиге задан [BearerTokenConfig.token], на каждый
* запрос внутри ветки роута проверяет заголовок `Authorization: Bearer <token>`.
* При несовпадении отвечает `401 Unauthorized` (тело `Unauthorized`); дальнейшие
* обработчики не вызываются — ktor трактует отправленный ответ как завершение
* call-pipeline.
*
* `/health` всегда пропускается без проверки: это ручка liveness для
* балансировщика/мониторинга, закрывать её — сломать health-check.
*/
internal val BearerTokenPlugin = createRouteScopedPlugin(
name = "AgentikBearerToken",
createConfiguration = ::BearerTokenConfig,
) {
val expected = pluginConfig.token
onCall { call ->
if (expected == null) return@onCall
val path = call.request.path()
if (path.endsWith("/health")) return@onCall
if (call.request.headers[HttpHeaders.Authorization] != "Bearer $expected") {
call.respondText("Unauthorized", ContentType.Text.Plain, HttpStatusCode.Unauthorized)
}
}
}
@@ -33,11 +33,16 @@ import pw.binom.agentik.proto.Agent
* - `GET /events` — SSE: события агента
* - `GET /health` — `"ok"`
*/
fun Route.agentikAgent(agent: Agent, path: String = "/agentik") {
fun Route.agentikAgent(agent: Agent, path: String = "/agentik", token: String? = null) {
route(path) {
install(ContentNegotiation) {
json(agentikJson)
}
if (token != null) {
install(BearerTokenPlugin) {
this.token = token
}
}
agentikRoutes(agent)
}
}
@@ -0,0 +1,115 @@
package pw.binom.agentik.server
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.request.get
import io.ktor.client.request.header
import io.ktor.client.statement.bodyAsText
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.server.cio.CIO as ServerCIO
import io.ktor.server.engine.EmbeddedServer
import io.ktor.server.engine.embeddedServer
import io.ktor.server.routing.routing
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.runBlocking
import pw.binom.agentik.proto.Agent
import pw.binom.agentik.proto.AgentEvent
import pw.binom.agentik.proto.Conversation
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.time.Instant
/**
* Тесты route-scoped плагина [BearerTokenPlugin]:
* - при `token != null` все роуты кроме `/health` требуют `Authorization: Bearer <token>`;
* - при `token == null` плагин не устанавливается, всё открыто;
* - `/health` всегда открыт, даже при заданном токене (liveness-ручка для балансировщика).
*/
class BearerTokenTest {
private class FakeAgent(override val id: String = "test") : Agent {
override fun createConversation(temp: Boolean): Conversation = TODO("not needed by tests")
override suspend fun getConversation(id: String): Conversation? = null
override suspend fun deleteConversation(id: String): Boolean = false
override suspend fun getConversations(offset: Int, limit: Int): List<Conversation> = emptyList()
override fun events(after: Instant): Flow<AgentEvent> = emptyFlow()
}
private suspend fun startServer(token: String?): Pair<EmbeddedServer<*, *>, Int> {
val server = embeddedServer(ServerCIO, port = 0) {
routing {
agentikAgent(FakeAgent(), path = "/agentik", token = token)
}
}.start(wait = false)
val port = server.engine.resolvedConnectors().first().port
return server to port
}
@Test
fun tokenRejectsRequestWithoutHeader() = runBlocking {
val (server, port) = startServer("secret")
try {
val client = HttpClient(CIO)
val resp = client.get("http://127.0.0.1:$port/agentik/conversations")
assertEquals(HttpStatusCode.Unauthorized, resp.status)
assertEquals("Unauthorized", resp.bodyAsText())
} finally {
server.stop(100, 200)
}
}
@Test
fun tokenRejectsWrongHeader() = runBlocking {
val (server, port) = startServer("secret")
try {
val client = HttpClient(CIO)
val resp = client.get("http://127.0.0.1:$port/agentik/conversations") {
header(HttpHeaders.Authorization, "Bearer wrong")
}
assertEquals(HttpStatusCode.Unauthorized, resp.status)
} finally {
server.stop(100, 200)
}
}
@Test
fun tokenAcceptsCorrectHeader() = runBlocking {
val (server, port) = startServer("secret")
try {
val client = HttpClient(CIO)
val resp = client.get("http://127.0.0.1:$port/agentik/conversations") {
header(HttpHeaders.Authorization, "Bearer secret")
}
assertEquals(HttpStatusCode.OK, resp.status)
} finally {
server.stop(100, 200)
}
}
@Test
fun healthStaysOpenWithToken() = runBlocking {
val (server, port) = startServer("secret")
try {
val client = HttpClient(CIO)
val resp = client.get("http://127.0.0.1:$port/agentik/health")
assertEquals(HttpStatusCode.OK, resp.status)
assertEquals("ok", resp.bodyAsText())
} finally {
server.stop(100, 200)
}
}
@Test
fun nullTokenMeansOpen() = runBlocking {
val (server, port) = startServer(null)
try {
val client = HttpClient(CIO)
val resp = client.get("http://127.0.0.1:$port/agentik/conversations")
assertEquals(HttpStatusCode.OK, resp.status)
} finally {
server.stop(100, 200)
}
}
}