1 Commits
3 ... 4

Author SHA1 Message Date
Porfiry c486c7f9ab 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 -> открыто. Мутационная проверка пройдена.
2026-09-19 20:58:52 +03:00
10 changed files with 307 additions and 5 deletions
@@ -27,7 +27,8 @@ import pw.binom.agentik.proto.Agent
fun AgentikAgent( fun AgentikAgent(
id: String, id: String,
baseUrl: String, baseUrl: String,
httpClient: HttpClient = defaultAgentikHttpClient(), token: String? = null,
httpClient: HttpClient = defaultAgentikHttpClient(token),
): Agent = AgentClient(httpClient = httpClient, baseUrl = baseUrl, id = id) ): Agent = AgentClient(httpClient = httpClient, baseUrl = baseUrl, id = id)
/** /**
@@ -2,7 +2,10 @@ package pw.binom.agentik.client
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.DefaultRequest
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.header
import io.ktor.http.HttpHeaders
import io.ktor.serialization.kotlinx.json.json import io.ktor.serialization.kotlinx.json.json
/** /**
@@ -11,8 +14,18 @@ import io.ktor.serialization.kotlinx.json.json
* *
* `requestTimeout = 0` — defense-in-depth против read-таймаута на SSE: * `requestTimeout = 0` — defense-in-depth против read-таймаута на SSE:
* основная защита в `HttpRequestBuilder.noSseReadTimeout()` ([SseTimeout]). * основная защита в `HttpRequestBuilder.noSseReadTimeout()` ([SseTimeout]).
*
* При заданном [token] на ВСЕ запросы клиента навешивается
* `Authorization: Bearer <token>` через плагин [DefaultRequest]. Это накрывает
* все 10 REST-вызовов и оба SSE-потока сразу — заголовок живёт на HTTP-клиенте,
* а не в отдельных запросах.
*/ */
fun defaultAgentikHttpClient(): HttpClient = HttpClient(CIO) { fun defaultAgentikHttpClient(token: String? = null): HttpClient = HttpClient(CIO) {
engine { requestTimeout = 0 } engine { requestTimeout = 0 }
install(ContentNegotiation) { json(agentikJson) } install(ContentNegotiation) { json(agentikJson) }
if (token != null) {
install(DefaultRequest) {
header(HttpHeaders.Authorization, "Bearer $token")
}
}
} }
@@ -0,0 +1,101 @@
package pw.binom.agentik.client
import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.call
import io.ktor.server.application.createRouteScopedPlugin
import io.ktor.server.cio.CIO as ServerCIO
import io.ktor.server.engine.EmbeddedServer
import io.ktor.server.engine.embeddedServer
import io.ktor.server.response.respondText
import io.ktor.server.routing.get
import io.ktor.server.routing.route
import io.ktor.server.routing.routing
import kotlinx.coroutines.runBlocking
import kotlin.test.Test
import kotlin.test.assertEquals
/**
* Тесты клиентской части: [defaultAgentikHttpClient] с заданным `token` прикладывает
* `Authorization: Bearer <token>` ко всем запросам через плагин `DefaultRequest`,
* без токена — заголовок не отправляется.
*
* Сервер в тесте — локальный ktor-CIO с inline route-scoped Bearer-плагином (один в один
* как боевой [pw.binom.agentik.server.BearerTokenPlugin]). Тестовый `:server` не зависит
* от `:client`, поэтому боевой плагин тут переиспользовать нельзя — пересоздаём его
* минимально, контракт тот же.
*/
class BearerHeaderTest {
private val TestBearer = createRouteScopedPlugin(
name = "TestBearer",
createConfiguration = ::BearerCfg,
) {
val expected = pluginConfig.token
onCall { call ->
if (expected == null) return@onCall
if (call.request.headers[HttpHeaders.Authorization] != "Bearer $expected") {
call.respondText("Unauthorized", ContentType.Text.Plain, HttpStatusCode.Unauthorized)
}
}
}
private class BearerCfg {
var token: String? = null
}
private suspend fun startServer(): Pair<EmbeddedServer<*, *>, Int> {
val server = embeddedServer(ServerCIO, port = 0) {
routing {
route("/agentik") {
install(TestBearer) { token = "secret" }
get("/conversations") {
call.respondText("[]")
}
}
}
}.start(wait = false)
val port = server.engine.resolvedConnectors().first().port
return server to port
}
@Test
fun clientWithTokenAttachesBearerHeader() = runBlocking {
val (server, port) = startServer()
try {
val client = defaultAgentikHttpClient("secret")
val resp = client.get("http://127.0.0.1:$port/agentik/conversations")
assertEquals(HttpStatusCode.OK, resp.status)
assertEquals("[]", resp.bodyAsText())
} finally {
server.stop(100, 200)
}
}
@Test
fun clientWithoutTokenGets401(): Unit = runBlocking {
val (server, port) = startServer()
try {
val client = defaultAgentikHttpClient(null)
val resp = client.get("http://127.0.0.1:$port/agentik/conversations")
assertEquals(HttpStatusCode.Unauthorized, resp.status)
} finally {
server.stop(100, 200)
}
}
@Test
fun clientWithWrongTokenGets401(): Unit = runBlocking {
val (server, port) = startServer()
try {
val client = defaultAgentikHttpClient("wrong")
val resp = client.get("http://127.0.0.1:$port/agentik/conversations")
assertEquals(HttpStatusCode.Unauthorized, resp.status)
} finally {
server.stop(100, 200)
}
}
}
+2
View File
@@ -37,6 +37,8 @@ kotlin {
commonTest.dependencies { commonTest.dependencies {
implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.coroutines.core)
implementation(kotlin("test")) 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 /events` — SSE: события агента
* - `GET /health` — `"ok"` * - `GET /health` — `"ok"`
*/ */
fun Route.agentikAgent(agent: Agent, path: String = "/agentik") { fun Route.agentikAgent(agent: Agent, path: String = "/agentik", token: String? = null) {
route(path) { route(path) {
install(ContentNegotiation) { install(ContentNegotiation) {
json(agentikJson) json(agentikJson)
} }
if (token != null) {
install(BearerTokenPlugin) {
this.token = token
}
}
agentikRoutes(agent) 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)
}
}
}
+2
View File
@@ -69,6 +69,8 @@ AGENTIK_GOOGLE_MODEL_PATH=/root/gemma-4-E2B-it.litertlm \
|---|---|---| |---|---|---|
| `AGENTIK_PORT` | `8080` | Порт HTTP-сервера | | `AGENTIK_PORT` | `8080` | Порт HTTP-сервера |
| `AGENTIK_DB_PATH` | `./agentik.db` | Путь к SQLite | | `AGENTIK_DB_PATH` | `./agentik.db` | Путь к SQLite |
| `AGENTIK_TOKEN` | (пусто) | Bearer-токен для HTTP-фасада `/agentik`. Пусто — авторизация выключена |
| `AGENTIK_A2A_TOKEN` | (пусто) | Bearer-токен для A2A-фасада `/a2a`. Пусто — авторизация выключена (независим от `AGENTIK_TOKEN`) |
| `AGENTIK_AGENT_ID` | `agentik` | ID агента (для multi-instance) | | `AGENTIK_AGENT_ID` | `agentik` | ID агента (для multi-instance) |
| `AGENTIK_LLM_BACKEND` | `openai` | `openai` или `google` | | `AGENTIK_LLM_BACKEND` | `openai` | `openai` или `google` |
| `AGENTIK_LLM_MODEL` | (выбирается по backend) | Имя модели | | `AGENTIK_LLM_MODEL` | (выбирается по backend) | Имя модели |
@@ -51,6 +51,8 @@ import pw.binom.agentik.llm.tools.SkillMiner
* *
* Вся конфигурация — [AppConfig.fromEnv] (см. [AppConfig]). Источники: * Вся конфигурация — [AppConfig.fromEnv] (см. [AppConfig]). Источники:
* - AGENTIK_PORT / AGENTIK_DB_PATH * - AGENTIK_PORT / AGENTIK_DB_PATH
* - AGENTIK_TOKEN — Bearer-токен для HTTP-фасада /agentik (пусто — авторизация выключена)
* - AGENTIK_A2A_TOKEN — Bearer-токен для A2A-фасада /a2a (пусто — авторизация выключена)
* - LLM: AGENTIK_LLM_BACKEND, OPENAI_* либо AGENTIK_GOOGLE_* * - LLM: AGENTIK_LLM_BACKEND, OPENAI_* либо AGENTIK_GOOGLE_*
* - MCP: AGENTIK_MCP_CONFIG=<path>.json (формат Claude Desktop) * - MCP: AGENTIK_MCP_CONFIG=<path>.json (формат Claude Desktop)
* - Skills: AGENTIK_SKILLS_DIR=<path> (папка с SKILL.md / *.yaml) * - Skills: AGENTIK_SKILLS_DIR=<path> (папка с SKILL.md / *.yaml)
@@ -353,8 +355,13 @@ private fun runServer() {
val server = embeddedServer(CIO, port = config.agent.port) { val server = embeddedServer(CIO, port = config.agent.port) {
routing { routing {
get("/health") { call.respondText("ok") } get("/health") { call.respondText("ok") }
agentikAgent(agent, path = "/agentik") agentikAgent(agent, path = "/agentik", token = config.agent.authToken)
a2aAgent(agentName = "agentik", handler = A2aBridge(agent), path = "/a2a") a2aAgent(
agentName = "agentik",
handler = A2aBridge(agent),
path = "/a2a",
token = config.agent.a2aToken,
)
if (config.debug.endpoints) { if (config.debug.endpoints) {
debugRoutes( debugRoutes(
agent = agent, agent = agent,
@@ -50,6 +50,18 @@ data class AppConfig(
* `null` — файл не читается, секция не добавляется. * `null` — файл не читается, секция не добавляется.
*/ */
val soulPath: String? = null, val soulPath: String? = null,
/**
* Токен для HTTP-фасада agentik (`/agentik`). `null` — авторизация выключена,
* сервер открыт (поведение по умолчанию, обратная совместимость).
* Источник: env `AGENTIK_TOKEN`.
*/
val authToken: String? = null,
/**
* Токен для A2A-фасада (`/a2a`). НЕ связан с [authToken] — это разные
* интерфейсы и разные токены (см. библиотеку pw.binom.a2a).
* `null` — авторизация выключена. Источник: env `AGENTIK_A2A_TOKEN`.
*/
val a2aToken: String? = null,
) )
/** Долговременная память. */ /** Долговременная память. */
@@ -163,6 +175,8 @@ data class AppConfig(
dbPath = env("AGENTIK_DB_PATH")?.takeIf { it.isNotBlank() } ?: DEFAULT_DB_PATH, dbPath = env("AGENTIK_DB_PATH")?.takeIf { it.isNotBlank() } ?: DEFAULT_DB_PATH,
skillsDir = env("AGENTIK_SKILLS_DIR")?.takeIf { it.isNotBlank() }, skillsDir = env("AGENTIK_SKILLS_DIR")?.takeIf { it.isNotBlank() },
soulPath = env("AGENTIK_SOUL")?.takeIf { it.isNotBlank() }, soulPath = env("AGENTIK_SOUL")?.takeIf { it.isNotBlank() },
authToken = env("AGENTIK_TOKEN")?.takeIf { it.isNotBlank() },
a2aToken = env("AGENTIK_A2A_TOKEN")?.takeIf { it.isNotBlank() },
), ),
llm = llm, llm = llm,
mcp = McpConfig.fromEnv(env), mcp = McpConfig.fromEnv(env),