use crate::auth::{hash_token, Principal};
use axum::{
extract::{Request, State},
http::{header, StatusCode},
response::{IntoResponse, Response},
routing::{get, post},
Extension, Json, Router,
};
use chrono::{Duration, Utc};
use kyma_core::catalog::Catalog;
use rand::RngCore;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
#[derive(Clone)]
pub struct AuthState {
pub catalog: Arc<dyn Catalog>,
}
#[derive(Debug, Deserialize)]
pub struct LoginRequest {
pub username: String,
pub password: String,
}
#[derive(Debug, Deserialize)]
pub struct RefreshRequest {
pub refresh_token: String,
}
#[derive(Debug, Serialize)]
pub struct TokenPairResponse {
pub access_token: String,
pub refresh_token: String,
pub access_expires_at: chrono::DateTime<Utc>,
pub refresh_expires_at: chrono::DateTime<Utc>,
pub user: UserInfo,
}
fn access_ttl() -> Duration {
std::env::var("KYMA_ACCESS_TTL_SECS")
.ok()
.and_then(|s| s.parse::<i64>().ok())
.map(Duration::seconds)
.unwrap_or_else(|| Duration::hours(1))
}
fn refresh_ttl() -> Duration {
std::env::var("KYMA_REFRESH_TTL_SECS")
.ok()
.and_then(|s| s.parse::<i64>().ok())
.map(Duration::seconds)
.unwrap_or_else(|| Duration::days(30))
}
fn mint_token() -> (String, Vec<u8>) {
let mut raw_bytes = [0u8; 32];
rand::thread_rng().fill_bytes(&mut raw_bytes);
use base64::Engine as _;
let raw = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(raw_bytes);
let hash = hash_token(&raw);
(raw, hash)
}
async fn issue_token_pair(
catalog: &dyn Catalog,
session_id: uuid::Uuid,
role: &str,
subject: &str,
) -> std::result::Result<TokenPairResponse, String> {
let now = Utc::now();
let access_expires_at = now + access_ttl();
let refresh_expires_at = now + refresh_ttl();
let (access_raw, access_hash) = mint_token();
let (refresh_raw, refresh_hash) = mint_token();
catalog
.insert_session_token(&access_hash, role, Some(subject), "access", access_expires_at, session_id)
.await
.map_err(|e| e.to_string())?;
catalog
.insert_session_token(&refresh_hash, role, Some(subject), "refresh", refresh_expires_at, session_id)
.await
.map_err(|e| e.to_string())?;
Ok(TokenPairResponse {
access_token: access_raw,
refresh_token: refresh_raw,
access_expires_at,
refresh_expires_at,
user: UserInfo { username: subject.to_string(), role: role.to_string() },
})
}
#[derive(Debug, Serialize)]
pub struct MeResponse {
pub username: Option<String>,
pub role: String,
}
#[derive(Debug, Serialize)]
pub struct UserInfo {
pub username: String,
pub role: String,
}
fn unauthorized_response() -> Response {
(
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({
"error": {
"code": "unauthorized",
"message": "invalid credentials"
}
})),
)
.into_response()
}
fn internal_error_response(msg: &str) -> Response {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": {
"code": "internal",
"message": msg
}
})),
)
.into_response()
}
async fn login_handler(
State(state): State<AuthState>,
Json(body): Json<LoginRequest>,
) -> Response {
let result = state
.catalog
.get_user_with_hash(&body.username)
.await;
let (user, stored_hash) = match result {
Ok(Some(pair)) => pair,
Ok(None) => return unauthorized_response(),
Err(e) => return internal_error_response(&e.to_string()),
};
if !crate::auth::passwords::verify_password(&body.password, &stored_hash) {
return unauthorized_response();
}
let session_id = uuid::Uuid::new_v4();
match issue_token_pair(state.catalog.as_ref(), session_id, &user.role, &user.username).await {
Ok(pair) => (StatusCode::OK, Json(pair)).into_response(),
Err(e) => internal_error_response(&e),
}
}
async fn refresh_handler(
State(state): State<AuthState>,
Json(body): Json<RefreshRequest>,
) -> Response {
let presented_hash = hash_token(body.refresh_token.trim());
let claim = match state.catalog.lookup_refresh_token(&presented_hash).await {
Ok(Some(c)) => c,
Ok(None) => return unauthorized_response(),
Err(e) => return internal_error_response(&e.to_string()),
};
if let Err(e) = state.catalog.revoke_api_token(&presented_hash).await {
return internal_error_response(&e.to_string());
}
let subject = claim.subject.clone().unwrap_or_default();
match issue_token_pair(state.catalog.as_ref(), claim.session_id, &claim.role, &subject).await {
Ok(pair) => (StatusCode::OK, Json(pair)).into_response(),
Err(e) => internal_error_response(&e),
}
}
async fn me_handler(Extension(principal): Extension<Principal>) -> Response {
let role = format!("{:?}", principal.role).to_lowercase();
(
StatusCode::OK,
Json(MeResponse {
username: principal.subject,
role,
}),
)
.into_response()
}
async fn logout_handler(State(state): State<AuthState>, req: Request) -> Response {
let raw_token = req
.headers()
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|s| s.strip_prefix("Bearer "))
.map(str::trim);
let Some(token) = raw_token else {
return (StatusCode::BAD_REQUEST, "missing Authorization header").into_response();
};
let hash = hash_token(token);
match state.catalog.revoke_session_by_token(&hash).await {
Ok(_) => StatusCode::NO_CONTENT.into_response(),
Err(e) => internal_error_response(&e.to_string()),
}
}
#[derive(Debug, Deserialize)]
pub struct SignupRequest {
pub username: String,
pub password: String,
}
async fn signup_handler(State(state): State<AuthState>, Json(body): Json<SignupRequest>) -> Response {
let username = body.username.trim().to_string();
if username.is_empty() || body.password.len() < 8 {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": {"code": "invalid",
"message": "username is required and password must be at least 8 characters"}})),
)
.into_response();
}
match state.catalog.count_users().await {
Ok(0) => {}
Ok(_) => {
return (
StatusCode::CONFLICT,
Json(serde_json::json!({"error": {"code": "already_setup",
"message": "setup is already complete — sign in instead"}})),
)
.into_response();
}
Err(e) => return internal_error_response(&e.to_string()),
}
let phc = match crate::auth::passwords::hash_password(&body.password) {
Ok(h) => h,
Err(e) => return internal_error_response(&format!("hashing password: {e}")),
};
if let Err(e) = state.catalog.create_user(&username, &phc, "admin").await {
return internal_error_response(&e.to_string());
}
let session_id = uuid::Uuid::new_v4();
match issue_token_pair(state.catalog.as_ref(), session_id, "admin", &username).await {
Ok(pair) => (StatusCode::CREATED, Json(pair)).into_response(),
Err(e) => internal_error_response(&e),
}
}
async fn auth_status_handler(State(state): State<AuthState>) -> Response {
let users_exist = matches!(state.catalog.count_users().await, Ok(n) if n > 0);
(
StatusCode::OK,
Json(serde_json::json!({
"users_exist": users_exist,
"setup_required": !users_exist,
})),
)
.into_response()
}
async fn env_probe_handler() -> Response {
let anthropic_key_present =
std::env::var("ANTHROPIC_API_KEY").map(|v| !v.is_empty()).unwrap_or(false);
let openai_key_present =
std::env::var("OPENAI_API_KEY").map(|v| !v.is_empty()).unwrap_or(false);
let claude_binary_found = crate::agent::engine::claude_cli::locate_binary().is_some();
let (ollama_reachable, ollama_models) = probe_ollama().await;
let gemma4_present = ollama_models.iter().any(|m| m.starts_with("gemma4"));
let recommend = if gemma4_present {
serde_json::json!({"kind": "ollama", "model": "gemma4:latest"})
} else if anthropic_key_present {
serde_json::json!({"kind": "anthropic", "model": "claude-sonnet-4-6"})
} else if claude_binary_found {
serde_json::json!({"kind": "claude_cli", "model": "sonnet"})
} else if openai_key_present {
serde_json::json!({"kind": "openai", "model": "gpt-4o"})
} else if ollama_reachable && !ollama_models.is_empty() {
serde_json::json!({"kind": "ollama", "model": ollama_models[0]})
} else {
serde_json::json!({"kind": "ollama", "model": "gemma4:latest", "needs_pull": true})
};
(
StatusCode::OK,
Json(serde_json::json!({
"ollama_reachable": ollama_reachable,
"ollama_models": ollama_models,
"gemma4_present": gemma4_present,
"anthropic_key_present": anthropic_key_present,
"openai_key_present": openai_key_present,
"claude_binary_found": claude_binary_found,
"recommend": recommend,
})),
)
.into_response()
}
async fn probe_ollama() -> (bool, Vec<String>) {
let host = std::env::var("KYMA_OLLAMA_HOST")
.ok()
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "http://localhost:11434".to_string());
let url = format!("{}/api/tags", host.trim_end_matches('/'));
let client = match reqwest::Client::builder()
.timeout(std::time::Duration::from_millis(1500))
.build()
{
Ok(c) => c,
Err(_) => return (false, Vec::new()),
};
match client.get(&url).send().await {
Ok(resp) if resp.status().is_success() => {
let models = resp
.json::<serde_json::Value>()
.await
.ok()
.and_then(|v| v.get("models").cloned())
.and_then(|m| m.as_array().cloned())
.map(|arr| {
arr.iter()
.filter_map(|m| m.get("name").and_then(|n| n.as_str()).map(String::from))
.collect::<Vec<_>>()
})
.unwrap_or_default();
(true, models)
}
_ => (false, Vec::new()),
}
}
pub fn auth_login_router(catalog: Arc<dyn Catalog>) -> Router {
let state = AuthState { catalog };
Router::new()
.route("/v1/auth/login", post(login_handler))
.route("/v1/auth/refresh", post(refresh_handler))
.route("/v1/auth/signup", post(signup_handler))
.route("/v1/auth/status", get(auth_status_handler))
.route("/v1/setup/probe", get(env_probe_handler))
.with_state(state)
}
pub fn auth_session_router(catalog: Arc<dyn Catalog>) -> Router {
let state = AuthState { catalog };
Router::new()
.route("/v1/auth/me", get(me_handler))
.route("/v1/auth/logout", post(logout_handler))
.with_state(state)
}