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:
95
src/api/auth.rs
Normal file
95
src/api/auth.rs
Normal 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)
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user