diff --git a/client/src/commonMain/kotlin/pw/binom/agentik/client/AgentikAgent.kt b/client/src/commonMain/kotlin/pw/binom/agentik/client/AgentikAgent.kt index 8c54720..ecb9540 100644 --- a/client/src/commonMain/kotlin/pw/binom/agentik/client/AgentikAgent.kt +++ b/client/src/commonMain/kotlin/pw/binom/agentik/client/AgentikAgent.kt @@ -27,7 +27,8 @@ import pw.binom.agentik.proto.Agent fun AgentikAgent( id: String, baseUrl: String, - httpClient: HttpClient = defaultAgentikHttpClient(), + token: String? = null, + httpClient: HttpClient = defaultAgentikHttpClient(token), ): Agent = AgentClient(httpClient = httpClient, baseUrl = baseUrl, id = id) /** diff --git a/client/src/commonMain/kotlin/pw/binom/agentik/client/HttpClientFactory.kt b/client/src/commonMain/kotlin/pw/binom/agentik/client/HttpClientFactory.kt index ed6ee55..295da32 100644 --- a/client/src/commonMain/kotlin/pw/binom/agentik/client/HttpClientFactory.kt +++ b/client/src/commonMain/kotlin/pw/binom/agentik/client/HttpClientFactory.kt @@ -2,7 +2,10 @@ package pw.binom.agentik.client import io.ktor.client.HttpClient import io.ktor.client.engine.cio.CIO +import io.ktor.client.plugins.DefaultRequest 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 /** @@ -11,8 +14,18 @@ import io.ktor.serialization.kotlinx.json.json * * `requestTimeout = 0` — defense-in-depth против read-таймаута на SSE: * основная защита в `HttpRequestBuilder.noSseReadTimeout()` ([SseTimeout]). + * + * При заданном [token] на ВСЕ запросы клиента навешивается + * `Authorization: Bearer ` через плагин [DefaultRequest]. Это накрывает + * все 10 REST-вызовов и оба SSE-потока сразу — заголовок живёт на HTTP-клиенте, + * а не в отдельных запросах. */ -fun defaultAgentikHttpClient(): HttpClient = HttpClient(CIO) { +fun defaultAgentikHttpClient(token: String? = null): HttpClient = HttpClient(CIO) { engine { requestTimeout = 0 } install(ContentNegotiation) { json(agentikJson) } + if (token != null) { + install(DefaultRequest) { + header(HttpHeaders.Authorization, "Bearer $token") + } + } } diff --git a/client/src/commonTest/kotlin/pw/binom/agentik/client/BearerHeaderTest.kt b/client/src/commonTest/kotlin/pw/binom/agentik/client/BearerHeaderTest.kt new file mode 100644 index 0000000..c31e9f0 --- /dev/null +++ b/client/src/commonTest/kotlin/pw/binom/agentik/client/BearerHeaderTest.kt @@ -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 ` ко всем запросам через плагин `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, 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) + } + } +} \ No newline at end of file diff --git a/server/build.gradle.kts b/server/build.gradle.kts index ca2eaad..ebc506e 100644 --- a/server/build.gradle.kts +++ b/server/build.gradle.kts @@ -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) } } } diff --git a/server/src/commonMain/kotlin/pw/binom/agentik/server/BearerTokenPlugin.kt b/server/src/commonMain/kotlin/pw/binom/agentik/server/BearerTokenPlugin.kt new file mode 100644 index 0000000..15fb009 --- /dev/null +++ b/server/src/commonMain/kotlin/pw/binom/agentik/server/BearerTokenPlugin.kt @@ -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 ` для роута `agentikAgent`. + * + * По умолчанию [token] == null → плагин пропускает все запросы (см. [Module.kt]). + */ +internal class BearerTokenConfig { + var token: String? = null +} + +/** + * Route-scoped плагин: если в конфиге задан [BearerTokenConfig.token], на каждый + * запрос внутри ветки роута проверяет заголовок `Authorization: Bearer `. + * При несовпадении отвечает `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) + } + } +} \ No newline at end of file diff --git a/server/src/commonMain/kotlin/pw/binom/agentik/server/Module.kt b/server/src/commonMain/kotlin/pw/binom/agentik/server/Module.kt index e00d264..798550f 100644 --- a/server/src/commonMain/kotlin/pw/binom/agentik/server/Module.kt +++ b/server/src/commonMain/kotlin/pw/binom/agentik/server/Module.kt @@ -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) } } diff --git a/server/src/commonTest/kotlin/pw/binom/agentik/server/BearerTokenTest.kt b/server/src/commonTest/kotlin/pw/binom/agentik/server/BearerTokenTest.kt new file mode 100644 index 0000000..e2d27eb --- /dev/null +++ b/server/src/commonTest/kotlin/pw/binom/agentik/server/BearerTokenTest.kt @@ -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 == 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 = emptyList() + override fun events(after: Instant): Flow = emptyFlow() + } + + private suspend fun startServer(token: String?): Pair, 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) + } + } +} \ No newline at end of file diff --git a/standalone/README.md b/standalone/README.md index 9c7a6b0..eec2444 100644 --- a/standalone/README.md +++ b/standalone/README.md @@ -69,6 +69,8 @@ AGENTIK_GOOGLE_MODEL_PATH=/root/gemma-4-E2B-it.litertlm \ |---|---|---| | `AGENTIK_PORT` | `8080` | Порт HTTP-сервера | | `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_LLM_BACKEND` | `openai` | `openai` или `google` | | `AGENTIK_LLM_MODEL` | (выбирается по backend) | Имя модели | diff --git a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/Main.kt b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/Main.kt index 278bd81..7b7403a 100644 --- a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/Main.kt +++ b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/Main.kt @@ -51,6 +51,8 @@ import pw.binom.agentik.llm.tools.SkillMiner * * Вся конфигурация — [AppConfig.fromEnv] (см. [AppConfig]). Источники: * - AGENTIK_PORT / AGENTIK_DB_PATH + * - AGENTIK_TOKEN — Bearer-токен для HTTP-фасада /agentik (пусто — авторизация выключена) + * - AGENTIK_A2A_TOKEN — Bearer-токен для A2A-фасада /a2a (пусто — авторизация выключена) * - LLM: AGENTIK_LLM_BACKEND, OPENAI_* либо AGENTIK_GOOGLE_* * - MCP: AGENTIK_MCP_CONFIG=.json (формат Claude Desktop) * - Skills: AGENTIK_SKILLS_DIR= (папка с SKILL.md / *.yaml) @@ -353,8 +355,13 @@ private fun runServer() { val server = embeddedServer(CIO, port = config.agent.port) { routing { get("/health") { call.respondText("ok") } - agentikAgent(agent, path = "/agentik") - a2aAgent(agentName = "agentik", handler = A2aBridge(agent), path = "/a2a") + agentikAgent(agent, path = "/agentik", token = config.agent.authToken) + a2aAgent( + agentName = "agentik", + handler = A2aBridge(agent), + path = "/a2a", + token = config.agent.a2aToken, + ) if (config.debug.endpoints) { debugRoutes( agent = agent, diff --git a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/config/AppConfig.kt b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/config/AppConfig.kt index db32de6..2f182c3 100644 --- a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/config/AppConfig.kt +++ b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/config/AppConfig.kt @@ -50,6 +50,18 @@ data class AppConfig( * `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, skillsDir = env("AGENTIK_SKILLS_DIR")?.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, mcp = McpConfig.fromEnv(env),