diff --git a/Cargo.lock b/Cargo.lock index 60e5e87..16cbf0d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -279,6 +279,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + [[package]] name = "digest" version = "0.10.7" @@ -899,6 +908,21 @@ 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 = "lazy_static" version = "1.5.0" @@ -1054,6 +1078,16 @@ 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-bigint-dig" version = "0.8.6" @@ -1070,6 +1104,12 @@ dependencies = [ "zeroize", ] +[[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" @@ -1135,6 +1175,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 = "pem-rfc7468" version = "0.7.0" @@ -1198,6 +1248,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" @@ -1685,6 +1741,18 @@ dependencies = [ "rand_core 0.6.4", ] +[[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" @@ -2000,6 +2068,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" @@ -2107,6 +2206,7 @@ dependencies = [ "axum", "chrono", "futures", + "jsonwebtoken", "mime_guess", "nix", "pulldown-cmark", diff --git a/Cargo.toml b/Cargo.toml index 9e71f9b..e2e06bb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,3 +28,4 @@ mime_guess = "2" tokio-util = { version = "0.7", features = ["io"] } nix = { version = "0.29", features = ["signal"] } pulldown-cmark = "0.12" +jsonwebtoken = "9" diff --git a/Dockerfile b/Dockerfile index 1d29bb6..a1bf23d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,6 +22,7 @@ COPY --from=frontend /app/web/dist ./web/dist/ COPY scripts/embed.py ./scripts/ COPY app-templates/ ./templates/ COPY config.yaml . +COPY data/jwt-private.pem ./data/ EXPOSE 3000 CMD ["./tori"] diff --git a/src/agent.rs b/src/agent.rs index f3d56c8..3e03ead 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -79,6 +79,7 @@ pub struct AgentManager { llm_config: LlmConfig, template_repo: Option, kb: Option>, + jwt_private_key_path: Option, } impl AgentManager { @@ -87,6 +88,7 @@ impl AgentManager { llm_config: LlmConfig, template_repo: Option, kb: Option>, + jwt_private_key_path: Option, ) -> Arc { Arc::new(Self { agents: RwLock::new(HashMap::new()), @@ -97,6 +99,7 @@ impl AgentManager { llm_config, template_repo, kb, + jwt_private_key_path, }) } @@ -177,7 +180,7 @@ async fn agent_loop( let pool = mgr.pool.clone(); let llm_config = mgr.llm_config.clone(); let llm = LlmClient::new(&llm_config); - let exec = LocalExecutor::new(); + let exec = LocalExecutor::new(mgr.jwt_private_key_path.clone()); let workdir = format!("/app/data/workspaces/{}", project_id); tracing::info!("Agent loop started for project {}", project_id); diff --git a/src/api/auth.rs b/src/api/auth.rs new file mode 100644 index 0000000..7c24949 --- /dev/null +++ b/src/api/auth.rs @@ -0,0 +1,95 @@ +use std::sync::Arc; + +use axum::{ + extract::State, + http::StatusCode, + response::IntoResponse, + routing::post, + Json, Router, +}; +use jsonwebtoken::{encode, EncodingKey, Header, Algorithm}; +use serde::{Deserialize, Serialize}; + +use crate::AppState; + +#[derive(Serialize)] +struct TokenResponse { + token: String, + expires_in: u64, +} + +#[derive(Deserialize)] +struct TokenRequest { + /// Subject claim (e.g. "oseng", "tori-agent") + #[serde(default = "default_sub")] + sub: String, + /// Token validity in seconds (default: 300) + #[serde(default = "default_ttl")] + ttl_secs: u64, +} + +fn default_sub() -> String { + "oseng".to_string() +} + +fn default_ttl() -> u64 { + 300 +} + +#[derive(Serialize)] +struct Claims { + sub: String, + iat: usize, + exp: usize, +} + +async fn generate_token( + State(state): State>, + Json(body): Json, +) -> impl IntoResponse { + let privkey_pem = match &state.config.jwt_private_key { + Some(path) => match std::fs::read_to_string(path) { + Ok(pem) => pem, + Err(e) => { + tracing::error!("Failed to read JWT private key {}: {}", path, e); + return (StatusCode::INTERNAL_SERVER_ERROR, "JWT private key not readable").into_response(); + } + }, + None => { + return (StatusCode::SERVICE_UNAVAILABLE, "JWT not configured (jwt_private_key missing)").into_response(); + } + }; + + let key = match EncodingKey::from_ec_pem(privkey_pem.as_bytes()) { + Ok(k) => k, + Err(e) => { + tracing::error!("Invalid EC private key: {}", e); + return (StatusCode::INTERNAL_SERVER_ERROR, "Invalid JWT private key").into_response(); + } + }; + + let now = chrono::Utc::now().timestamp() as usize; + let claims = Claims { + sub: body.sub, + iat: now, + exp: now + body.ttl_secs as usize, + }; + + let header = Header::new(Algorithm::ES256); + match encode(&header, &claims, &key) { + Ok(token) => Json(TokenResponse { + token, + expires_in: body.ttl_secs, + }).into_response(), + Err(e) => { + tracing::error!("Failed to encode JWT: {}", e); + (StatusCode::INTERNAL_SERVER_ERROR, "Failed to generate token").into_response() + } + } +} + +pub fn router(state: Arc) -> Router { + Router::new() + .route("/token", post(generate_token)) + .with_state(state) +} diff --git a/src/api/mod.rs b/src/api/mod.rs index 8bf8157..a8b3181 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1,3 +1,4 @@ +mod auth; mod chat; mod kb; pub mod obj; @@ -33,6 +34,7 @@ pub fn router(state: Arc) -> Router { .merge(kb::router(state.clone())) .merge(settings::router(state.clone())) .merge(chat::router(state.clone())) + .merge(auth::router(state.clone())) .route("/projects/{id}/files/{*path}", get(serve_project_file)) .route("/projects/{id}/app/{*path}", any(proxy_to_service).with_state(state.clone())) .route("/projects/{id}/app/", any(proxy_to_service_root).with_state(state)) diff --git a/src/exec.rs b/src/exec.rs index 370c65c..7f95c44 100644 --- a/src/exec.rs +++ b/src/exec.rs @@ -1,8 +1,10 @@ -pub struct LocalExecutor; +pub struct LocalExecutor { + jwt_private_key_path: Option, +} impl LocalExecutor { - pub fn new() -> Self { - Self + pub fn new(jwt_private_key_path: Option) -> Self { + Self { jwt_private_key_path } } pub async fn execute(&self, command: &str, workdir: &str) -> anyhow::Result { @@ -16,14 +18,19 @@ impl LocalExecutor { Err(_) => venv_bin, }; - let output = tokio::process::Command::new("sh") - .arg("-c") + let mut cmd = tokio::process::Command::new("sh"); + cmd.arg("-c") .arg(command) .current_dir(workdir) .env("PATH", &path) - .env("VIRTUAL_ENV", format!("{}/.venv", workdir)) - .output() - .await?; + .env("VIRTUAL_ENV", format!("{}/.venv", workdir)); + + // Auto-inject GLIDE_JWT if a private key is configured + if let Some(token) = self.generate_jwt() { + cmd.env("TORI_JWT", token); + } + + let output = cmd.output().await?; Ok(ExecResult { stdout: String::from_utf8_lossy(&output.stdout).to_string(), @@ -31,6 +38,20 @@ impl LocalExecutor { exit_code: output.status.code().unwrap_or(-1), }) } + + fn generate_jwt(&self) -> Option { + let path = self.jwt_private_key_path.as_ref()?; + let pem = std::fs::read_to_string(path).ok()?; + let key = jsonwebtoken::EncodingKey::from_ec_pem(pem.as_bytes()).ok()?; + let now = chrono::Utc::now().timestamp() as usize; + let claims = serde_json::json!({ + "sub": "tori", + "iat": now, + "exp": now + 300, + }); + let header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::ES256); + jsonwebtoken::encode(&header, &claims, &key).ok() + } } pub struct ExecResult { diff --git a/src/main.rs b/src/main.rs index df9c5b5..362f0b4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -31,6 +31,9 @@ pub struct Config { pub database: DatabaseConfig, #[serde(default)] pub template_repo: Option, + /// Path to EC private key PEM file for JWT signing + #[serde(default)] + pub jwt_private_key: Option, } #[derive(Debug, Clone, serde::Deserialize)] @@ -104,6 +107,7 @@ async fn main() -> anyhow::Result<()> { config.llm.clone(), config.template_repo.clone(), kb_arc.clone(), + config.jwt_private_key.clone(), ); timer::start_timer_runner(database.pool.clone(), agent_mgr.clone());