From 125137da3212da21da43559e9b36cf3cdd853c78 Mon Sep 17 00:00:00 2001 From: Alessandro Siniscalchi Date: Thu, 11 Jun 2026 07:48:28 +0000 Subject: [PATCH] refactor(auth)!: authentication is always on; remove FROID_AUTH_ENABLED MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The HTTP listener now unconditionally requires bearer tokens minted via the Telegram /token command — there is no unauthenticated mode and no way to misconfigure an exposed instance into serving journals openly. The single-tenant router layout and the legacy-database HTTP binding are gone; the default database now only hosts the central token store. FROID_AUTH_ENABLED joins the other removed auth variables as a hard startup error so the change cannot go unnoticed. The Telegram whitelist is now the gate deciding who can mint HTTP credentials, which the README calls out. Co-Authored-By: Claude Fable 5 --- .env.example | 8 ++--- README.md | 11 +++---- src/adapters/telegram.rs | 17 ++++++----- src/app.rs | 66 +++++++--------------------------------- src/auth.rs | 10 +++--- src/cli.rs | 54 +++++--------------------------- src/http.rs | 29 ++++-------------- tests/http_auth_tests.rs | 37 +--------------------- 8 files changed, 46 insertions(+), 186 deletions(-) diff --git a/.env.example b/.env.example index 778a6048..a223526f 100644 --- a/.env.example +++ b/.env.example @@ -18,12 +18,10 @@ FROID_EXTRACTION_WORKER_INTERVAL_SECONDS=300 FROID_MCP_ENABLED=false FROID_MCP_BIND=127.0.0.1:8080 -# Require bearer tokens on the HTTP listener (MCP and dashboard). Users mint -# their own token by sending /token to the Telegram bot (rotate with /token, +# The HTTP listener (MCP and dashboard) always requires bearer tokens. Users +# mint their own by sending /token to the Telegram bot (rotate with /token, # disable with /token revoke); each request is served from the token owner's -# isolated database. When unset, the endpoints are unauthenticated; restrict -# access at the network level instead. -FROID_AUTH_ENABLED=false +# isolated database. # Required when FROID_EMBEDDING_WORKER_ENABLED=true or FROID_EXTRACTION_WORKER_ENABLED=true OPENAI_API_KEY= diff --git a/README.md b/README.md index 3edea086..965e7fe3 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ docker run --env-file .env -v ./data:/app/data ghcr.io/asiniscalchi/froid:latest ## Exposing tools over MCP -Set `FROID_MCP_ENABLED=true` to expose the analyzer's read-only tools over the MCP Streamable HTTP transport at `http://127.0.0.1:8080/mcp`. The MCP server runs alongside the Telegram bot in the same process. Set `FROID_AUTH_ENABLED=true` to require an `Authorization: Bearer ` header on the HTTP listener, with tokens minted via the Telegram `/token` command (see [Authentication](#authentication)); when it is unset, the endpoints are unauthenticated, so restrict access at the network level — use the default loopback bind for local use, or a Docker internal network when running in Compose. +Set `FROID_MCP_ENABLED=true` to expose the analyzer's read-only tools over the MCP Streamable HTTP transport at `http://127.0.0.1:8080/mcp`. The MCP server runs alongside the Telegram bot in the same process. Every request must carry an `Authorization: Bearer ` header with a token minted via the Telegram `/token` command (see [Authentication](#authentication)). ```bash FROID_MCP_ENABLED=true cargo run -- serve @@ -49,7 +49,7 @@ Available tools: `journal_get`, `journal_get_recent`, `journal_search_text`, `jo ## Dashboard webapp -Set `FROID_DASHBOARD_ENABLED=true` to serve a small React webapp at `http://127.0.0.1:8080/`. Besides message export/import and prompt editing, the dashboard API exposes journal data directly: `GET /api/entries` (recent entries), `POST /api/messages` (capture a new entry from the browser; it flows through the same extraction/embedding/review pipeline as Telegram messages), and `GET /api/reviews/daily` / `GET /api/reviews/weekly` (completed reviews, optional `from`/`to` date filters). The dashboard shares the HTTP listener with the MCP endpoint (`FROID_MCP_BIND`, default `127.0.0.1:8080`) and can be enabled independently of MCP. Assets are embedded into the release binary, so the Docker image carries everything it needs. The dashboard is protected by the same bearer check as MCP (see [Authentication](#authentication)); when authentication is disabled, restrict access at the network level. +Set `FROID_DASHBOARD_ENABLED=true` to serve a small React webapp at `http://127.0.0.1:8080/`. Besides message export/import and prompt editing, the dashboard API exposes journal data directly: `GET /api/entries` (recent entries), `POST /api/messages` (capture a new entry from the browser; it flows through the same extraction/embedding/review pipeline as Telegram messages), and `GET /api/reviews/daily` / `GET /api/reviews/weekly` (completed reviews, optional `from`/`to` date filters). The dashboard shares the HTTP listener with the MCP endpoint (`FROID_MCP_BIND`, default `127.0.0.1:8080`) and can be enabled independently of MCP. Assets are embedded into the release binary, so the Docker image carries everything it needs. The dashboard is protected by the same bearer check as MCP (see [Authentication](#authentication)); on first visit it asks for your token. ```bash FROID_DASHBOARD_ENABLED=true cargo run -- serve @@ -57,7 +57,7 @@ FROID_DASHBOARD_ENABLED=true cargo run -- serve ## Authentication -Set `FROID_AUTH_ENABLED=true` and every request to `/mcp` and `/api` must carry a bearer token minted through the bot: +Authentication is always on: every request to `/mcp` and `/api` must carry a bearer token minted through the bot: ``` Authorization: Bearer @@ -67,9 +67,9 @@ Users mint their own tokens by sending `/token` to the Telegram bot. Telegram is Requests without a valid token receive `401 Unauthorized`. The static dashboard shell (HTML/JS assets, which contain no journal data) and the `/health` probe are intentionally served without authentication; all data flows through the protected `/api` and `/mcp` routes. -When `FROID_AUTH_ENABLED` is unset, the endpoints are unauthenticated and Froid logs a warning at startup — in that case restrict access at the network level (loopback bind or a Docker internal network). Unauthenticated requests are served from a single database: in multiuser mode, the isolated database of the first whitelisted Telegram user; otherwise the default database. +Because Telegram access is what mints HTTP credentials, `TELEGRAM_ALLOWED_USER_IDS` is the gate that decides who can become a user — set it on any instance whose bot a stranger could message. -The static-token variables from earlier versions (`FROID_AUTH_TOKEN`, `FROID_AUTH_TOKENS`, `FROID_AUTH_DYNAMIC_TOKENS`) have been removed; Froid refuses to start if one is still set, to avoid silently running unauthenticated. +The auth variables from earlier versions (`FROID_AUTH_TOKEN`, `FROID_AUTH_TOKENS`, `FROID_AUTH_DYNAMIC_TOKENS`, `FROID_AUTH_ENABLED`) have been removed; Froid refuses to start if one is still set, so the behavior change cannot go unnoticed. ## Health endpoint @@ -146,7 +146,6 @@ All workers are disabled by default and require `OPENAI_API_KEY`. |---|---|---| | `FROID_MCP_ENABLED` | `false` | Enable the MCP Streamable HTTP server | | `FROID_MCP_BIND` | `127.0.0.1:8080` | Bind address (e.g. `0.0.0.0:8080` for Docker Compose) | -| `FROID_AUTH_ENABLED` | `false` | Require bearer tokens (minted via the Telegram `/token` command) on the HTTP listener; each request is served from the token owner's isolated database. Unset means no authentication | ### Models diff --git a/src/adapters/telegram.rs b/src/adapters/telegram.rs index 3fce8436..43c4f1f5 100644 --- a/src/adapters/telegram.rs +++ b/src/adapters/telegram.rs @@ -34,9 +34,9 @@ impl TelegramAdapter { } } - /// Enable the `/token` command, backed by the central token store. - pub fn with_token_issuer(mut self, token_issuer: Option) -> Self { - self.token_issuer = token_issuer; + /// Attach the issuer backing the `/token` command (the central token store). + pub fn with_token_issuer(mut self, token_issuer: TokenIssuer) -> Self { + self.token_issuer = Some(token_issuer); self } @@ -211,9 +211,10 @@ async fn handle_token_command( chat_id: &str, ) -> String { let Some(issuer) = issuer else { - return "Access tokens are not enabled on this server. Ask the operator to set \ - FROID_AUTH_ENABLED=true." - .to_string(); + // Defensive: serve() always attaches an issuer; this only triggers if + // the adapter was built without one. + error!(chat_id, "received /token but no token issuer is attached"); + return "Access tokens are not available right now. Please try again later.".to_string(); }; match action { @@ -628,10 +629,10 @@ mod tests { } #[tokio::test] - async fn replies_with_hint_when_dynamic_tokens_disabled() { + async fn replies_gracefully_when_no_issuer_is_attached() { let reply = handle_token_command(None, TokenAction::Issue, "42").await; - assert!(reply.contains("not enabled")); + assert!(reply.contains("not available")); } #[tokio::test] diff --git a/src/app.rs b/src/app.rs index aa1f33e7..0c2a0fa5 100644 --- a/src/app.rs +++ b/src/app.rs @@ -89,12 +89,10 @@ pub async fn serve(config: ServeConfig) -> Result<(), Box> { // Central store for tokens issued via the Telegram /token command. It // lives in the default database so one lookup resolves any token. - let issued_tokens = if config.http_auth.enabled { + let issued_tokens = { let pool = database::connect_pool(&config.database_url).await?; sqlx::migrate!().run(&pool).await?; - Some(crate::tokens::UserTokenStore::new(pool)) - } else { - None + crate::tokens::UserTokenStore::new(pool) }; // Spawn the global HTTP server (MCP and Dashboard) @@ -134,7 +132,7 @@ pub async fn serve(config: ServeConfig) -> Result<(), Box> { config.telegram_allowed_user_ids.clone(), journal_registry, ) - .with_token_issuer(issued_tokens.map(crate::tokens::TokenIssuer::new)); + .with_token_issuer(crate::tokens::TokenIssuer::new(issued_tokens)); supervise(workers, shutdown, shutdown_signal(), adapter.run()).await } @@ -144,7 +142,7 @@ async fn spawn_http_server( config: &ServeConfig, embedding_config: Option<&EmbeddingConfig>, registry: &crate::journal::registry::JournalServiceRegistry, - issued_tokens: Option, + issued_tokens: crate::tokens::UserTokenStore, ) -> Result<(), Box> { if !config.mcp_server.enabled && !config.dashboard.enabled { return Ok(()); @@ -164,53 +162,12 @@ async fn spawn_http_server( shutdown: shutdown.clone(), }; - let (router, auth_mode) = if let Some(issued_tokens) = issued_tokens { - // Auth enabled: every /mcp and /api request must carry a bearer token - // minted via the Telegram /token command, and is served from the - // database of the user who minted it. - let resolver = Arc::new(crate::auth::TokenResolver::new(issued_tokens)); - let tenants = http::TenantRouters::new(registry.clone(), router_config); - ( - http::build_per_user_app(tenants, resolver, config.dashboard.enabled), - "per-user", - ) - } else { - // Auth disabled: unauthenticated single tenant. In multiuser mode - // with a whitelist, the administrative user is the first ID in the - // whitelist and we serve their isolated database. Otherwise, the - // default/legacy database. - warn!( - "HTTP server (MCP & Dashboard) is running without authentication; set FROID_AUTH_ENABLED=true to require bearer tokens minted via the Telegram /token command" - ); - let (pool, capture_conversation_id) = if let Some(first_id) = config - .telegram_allowed_user_ids - .as_ref() - .and_then(|ids| ids.first()) - { - info!( - chat_id = %first_id, - "HTTP server (MCP & Dashboard) running in multiuser mode; binding to first whitelisted user's database" - ); - let chat_id = first_id.to_string(); - let pool = registry - .pool(&chat_id) - .await - .map_err(|e| -> Box { e })?; - (pool, chat_id) - } else { - let pool = database::connect_pool(&config.database_url).await?; - sqlx::migrate!().run(&pool).await?; - (pool, crate::messages::SINGLE_USER_ID.to_string()) - }; - - let tenant_router = - http::build_tenant_router(&pool, &capture_conversation_id, &router_config) - .map_err(|e| -> Box { e })?; - ( - http::build_single_tenant_app(tenant_router, config.dashboard.enabled), - "none", - ) - }; + // Every /mcp and /api request must carry a bearer token minted via the + // Telegram /token command, and is served from the database of the user + // who minted it. + let resolver = Arc::new(crate::auth::TokenResolver::new(issued_tokens)); + let tenants = http::TenantRouters::new(registry.clone(), router_config); + let router = http::build_per_user_app(tenants, resolver, config.dashboard.enabled); let listener = tokio::net::TcpListener::bind(config.mcp_server.bind).await?; let local_addr = listener.local_addr()?; @@ -218,8 +175,7 @@ async fn spawn_http_server( addr = %local_addr, mcp = config.mcp_server.enabled, dashboard = config.dashboard.enabled, - auth = auth_mode, - "HTTP server listening" + "HTTP server listening; bearer tokens are minted via the Telegram /token command" ); let token = shutdown.clone(); diff --git a/src/auth.rs b/src/auth.rs index fc82d338..af52c58e 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -1,11 +1,9 @@ //! Bearer-token authentication for the shared HTTP listener (MCP and dashboard). //! -//! Tokens are minted by users themselves through the Telegram `/token` -//! command and stored hashed in the central database. A matching request is -//! tagged with an [`AuthenticatedTenant`] extension so the router can serve -//! it from that user's isolated database. When authentication is disabled -//! (`FROID_AUTH_ENABLED` unset), no middleware is installed and access must -//! be restricted at the network level. +//! Authentication is always on. Tokens are minted by users themselves +//! through the Telegram `/token` command and stored hashed in the central +//! database. A matching request is tagged with an [`AuthenticatedTenant`] +//! extension so the router can serve it from that user's isolated database. use std::sync::Arc; diff --git a/src/cli.rs b/src/cli.rs index dd12f279..f02f9879 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -170,9 +170,6 @@ pub struct Cli { #[arg(long, env = "FROID_DASHBOARD_ENABLED", global = true)] dashboard_enabled: Option, - - #[arg(long, env = "FROID_AUTH_ENABLED", global = true)] - auth_enabled: Option, } #[derive(Debug, Subcommand)] @@ -211,15 +208,6 @@ pub struct DashboardConfig { pub enabled: bool, } -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct HttpAuthConfig { - /// Require bearer tokens (minted via the Telegram `/token` command) on - /// the HTTP listener, routing each request to the owning user's database. - /// When `false`, the endpoints are unauthenticated and access must be - /// restricted at the network level. - pub enabled: bool, -} - #[derive(Debug, Clone, PartialEq, Eq)] pub struct ServeConfig { pub telegram_bot_token: String, @@ -234,16 +222,16 @@ pub struct ServeConfig { pub signal_worker: ReconciliationWorkerConfig, pub mcp_server: McpServerConfig, pub dashboard: DashboardConfig, - pub http_auth: HttpAuthConfig, } -/// Environment variables from the removed static-token auth strategies. -/// Their presence is a hard error rather than a silent ignore: an operator -/// upgrading with one still set would otherwise run unauthenticated. +/// Environment variables from removed auth strategies. Their presence is a +/// hard error rather than a silent ignore, so an operator upgrading with one +/// still set notices that the behavior changed. const REMOVED_AUTH_VARS: &[&str] = &[ "FROID_AUTH_TOKEN", "FROID_AUTH_TOKENS", "FROID_AUTH_DYNAMIC_TOKENS", + "FROID_AUTH_ENABLED", ]; fn reject_removed_auth_vars(lookup: impl Fn(&str) -> Option) -> Result<(), clap::Error> { @@ -252,9 +240,9 @@ fn reject_removed_auth_vars(lookup: impl Fn(&str) -> Option) -> Result<( return Err(clap::Error::raw( clap::error::ErrorKind::ValueValidation, format!( - "{name} is no longer supported: static bearer tokens were replaced by \ - self-serve tokens. Set FROID_AUTH_ENABLED=true and have each user mint a \ - token with the Telegram /token command." + "{name} is no longer supported: authentication is always enabled and every \ + user mints their own bearer token with the Telegram /token command. Remove \ + the variable." ), )); } @@ -356,10 +344,6 @@ impl Cli { reject_removed_auth_vars(|name| std::env::var(name).ok())?; - let http_auth = HttpAuthConfig { - enabled: self.auth_enabled.unwrap_or(false), - }; - Ok(ServeConfig { telegram_bot_token: telegram_bot_token.clone(), telegram_allowed_user_ids: self.telegram_allowed_user_ids.clone(), @@ -373,7 +357,6 @@ impl Cli { signal_worker, mcp_server, dashboard, - http_auth, }) } } @@ -412,7 +395,6 @@ mod tests { mcp_enabled: None, mcp_bind: "127.0.0.1:8080".parse().unwrap(), dashboard_enabled: None, - auth_enabled: None, } } @@ -823,28 +805,6 @@ mod tests { assert!(!config.dashboard.enabled); } - #[test] - fn serve_config_auth_disabled_by_default() { - let config = cli_with_token("token").serve_config().unwrap(); - - assert!(!config.http_auth.enabled); - } - - #[test] - fn serve_config_enables_auth_when_flag_set() { - let cli = Cli::parse_from([ - "froid", - "--telegram-bot-token", - "token", - "--auth-enabled", - "true", - ]); - - let config = cli.serve_config().unwrap(); - - assert!(config.http_auth.enabled); - } - #[test] fn rejects_removed_static_token_variables() { for name in super::REMOVED_AUTH_VARS { diff --git a/src/http.rs b/src/http.rs index 1afb97d1..a87bd1ad 100644 --- a/src/http.rs +++ b/src/http.rs @@ -1,15 +1,10 @@ //! Assembly of the shared HTTP listener (MCP endpoint + dashboard). //! -//! Two layouts exist, selected by the auth configuration: -//! -//! - **Single tenant** (`FROID_AUTH_TOKEN` or no auth): every request is -//! served from one fixed database, optionally behind one bearer token. -//! - **Per user** (`FROID_AUTH_TOKENS`): the bearer token identifies a user -//! (Telegram chat id) and `/mcp` + `/api` requests are forwarded to a -//! lazily built router bound to that user's isolated database. -//! -//! In both layouts the `/health` probe and the static SPA shell are served -//! without authentication; only the data-bearing routes sit behind tokens. +//! Authentication is always on: the bearer token (minted via the Telegram +//! `/token` command) identifies a user, and `/mcp` + `/api` requests are +//! forwarded to a lazily built router bound to that user's isolated +//! database. The `/health` probe and the static SPA shell are served without +//! authentication; only the data-bearing routes sit behind tokens. use std::{collections::HashMap, error::Error, sync::Arc}; @@ -54,7 +49,7 @@ pub struct TenantRouterConfig { /// Build the protected routes (`/mcp`, `/api`) bound to one database. /// `capture_conversation_id` is the conversation web-captured entries are /// filed under (the owning user's chat id). -pub fn build_tenant_router( +fn build_tenant_router( pool: &SqlitePool, capture_conversation_id: &str, config: &TenantRouterConfig, @@ -187,15 +182,3 @@ pub fn build_per_user_app( } router } - -/// Full listener app for the unauthenticated single-tenant layout (no auth -/// configured): everything is served from one database and access is expected -/// to be restricted at the network level. -pub fn build_single_tenant_app(tenant_router: Router, dashboard_enabled: bool) -> Router { - let mut router = tenant_router.merge(crate::health::router()); - - if dashboard_enabled { - router = router.merge(dashboard::spa_router()); - } - router -} diff --git a/tests/http_auth_tests.rs b/tests/http_auth_tests.rs index a0620b2c..96c788cc 100644 --- a/tests/http_auth_tests.rs +++ b/tests/http_auth_tests.rs @@ -17,7 +17,7 @@ use tower::ServiceExt; use froid::{ auth::TokenResolver, cli::Cli, - http::{TenantRouterConfig, TenantRouters, build_per_user_app, build_single_tenant_app}, + http::{TenantRouterConfig, TenantRouters, build_per_user_app}, journal::{ extraction::JournalEntryExtractionRuntimeConfig, registry::{JournalServiceRegistry, JournalServiceRegistryConfig}, @@ -50,12 +50,9 @@ async fn registry(temp_base_dir: &std::path::Path) -> JournalServiceRegistry { temp_base_dir.to_str().unwrap(), "--dashboard-enabled", "true", - "--auth-enabled", - "true", ]) .unwrap(); let config = cli.serve_config().unwrap(); - assert!(config.http_auth.enabled); JournalServiceRegistry::new(JournalServiceRegistryConfig { config, @@ -198,35 +195,3 @@ async fn per_user_app_serves_health_and_spa_without_token() { assert_eq!(status, StatusCode::OK); assert!(body.contains("
")); } - -#[tokio::test] -async fn unauthenticated_single_tenant_app_serves_everything() { - use froid::http::build_tenant_router; - - froid::database::register_sqlite_vec_extension(); - let pool = sqlx::SqlitePool::connect("sqlite::memory:").await.unwrap(); - sqlx::migrate!().run(&pool).await.unwrap(); - - let tenant_router = build_tenant_router( - &pool, - "default", - &TenantRouterConfig { - mcp_enabled: false, - dashboard_enabled: true, - embedding_config: None, - shutdown: CancellationToken::new(), - }, - ) - .unwrap(); - let app = build_single_tenant_app(tenant_router, true); - - let (status, _) = get(app.clone(), "/api/messages/export", None).await; - assert_eq!(status, StatusCode::OK); - - let (status, _) = get(app.clone(), "/health", None).await; - assert_eq!(status, StatusCode::OK); - - let (status, body) = get(app, "/", None).await; - assert_eq!(status, StatusCode::OK); - assert!(body.contains("
")); -}