From 1ad12617461583749c415c83d4b0190b68804f20 Mon Sep 17 00:00:00 2001 From: Carlos Escobar Date: Fri, 20 Mar 2026 18:12:33 -0500 Subject: [PATCH] feat: add JWT auth middleware to Autonomic HTTP API Protect /gating/{session_id} and /projection/{session_id} endpoints with JWT authentication while keeping /health unprotected for Railway health checks. Uses lago-auth JWT validation primitives (validate_jwt, extract_bearer_token) with a simpler middleware that skips session mapping. Auth is backward-compatible: if no AUTONOMIC_JWT_SECRET or AUTH_SECRET env var is configured, the API starts without auth (local dev mode) but logs a warning. When a secret IS set, valid Bearer tokens are required. - Add lago-auth and jsonwebtoken as workspace dependencies - New auth.rs module in autonomic-api with AuthConfig and auth_middleware - Split router into public (health) and protected (gating, projection) route groups - 9 new tests covering auth enabled/disabled, valid/invalid/missing tokens - All 95 workspace tests passing, clippy clean Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.lock | 146 +++++++++++++++++++++++++++ Cargo.toml | 4 + crates/autonomic-api/Cargo.toml | 2 + crates/autonomic-api/src/auth.rs | 142 ++++++++++++++++++++++++++ crates/autonomic-api/src/lib.rs | 5 +- crates/autonomic-api/src/router.rs | 155 ++++++++++++++++++++++++++--- crates/autonomicd/src/main.rs | 5 +- 7 files changed, 441 insertions(+), 18 deletions(-) create mode 100644 crates/autonomic-api/src/auth.rs diff --git a/Cargo.lock b/Cargo.lock index 56f9dee..91c0ace 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -145,6 +145,8 @@ dependencies = [ "autonomic-lago", "axum 0.8.8", "http-body-util", + "jsonwebtoken", + "lago-auth", "lago-core", "serde", "serde_json", @@ -430,6 +432,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -591,8 +602,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -977,6 +990,35 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + +[[package]] +name = "lago-auth" +version = "0.2.1" +dependencies = [ + "axum 0.8.8", + "jsonwebtoken", + "lago-core", + "serde", + "serde_json", + "thiserror", + "tokio", + "tracing", +] + [[package]] name = "lago-core" version = "0.2.1" @@ -1104,6 +1146,31 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1232,6 +1299,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1279,6 +1356,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1479,6 +1562,20 @@ dependencies = [ "web-sys", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rustix" version = "1.1.4" @@ -1616,6 +1713,18 @@ dependencies = [ "libc", ] +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror", + "time", +] + [[package]] name = "slab" version = "0.4.12" @@ -1733,6 +1842,37 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.2" @@ -2053,6 +2193,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" diff --git a/Cargo.toml b/Cargo.toml index 3924ebe..01f375e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -89,6 +89,10 @@ aios-protocol = { version = "0.1.0", path = "../aiOS/crates/aios-protocol" } # Lago persistence layer lago-core = { version = "0.2.1", path = "../lago/crates/lago-core" } lago-journal = { version = "0.2.1", path = "../lago/crates/lago-journal" } +lago-auth = { version = "0.2.1", path = "../lago/crates/lago-auth" } + +# Auth +jsonwebtoken = "9" # Internal crates autonomic-core = { version = "0.1.0", path = "crates/autonomic-core" } diff --git a/crates/autonomic-api/Cargo.toml b/crates/autonomic-api/Cargo.toml index a0f73fa..07f3d5d 100644 --- a/crates/autonomic-api/Cargo.toml +++ b/crates/autonomic-api/Cargo.toml @@ -14,9 +14,11 @@ autonomic-controller.workspace = true autonomic-lago.workspace = true aios-protocol.workspace = true lago-core.workspace = true +lago-auth.workspace = true axum.workspace = true serde.workspace = true serde_json.workspace = true +jsonwebtoken.workspace = true tokio.workspace = true tracing.workspace = true diff --git a/crates/autonomic-api/src/auth.rs b/crates/autonomic-api/src/auth.rs new file mode 100644 index 0000000..3556638 --- /dev/null +++ b/crates/autonomic-api/src/auth.rs @@ -0,0 +1,142 @@ +//! JWT authentication middleware for the Autonomic HTTP API. +//! +//! Uses `lago-auth` JWT validation primitives to protect sensitive endpoints. +//! Auth is optional: if no JWT secret is configured, requests pass through +//! unauthenticated (local dev mode). If a secret IS configured, a valid +//! `Authorization: Bearer ` header is required. + +use std::sync::Arc; + +use axum::extract::Request; +use axum::http::StatusCode; +use axum::middleware::Next; +use axum::response::{IntoResponse, Response}; +use serde::Serialize; + +use lago_auth::jwt::{extract_bearer_token, validate_jwt}; + +/// Auth configuration for the Autonomic API. +#[derive(Clone)] +pub struct AuthConfig { + /// JWT secret for token validation. `None` means auth is disabled. + inner: Option>, +} + +impl AuthConfig { + /// Create an auth config from the environment. + /// + /// Reads `AUTONOMIC_JWT_SECRET` first, then falls back to `AUTH_SECRET`. + /// If neither is set, auth is disabled (local dev mode) and a warning is logged. + pub fn from_env() -> Self { + let secret = std::env::var("AUTONOMIC_JWT_SECRET") + .or_else(|_| std::env::var("AUTH_SECRET")) + .ok(); + + match &secret { + Some(_) => { + tracing::info!("JWT auth enabled for protected endpoints"); + } + None => { + tracing::warn!( + "No AUTONOMIC_JWT_SECRET or AUTH_SECRET configured — \ + protected endpoints are UNPROTECTED. \ + Set one of these env vars in production." + ); + } + } + + Self { + inner: secret.map(Arc::new), + } + } + + /// Create an auth config with a specific secret (for testing). + pub fn with_secret(secret: impl Into) -> Self { + Self { + inner: Some(Arc::new(secret.into())), + } + } + + /// Create an auth config with auth disabled (for testing). + pub fn disabled() -> Self { + Self { inner: None } + } + + /// Whether auth is enabled. + pub fn is_enabled(&self) -> bool { + self.inner.is_some() + } +} + +/// Auth error response body. +#[derive(Serialize)] +struct AuthErrorBody { + error: String, + message: String, +} + +fn auth_error(status: StatusCode, message: impl Into) -> Response { + let body = AuthErrorBody { + error: "unauthorized".to_string(), + message: message.into(), + }; + (status, axum::Json(body)).into_response() +} + +/// Axum middleware that validates JWT bearer tokens. +/// +/// If no secret is configured (auth disabled), requests pass through. +/// If a secret IS configured, the `Authorization: Bearer ` header +/// must contain a valid JWT signed with that secret. +pub async fn auth_middleware( + axum::extract::State(config): axum::extract::State, + request: Request, + next: Next, +) -> Response { + // If auth is disabled, pass through + let Some(secret) = &config.inner else { + return next.run(request).await; + }; + + // Extract Authorization header + let auth_header = match request.headers().get("authorization") { + Some(h) => match h.to_str() { + Ok(s) => s.to_string(), + Err(_) => return auth_error(StatusCode::UNAUTHORIZED, "invalid authorization header"), + }, + None => return auth_error(StatusCode::UNAUTHORIZED, "missing authorization header"), + }; + + // Extract bearer token + let token = match extract_bearer_token(&auth_header) { + Ok(t) => t, + Err(e) => return auth_error(StatusCode::UNAUTHORIZED, e.to_string()), + }; + + // Validate JWT + match validate_jwt(token, secret) { + Ok(_claims) => { + // Token is valid — proceed. We don't inject user context + // since Autonomic doesn't need per-user session mapping. + next.run(request).await + } + Err(e) => auth_error(StatusCode::UNAUTHORIZED, e.to_string()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn auth_config_disabled() { + let config = AuthConfig::disabled(); + assert!(!config.is_enabled()); + } + + #[test] + fn auth_config_with_secret() { + let config = AuthConfig::with_secret("test-secret"); + assert!(config.is_enabled()); + } +} diff --git a/crates/autonomic-api/src/lib.rs b/crates/autonomic-api/src/lib.rs index ce5c893..7145719 100644 --- a/crates/autonomic-api/src/lib.rs +++ b/crates/autonomic-api/src/lib.rs @@ -1,9 +1,12 @@ //! HTTP API server for the Autonomic homeostasis controller. //! //! Provides REST endpoints for querying gating profiles and projection state. +//! Supports optional JWT authentication for protecting sensitive endpoints. +pub mod auth; pub mod router; pub mod state; -pub use router::build_router; +pub use auth::AuthConfig; +pub use router::{build_router, build_router_with_auth}; pub use state::AppState; diff --git a/crates/autonomic-api/src/router.rs b/crates/autonomic-api/src/router.rs index 37aecf8..979de1c 100644 --- a/crates/autonomic-api/src/router.rs +++ b/crates/autonomic-api/src/router.rs @@ -1,14 +1,14 @@ //! HTTP router for the Autonomic API. //! //! Endpoints: -//! - `GET /health` — health check -//! - `GET /gating/{session_id}` — evaluate rules and return gating profile -//! - `GET /projection/{session_id}` — return raw homeostatic state +//! - `GET /health` — health check (unprotected) +//! - `GET /gating/{session_id}` — evaluate rules and return gating profile (auth-protected) +//! - `GET /projection/{session_id}` — return raw homeostatic state (auth-protected) use axum::extract::{Path, State}; use axum::http::StatusCode; use axum::response::Json; -use axum::{Router, routing::get}; +use axum::{Router, middleware, routing::get}; use serde::Serialize; use serde_json::json; use tracing::instrument; @@ -16,15 +16,32 @@ use tracing::instrument; use autonomic_controller::evaluate; use autonomic_core::gating::{AutonomicGatingProfile, HomeostaticState}; +use crate::auth::{AuthConfig, auth_middleware}; use crate::state::AppState; /// Build the axum router with all endpoints. +/// +/// Health endpoint is always unprotected (for load balancer / Railway health checks). +/// Gating and projection endpoints are protected with JWT auth when a secret is configured. pub fn build_router(state: AppState) -> Router { - Router::new() - .route("/health", get(health)) + build_router_with_auth(state, AuthConfig::from_env()) +} + +/// Build the router with an explicit auth config (used in tests). +pub fn build_router_with_auth(state: AppState, auth_config: AuthConfig) -> Router { + // Protected routes: gating + projection + let protected = Router::new() .route("/gating/{session_id}", get(get_gating)) .route("/projection/{session_id}", get(get_projection)) - .with_state(state) + .route_layer(middleware::from_fn_with_state(auth_config, auth_middleware)) + .with_state(state.clone()); + + // Public routes: health + let public = Router::new() + .route("/health", get(health)) + .with_state(state); + + public.merge(protected) } async fn health(State(state): State) -> Json { @@ -143,20 +160,48 @@ mod tests { use axum::body::Body; use axum::http::Request; use http_body_util::BodyExt; + use lago_auth::jwt::BroomvaClaims; use tower::ServiceExt; + const TEST_SECRET: &str = "autonomic-test-secret-32bytes!!"; + fn test_state() -> AppState { AppState::new(RuleSet::new()) } + fn app_no_auth() -> Router { + build_router_with_auth(test_state(), AuthConfig::disabled()) + } + + fn app_with_auth() -> Router { + build_router_with_auth(test_state(), AuthConfig::with_secret(TEST_SECRET)) + } + + fn make_token(secret: &str) -> String { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + let claims = BroomvaClaims { + sub: "agent-1".to_string(), + email: "agent@broomva.tech".to_string(), + exp: now + 3600, + iat: now, + }; + let key = jsonwebtoken::EncodingKey::from_secret(secret.as_bytes()); + jsonwebtoken::encode(&jsonwebtoken::Header::default(), &claims, &key).unwrap() + } + async fn body_json(resp: axum::http::Response) -> serde_json::Value { let body = resp.into_body().collect().await.unwrap().to_bytes(); serde_json::from_slice(&body).unwrap() } + // --- Health endpoint (always unprotected) --- + #[tokio::test] - async fn health_endpoint() { - let app = build_router(test_state()); + async fn health_without_token_returns_200() { + let app = app_with_auth(); let req = Request::builder() .uri("/health") .body(Body::empty()) @@ -169,9 +214,11 @@ mod tests { assert_eq!(json["status"], "ok"); } + // --- Gating endpoint: auth disabled (local dev) --- + #[tokio::test] - async fn gating_endpoint_default_session() { - let app = build_router(test_state()); + async fn gating_no_auth_no_token_returns_200() { + let app = app_no_auth(); let req = Request::builder() .uri("/gating/test-session") .body(Body::empty()) @@ -185,22 +232,100 @@ mod tests { assert_eq!(json["profile"]["operational"]["allow_side_effects"], true); } + // --- Gating endpoint: auth enabled --- + #[tokio::test] - async fn projection_endpoint_not_found() { - let app = build_router(test_state()); + async fn gating_auth_enabled_no_token_returns_401() { + let app = app_with_auth(); + let req = Request::builder() + .uri("/gating/test-session") + .body(Body::empty()) + .unwrap(); + + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + + let json = body_json(resp).await; + assert_eq!(json["error"], "unauthorized"); + } + + #[tokio::test] + async fn gating_auth_enabled_invalid_token_returns_401() { + let app = app_with_auth(); + let req = Request::builder() + .uri("/gating/test-session") + .header("authorization", "Bearer invalid-garbage-token") + .body(Body::empty()) + .unwrap(); + + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn gating_auth_enabled_wrong_secret_returns_401() { + let token = make_token("wrong-secret-not-the-right-one!!"); + let app = app_with_auth(); + let req = Request::builder() + .uri("/gating/test-session") + .header("authorization", format!("Bearer {token}")) + .body(Body::empty()) + .unwrap(); + + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn gating_auth_enabled_valid_token_returns_200() { + let token = make_token(TEST_SECRET); + let app = app_with_auth(); + let req = Request::builder() + .uri("/gating/test-session") + .header("authorization", format!("Bearer {token}")) + .body(Body::empty()) + .unwrap(); + + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + let json = body_json(resp).await; + assert_eq!(json["session_id"], "test-session"); + } + + // --- Projection endpoint: auth enabled --- + + #[tokio::test] + async fn projection_auth_enabled_no_token_returns_401() { + let app = app_with_auth(); let req = Request::builder() .uri("/projection/nonexistent") .body(Body::empty()) .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn projection_auth_enabled_valid_token_returns_200() { + let token = make_token(TEST_SECRET); + let app = app_with_auth(); + let req = Request::builder() + .uri("/projection/nonexistent") + .header("authorization", format!("Bearer {token}")) + .body(Body::empty()) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::OK); let json = body_json(resp).await; assert_eq!(json["found"], false); - assert_eq!(json["session_id"], "nonexistent"); } + // --- Projection endpoint with data (auth disabled for backward-compat) --- + #[tokio::test] async fn projection_endpoint_with_data() { let state = test_state(); @@ -212,7 +337,7 @@ mod tests { map.insert("sess-1".into(), hs); } - let app = build_router(state); + let app = build_router_with_auth(state, AuthConfig::disabled()); let req = Request::builder() .uri("/projection/sess-1") .body(Body::empty()) diff --git a/crates/autonomicd/src/main.rs b/crates/autonomicd/src/main.rs index f875557..d864f9b 100644 --- a/crates/autonomicd/src/main.rs +++ b/crates/autonomicd/src/main.rs @@ -10,7 +10,7 @@ use std::path::PathBuf; use std::sync::Arc; use anyhow::Result; -use autonomic_api::{AppState, build_router}; +use autonomic_api::{AppState, AuthConfig, build_router_with_auth}; use autonomic_controller::{ BudgetExhaustionRule, ContextPressureRule, ErrorStreakRule, SpendVelocityRule, StrategyRule, SurvivalRule, TokenExhaustionRule, @@ -102,7 +102,8 @@ async fn main() -> Result<()> { AppState::with_projections(projections, rules) }; - let app = build_router(state); + let auth_config = AuthConfig::from_env(); + let app = build_router_with_auth(state, auth_config); let listener = tokio::net::TcpListener::bind(&config.bind).await?; info!(addr = %config.bind, "autonomicd listening");