use anyhow::Result; use log::{info, warn}; use serde::{Deserialize, Serialize}; use std::path::PathBuf; use reqwest::Client; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DirectoryConfig { pub base_url: String, pub issuer_url: String, pub issuer: String, pub client_id: String, pub client_secret: String, pub redirect_uri: String, pub project_id: String, pub api_url: String, pub service_account_key: Option, } pub struct DirectorySetup { base_url: String, config_path: PathBuf, http_client: Client, pat_token: Option, } #[derive(Debug, Serialize)] struct CreateProjectRequest { name: String, } #[derive(Debug, Deserialize)] struct ProjectResponse { id: String, } impl DirectorySetup { pub fn new(base_url: String, config_path: PathBuf) -> Self { let http_client = Client::builder() .timeout(std::time::Duration::from_secs(30)) .build() .unwrap_or_else(|_| Client::new()); Self { base_url, config_path, http_client, pat_token: None, } } pub async fn initialize(&mut self) -> Result { info!("Initializing Directory (Zitadel) OAuth client..."); // Step 1: Wait for Zitadel to be ready self.wait_for_zitadel().await?; // Step 2: Load PAT token from file (created by start-from-init --steps) info!("Loading PAT token from Zitadel init steps..."); self.load_pat_token()?; // Step 3: Get or create project let project_id = self.get_or_create_project().await?; info!("Using project ID: {project_id}"); // Step 4: Create OAuth application let (client_id, client_secret) = self.create_oauth_application(&project_id).await?; info!("Created OAuth application with client_id: {client_id}"); // Step 5: Create config let config = DirectoryConfig { base_url: self.base_url.clone(), issuer_url: self.base_url.clone(), issuer: self.base_url.clone(), client_id, client_secret, redirect_uri: "http://localhost:3000/auth/callback".to_string(), project_id, api_url: self.base_url.clone(), service_account_key: None, }; // Step 6: Save config self.save_config(&config)?; Ok(config) } async fn wait_for_zitadel(&self) -> Result<()> { info!("Waiting for Zitadel to be ready..."); for i in 1..=120 { match self.http_client .get(format!("{}/debug/healthz", self.base_url)) .send() .await { Ok(response) if response.status().is_success() => { info!("Zitadel is ready (healthz OK)"); return Ok(()); } _ => { if i % 15 == 0 { info!("Still waiting for Zitadel... (attempt {i}/120)"); } tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; } } } Err(anyhow::anyhow!("Zitadel did not become ready within 240 seconds")) } /// Load the PAT token from the file generated by Zitadel's start-from-init --steps /// The steps YAML configures FirstInstance.Org.PatPath which tells Zitadel to /// create a machine user with IAM_OWNER role and write its PAT to disk fn load_pat_token(&mut self) -> Result<()> { let stack_path = std::env::var("BOTSERVER_STACK_PATH") .unwrap_or_else(|_| "./botserver-stack".to_string()); let pat_path = PathBuf::from(&stack_path).join("conf/directory/admin-pat.txt"); if pat_path.exists() { let pat_token = std::fs::read_to_string(&pat_path) .map_err(|e| anyhow::anyhow!("Failed to read PAT file {}: {}", pat_path.display(), e))? .trim() .to_string(); if pat_token.is_empty() { return Err(anyhow::anyhow!( "PAT file exists at {} but is empty. Zitadel start-from-init may have failed.", pat_path.display() )); } info!("Loaded PAT token from: {} (len={})", pat_path.display(), pat_token.len()); self.pat_token = Some(pat_token); return Ok(()); } // Also check the legacy location let legacy_pat_path = std::path::Path::new("./botserver-stack/conf/directory/admin-pat.txt"); if legacy_pat_path.exists() { let pat_token = std::fs::read_to_string(legacy_pat_path) .map_err(|e| anyhow::anyhow!("Failed to read PAT file: {e}"))? .trim() .to_string(); if !pat_token.is_empty() { info!("Loaded PAT token from legacy path"); self.pat_token = Some(pat_token); return Ok(()); } } Err(anyhow::anyhow!( "No PAT token file found at {}. \ Zitadel must be started with 'start-from-init --steps ' \ where steps.yaml has FirstInstance.Org.PatPath configured.", pat_path.display() )) } async fn get_or_create_project(&self) -> Result { info!("Getting or creating Zitadel project..."); let auth_header = self.get_auth_header()?; // Try to list existing projects via management API v1 let list_response = self.http_client .post(format!("{}/management/v1/projects/_search", self.base_url)) .header("Authorization", &auth_header) .json(&serde_json::json!({})) .send() .await?; if list_response.status().is_success() { let projects: serde_json::Value = list_response.json().await?; if let Some(result) = projects.get("result").and_then(|r| r.as_array()) { for project in result { if project.get("name") .and_then(|n| n.as_str()) .map(|n| n == "General Bots") .unwrap_or(false) { if let Some(id) = project.get("id").and_then(|i| i.as_str()) { info!("Found existing 'General Bots' project: {id}"); return Ok(id.to_string()); } } } } } // Create new project info!("Creating new 'General Bots' project..."); let create_request = CreateProjectRequest { name: "General Bots".to_string(), }; let create_response = self.http_client .post(format!("{}/management/v1/projects", self.base_url)) .header("Authorization", self.get_auth_header()?) .json(&create_request) .send() .await?; if !create_response.status().is_success() { let error_text = create_response.text().await.unwrap_or_default(); return Err(anyhow::anyhow!("Failed to create project: {error_text}")); } let project: ProjectResponse = create_response.json().await?; info!("Created project with ID: {}", project.id); Ok(project.id) } async fn create_oauth_application(&self, project_id: &str) -> Result<(String, String)> { info!("Creating OAuth/OIDC application for BotServer..."); let auth_header = self.get_auth_header()?; // Use the management v1 OIDC app creation endpoint which returns // client_id and client_secret in the response directly let app_body = serde_json::json!({ "name": "BotServer", "redirectUris": [ "http://localhost:3000/auth/callback", "http://localhost:8080/auth/callback" ], "responseTypes": ["OIDC_RESPONSE_TYPE_CODE"], "grantTypes": [ "OIDC_GRANT_TYPE_AUTHORIZATION_CODE", "OIDC_GRANT_TYPE_REFRESH_TOKEN" ], "appType": "OIDC_APP_TYPE_WEB", "authMethodType": "OIDC_AUTH_METHOD_TYPE_POST", "postLogoutRedirectUris": ["http://localhost:3000"], "devMode": true }); let app_response = self.http_client .post(format!("{}/management/v1/projects/{project_id}/apps/oidc", self.base_url)) .header("Authorization", &auth_header) .json(&app_body) .send() .await?; if !app_response.status().is_success() { let error_text = app_response.text().await.unwrap_or_default(); return Err(anyhow::anyhow!("Failed to create OIDC app: {error_text}")); } let app_data: serde_json::Value = app_response.json().await .map_err(|e| anyhow::anyhow!("Failed to parse OIDC app response: {e}"))?; info!("OIDC app creation response: {}", serde_json::to_string_pretty(&app_data).unwrap_or_default()); // The response contains clientId and clientSecret directly let client_id = app_data .get("clientId") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("No clientId in OIDC app response: {app_data}"))? .to_string(); let client_secret = app_data .get("clientSecret") .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); if client_secret.is_empty() { warn!("No clientSecret returned — app may use PKCE only"); } info!("Retrieved OAuth client credentials (client_id: {client_id})"); Ok((client_id, client_secret)) } fn get_auth_header(&self) -> Result { match &self.pat_token { Some(token) => Ok(format!("Bearer {token}")), None => Err(anyhow::anyhow!( "No PAT token available. Cannot authenticate with Zitadel API." )), } } fn save_config(&self, config: &DirectoryConfig) -> Result<()> { if let Some(parent) = self.config_path.parent() { std::fs::create_dir_all(parent)?; } let content = serde_json::to_string_pretty(config)?; std::fs::write(&self.config_path, content)?; info!("Saved Directory config to: {}", self.config_path.display()); println!(); println!("╔════════════════════════════════════════════════════════════╗"); println!("║ ZITADEL OAUTH CLIENT CONFIGURED ║"); println!("╠════════════════════════════════════════════════════════════╣"); println!("║ Project ID: {:<43}║", config.project_id); println!("║ Client ID: {:<43}║", config.client_id); println!("║ Redirect URI: {:<43}║", config.redirect_uri); println!("║ Config saved: {:<43}║", self.config_path.display().to_string().chars().take(43).collect::()); println!("╚════════════════════════════════════════════════════════════╝"); println!(); Ok(()) } } #[cfg(test)] mod tests { use super::*; #[test] fn test_directory_config_serialization() { let config = DirectoryConfig { base_url: "http://localhost:8300".to_string(), issuer_url: "http://localhost:8300".to_string(), issuer: "http://localhost:8300".to_string(), client_id: "test_client".to_string(), client_secret: "test_secret".to_string(), redirect_uri: "http://localhost:3000/callback".to_string(), project_id: "12345".to_string(), api_url: "http://localhost:8300".to_string(), service_account_key: None, }; let json = serde_json::to_string(&config).unwrap(); assert!(json.contains("test_client")); assert!(json.contains("test_secret")); } }