use reqwest::Client as HttpClient; use serde::{Deserialize, Serialize}; use tracing::{info, warn}; pub struct KratosClient { admin_url: String, http: HttpClient, } // ── Response types ────────────────────────────────────────────────────────── #[derive(Debug, Serialize, Deserialize)] pub struct Identity { pub id: String, #[serde(default)] pub state: String, pub traits: IdentityTraits, #[serde(default)] pub created_at: String, #[serde(default)] pub updated_at: String, } #[derive(Debug, Serialize, Deserialize)] pub struct IdentityTraits { #[serde(default)] pub email: String, #[serde(default)] pub name: Option, } #[derive(Debug, Serialize, Deserialize)] pub struct NameTraits { #[serde(default)] pub first: String, #[serde(default)] pub last: String, } #[derive(Debug, Serialize, Deserialize)] pub struct Session { pub id: String, #[serde(default)] pub active: bool, #[serde(default)] pub authenticated_at: String, #[serde(default)] pub expires_at: String, } #[derive(Debug, Serialize, Deserialize)] pub struct RecoveryResponse { #[serde(default)] pub recovery_link: String, #[serde(default)] pub recovery_code: String, } // ── Implementation ────────────────────────────────────────────────────────── impl KratosClient { pub fn new(admin_url: String) -> Self { Self { admin_url: admin_url.trim_end_matches('/').to_string(), http: HttpClient::new(), } } /// Resolve an email or UUID to an identity ID. /// If the input looks like a UUID, use it directly. /// Otherwise, search by credentials_identifier (email). async fn resolve_id(&self, email_or_id: &str) -> Result { if is_uuid(email_or_id) { return Ok(email_or_id.to_string()); } // Search by email let url = format!( "{}/admin/identities?credentials_identifier={}", self.admin_url, urlencoding::encode(email_or_id) ); let resp = self .http .get(&url) .send() .await .map_err(|e| format!("failed to search identities: {e}"))?; if !resp.status().is_success() { let text = resp.text().await.unwrap_or_default(); return Err(format!("identity search failed: {text}")); } let identities: Vec = resp .json() .await .map_err(|e| format!("failed to parse identities: {e}"))?; identities .first() .map(|i| i.id.clone()) .ok_or_else(|| format!("no identity found for '{email_or_id}'")) } pub async fn list_users( &self, search: Option<&str>, limit: Option, ) -> Result, String> { let mut url = format!("{}/admin/identities", self.admin_url); let mut params = vec![]; if let Some(s) = search { params.push(format!( "credentials_identifier={}", urlencoding::encode(s) )); } params.push(format!("page_size={}", limit.unwrap_or(50))); if !params.is_empty() { url.push_str(&format!("?{}", params.join("&"))); } let resp = self .http .get(&url) .send() .await .map_err(|e| format!("failed to list identities: {e}"))?; if !resp.status().is_success() { let text = resp.text().await.unwrap_or_default(); return Err(format!("list identities failed: {text}")); } resp.json() .await .map_err(|e| format!("failed to parse identities: {e}")) } pub async fn get_user(&self, email_or_id: &str) -> Result { let id = self.resolve_id(email_or_id).await?; let url = format!("{}/admin/identities/{}", self.admin_url, id); let resp = self .http .get(&url) .send() .await .map_err(|e| format!("failed to get identity: {e}"))?; if !resp.status().is_success() { let text = resp.text().await.unwrap_or_default(); return Err(format!("get identity failed: {text}")); } resp.json() .await .map_err(|e| format!("failed to parse identity: {e}")) } pub async fn create_user( &self, email: &str, first_name: Option<&str>, last_name: Option<&str>, ) -> Result { let mut traits = serde_json::json!({ "email": email }); if first_name.is_some() || last_name.is_some() { traits["name"] = serde_json::json!({ "first": first_name.unwrap_or(""), "last": last_name.unwrap_or(""), }); } let body = serde_json::json!({ "schema_id": "default", "traits": traits, }); let url = format!("{}/admin/identities", self.admin_url); let resp = self .http .post(&url) .json(&body) .send() .await .map_err(|e| format!("failed to create identity: {e}"))?; if !resp.status().is_success() { let text = resp.text().await.unwrap_or_default(); return Err(format!("create identity failed: {text}")); } let identity: Identity = resp .json() .await .map_err(|e| format!("failed to parse identity: {e}"))?; info!(id = identity.id.as_str(), email, "Created identity"); Ok(identity) } pub async fn recover_user(&self, email_or_id: &str) -> Result { let id = self.resolve_id(email_or_id).await?; let body = serde_json::json!({ "identity_id": id, }); let url = format!("{}/admin/recovery/code", self.admin_url); let resp = self .http .post(&url) .json(&body) .send() .await .map_err(|e| format!("failed to create recovery: {e}"))?; if !resp.status().is_success() { let text = resp.text().await.unwrap_or_default(); return Err(format!("create recovery failed: {text}")); } resp.json() .await .map_err(|e| format!("failed to parse recovery response: {e}")) } pub async fn disable_user(&self, email_or_id: &str) -> Result { self.set_state(email_or_id, "inactive").await } pub async fn enable_user(&self, email_or_id: &str) -> Result { self.set_state(email_or_id, "active").await } async fn set_state(&self, email_or_id: &str, state: &str) -> Result { let id = self.resolve_id(email_or_id).await?; let url = format!("{}/admin/identities/{}", self.admin_url, id); let body = serde_json::json!({ "state": state }); let resp = self .http .put(&url) .json(&body) .send() .await .map_err(|e| format!("failed to update identity state: {e}"))?; if !resp.status().is_success() { let text = resp.text().await.unwrap_or_default(); return Err(format!("update identity state failed: {text}")); } info!(id = id.as_str(), state, "Updated identity state"); resp.json() .await .map_err(|e| format!("failed to parse identity: {e}")) } pub async fn list_sessions(&self, email_or_id: &str) -> Result, String> { let id = self.resolve_id(email_or_id).await?; let url = format!("{}/admin/identities/{}/sessions", self.admin_url, id); let resp = self .http .get(&url) .send() .await .map_err(|e| format!("failed to list sessions: {e}"))?; if !resp.status().is_success() { let text = resp.text().await.unwrap_or_default(); return Err(format!("list sessions failed: {text}")); } resp.json() .await .map_err(|e| format!("failed to parse sessions: {e}")) } } /// Check if a string looks like a UUID (Kratos identity ID format). fn is_uuid(s: &str) -> bool { s.len() == 36 && s.chars() .all(|c| c.is_ascii_hexdigit() || c == '-') && s.matches('-').count() == 4 } // ── URL encoding helper ───────────────────────────────────────────────────── mod urlencoding { pub fn encode(s: &str) -> String { url::form_urlencoded::byte_serialize(s.as_bytes()).collect() } } #[cfg(test)] mod tests { use super::*; #[test] fn test_identity_deserialize() { let json = serde_json::json!({ "id": "cd0a2db5-1234-5678-9abc-def012345678", "state": "active", "traits": { "email": "sienna@sunbeam.pt", "name": { "first": "Sienna", "last": "V" } }, "created_at": "2026-03-05T10:00:00Z", "updated_at": "2026-03-20T12:00:00Z", }); let id: Identity = serde_json::from_value(json).unwrap(); assert_eq!(id.state, "active"); assert_eq!(id.traits.email, "sienna@sunbeam.pt"); assert_eq!(id.traits.name.as_ref().unwrap().first, "Sienna"); } #[test] fn test_identity_minimal_traits() { let json = serde_json::json!({ "id": "abc-123", "traits": { "email": "test@example.com" }, }); let id: Identity = serde_json::from_value(json).unwrap(); assert_eq!(id.traits.email, "test@example.com"); assert!(id.traits.name.is_none()); } #[test] fn test_session_deserialize() { let json = serde_json::json!({ "id": "sess-123", "active": true, "authenticated_at": "2026-03-22T10:00:00Z", "expires_at": "2026-04-21T10:00:00Z", }); let sess: Session = serde_json::from_value(json).unwrap(); assert!(sess.active); assert_eq!(sess.id, "sess-123"); } #[test] fn test_is_uuid() { assert!(is_uuid("cd0a2db5-1234-5678-9abc-def012345678")); assert!(!is_uuid("sienna@sunbeam.pt")); assert!(!is_uuid("not-a-uuid")); assert!(!is_uuid("")); } #[test] fn test_urlencoding() { assert_eq!(urlencoding::encode("hello@world.com"), "hello%40world.com"); assert_eq!(urlencoding::encode("plain"), "plain"); } }