feat: JWT token generation API + auto-inject TORI_JWT in executor

- POST /tori/api/token — sign ES256 JWT with configurable private key
- exec.rs auto-generates and injects TORI_JWT env var for all commands
- Config: jwt_private_key field for PEM file path
This commit is contained in:
Fam Zheng
2026-03-16 09:44:58 +00:00
parent f2fa721ef0
commit 186d882f35
8 changed files with 236 additions and 9 deletions

View File

@@ -79,6 +79,7 @@ pub struct AgentManager {
llm_config: LlmConfig,
template_repo: Option<crate::TemplateRepoConfig>,
kb: Option<Arc<crate::kb::KbManager>>,
jwt_private_key_path: Option<String>,
}
impl AgentManager {
@@ -87,6 +88,7 @@ impl AgentManager {
llm_config: LlmConfig,
template_repo: Option<crate::TemplateRepoConfig>,
kb: Option<Arc<crate::kb::KbManager>>,
jwt_private_key_path: Option<String>,
) -> Arc<Self> {
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);

95
src/api/auth.rs Normal file
View File

@@ -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<Arc<AppState>>,
Json(body): Json<TokenRequest>,
) -> 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<AppState>) -> Router {
Router::new()
.route("/token", post(generate_token))
.with_state(state)
}

View File

@@ -1,3 +1,4 @@
mod auth;
mod chat;
mod kb;
pub mod obj;
@@ -33,6 +34,7 @@ pub fn router(state: Arc<AppState>) -> 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))

View File

@@ -1,8 +1,10 @@
pub struct LocalExecutor;
pub struct LocalExecutor {
jwt_private_key_path: Option<String>,
}
impl LocalExecutor {
pub fn new() -> Self {
Self
pub fn new(jwt_private_key_path: Option<String>) -> Self {
Self { jwt_private_key_path }
}
pub async fn execute(&self, command: &str, workdir: &str) -> anyhow::Result<ExecResult> {
@@ -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<String> {
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 {

View File

@@ -31,6 +31,9 @@ pub struct Config {
pub database: DatabaseConfig,
#[serde(default)]
pub template_repo: Option<TemplateRepoConfig>,
/// Path to EC private key PEM file for JWT signing
#[serde(default)]
pub jwt_private_key: Option<String>,
}
#[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());