mecha10-auth 0.7.0

Authentication services for Mecha10 - shared between CLI and launcher
Documentation
//! Credentials service for managing user authentication credentials
//!
//! Handles loading, saving, and deleting credentials stored at ~/.mecha10/credentials.json

use crate::types::{AuthError, Credentials};
use anyhow::{Context, Result};
use mecha10_core::fs_utils::home_subpath;
use std::path::PathBuf;

/// Get the default credentials path (~/.mecha10/credentials.json)
pub fn default_credentials_path() -> PathBuf {
    home_subpath(&[".mecha10", "credentials.json"])
}

pub struct CredentialsService {
    credentials_path: PathBuf,
}

impl CredentialsService {
    /// Create a new CredentialsService with default path (~/.mecha10/credentials.json)
    pub fn new() -> Self {
        Self {
            credentials_path: default_credentials_path(),
        }
    }

    /// Create a CredentialsService with a custom credentials path (for testing)
    pub fn with_path(path: PathBuf) -> Self {
        Self { credentials_path: path }
    }

    /// Get the path to the credentials file
    pub fn credentials_path(&self) -> &PathBuf {
        &self.credentials_path
    }

    /// Load credentials from disk, or synthesize them from `MECHA10_API_KEY` if set.
    ///
    /// The env var takes priority and is the non-interactive path for an ephemeral/service
    /// context (e.g. a Kestra-dispatched trainer container carrying the control-plane
    /// service key) that has no `~/.mecha10/credentials.json` from an interactive
    /// `auth login`. Checking it here - not just in a higher-level `get_api_key` helper -
    /// means every existing caller (`get_api_key`, `is_logged_in`, `get_user_info`, and
    /// direct `load()` callers like `handle_config_pull`'s `RobotConfigClient::with_user_id`)
    /// picks it up with no call-site changes. `user_id`/`email` are sentinels: nothing
    /// downstream of this crate treats them as a real identity for a service-keyed caller
    /// (mirrors `mecha10-api`'s own `AuthUser { user_id: "control-plane", .. }` for the same
    /// key, `jobs/auth.rs`) - `mecha10-api` disambiguates a service key from a real user key
    /// by the key's value, not by whatever `user_id` accompanies it.
    ///
    /// Returns `None` if neither the env var nor the credentials file is present.
    pub fn load(&self) -> Result<Option<Credentials>> {
        if let Ok(api_key) = std::env::var("MECHA10_API_KEY") {
            if !api_key.is_empty() {
                return Ok(Some(Credentials {
                    api_key,
                    user_id: "control-plane".to_string(),
                    email: "control-plane@mecha.industries".to_string(),
                    name: None,
                    authenticated_at: chrono::Utc::now(),
                    auth_url: crate::DEFAULT_AUTH_URL.to_string(),
                }));
            }
        }

        if !self.credentials_path.exists() {
            return Ok(None);
        }

        let content = std::fs::read_to_string(&self.credentials_path)
            .with_context(|| format!("Failed to read credentials from {}", self.credentials_path.display()))?;

        let credentials: Credentials = serde_json::from_str(&content).map_err(|e| AuthError::InvalidCredentials {
            message: format!("Failed to parse credentials: {}", e),
        })?;

        Ok(Some(credentials))
    }

    /// Save credentials to disk
    ///
    /// Creates the ~/.mecha10 directory if it doesn't exist
    pub fn save(&self, credentials: &Credentials) -> Result<()> {
        if let Some(parent) = self.credentials_path.parent() {
            std::fs::create_dir_all(parent)
                .with_context(|| format!("Failed to create directory {}", parent.display()))?;
        }

        let content = serde_json::to_string_pretty(credentials).with_context(|| "Failed to serialize credentials")?;

        std::fs::write(&self.credentials_path, content)
            .with_context(|| format!("Failed to write credentials to {}", self.credentials_path.display()))?;

        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            let permissions = std::fs::Permissions::from_mode(0o600);
            std::fs::set_permissions(&self.credentials_path, permissions)
                .with_context(|| "Failed to set credentials file permissions")?;
        }

        Ok(())
    }

    /// Delete credentials from disk
    pub fn delete(&self) -> Result<()> {
        if self.credentials_path.exists() {
            std::fs::remove_file(&self.credentials_path)
                .with_context(|| format!("Failed to delete credentials from {}", self.credentials_path.display()))?;
        }
        Ok(())
    }

    /// Get API key from stored credentials (or `MECHA10_API_KEY` - see `load`'s doc comment).
    ///
    /// Returns None if not logged in or credentials are invalid
    pub fn get_api_key(&self) -> Result<Option<String>> {
        match self.load()? {
            Some(creds) if creds.is_valid() => Ok(Some(creds.api_key)),
            _ => Ok(None),
        }
    }

    /// Check if user is logged in (has valid credentials)
    pub fn is_logged_in(&self) -> bool {
        self.load()
            .map(|creds| creds.map(|c| c.is_valid()).unwrap_or(false))
            .unwrap_or(false)
    }

    /// Get user info if logged in
    pub fn get_user_info(&self) -> Result<Option<(String, String, Option<String>)>> {
        match self.load()? {
            Some(creds) if creds.is_valid() => Ok(Some((creds.user_id, creds.email, creds.name))),
            _ => Ok(None),
        }
    }
}

impl Default for CredentialsService {
    fn default() -> Self {
        Self::new()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::DEFAULT_AUTH_URL;
    use chrono::Utc;
    use serial_test::serial;
    use tempfile::TempDir;

    fn create_test_credentials() -> Credentials {
        Credentials {
            api_key: "mecha_test123abc456def".to_string(),
            user_id: "usr_test123".to_string(),
            email: "test@example.com".to_string(),
            name: Some("Test User".to_string()),
            authenticated_at: Utc::now(),
            auth_url: DEFAULT_AUTH_URL.to_string(),
        }
    }

    #[test]
    #[serial]
    fn test_save_and_load_credentials() {
        let temp_dir = TempDir::new().unwrap();
        let creds_path = temp_dir.path().join("credentials.json");

        let service = CredentialsService::with_path(creds_path);
        let creds = create_test_credentials();

        service.save(&creds).unwrap();

        let loaded = service.load().unwrap().unwrap();
        assert_eq!(loaded.api_key, creds.api_key);
        assert_eq!(loaded.user_id, creds.user_id);
        assert_eq!(loaded.email, creds.email);
    }

    #[test]
    #[serial]
    fn test_delete_credentials() {
        let temp_dir = TempDir::new().unwrap();
        let creds_path = temp_dir.path().join("credentials.json");

        let service = CredentialsService::with_path(creds_path.clone());
        let creds = create_test_credentials();

        service.save(&creds).unwrap();
        assert!(creds_path.exists());

        service.delete().unwrap();
        assert!(!creds_path.exists());

        let loaded = service.load().unwrap();
        assert!(loaded.is_none());
    }

    #[test]
    #[serial]
    fn test_get_api_key() {
        let temp_dir = TempDir::new().unwrap();
        let creds_path = temp_dir.path().join("credentials.json");

        let service = CredentialsService::with_path(creds_path);
        let creds = create_test_credentials();

        assert!(service.get_api_key().unwrap().is_none());

        service.save(&creds).unwrap();
        assert_eq!(service.get_api_key().unwrap().unwrap(), creds.api_key);
    }

    #[test]
    #[serial]
    fn get_api_key_prefers_mecha10_api_key_env_over_the_credentials_file() {
        let temp_dir = TempDir::new().unwrap();
        let creds_path = temp_dir.path().join("credentials.json");

        let service = CredentialsService::with_path(creds_path);
        service.save(&create_test_credentials()).unwrap();

        std::env::set_var("MECHA10_API_KEY", "9d6d598ddb4ce0e4e48eef4dc3610af");
        let result = service.get_api_key();
        std::env::remove_var("MECHA10_API_KEY");

        assert_eq!(result.unwrap().unwrap(), "9d6d598ddb4ce0e4e48eef4dc3610af");
    }

    #[test]
    #[serial]
    fn get_api_key_falls_back_to_the_credentials_file_when_env_is_unset() {
        let temp_dir = TempDir::new().unwrap();
        let creds_path = temp_dir.path().join("credentials.json");

        let service = CredentialsService::with_path(creds_path);
        let creds = create_test_credentials();
        service.save(&creds).unwrap();

        std::env::remove_var("MECHA10_API_KEY");

        assert_eq!(service.get_api_key().unwrap().unwrap(), creds.api_key);
    }

    #[test]
    #[serial]
    fn test_is_logged_in() {
        let temp_dir = TempDir::new().unwrap();
        let creds_path = temp_dir.path().join("credentials.json");

        let service = CredentialsService::with_path(creds_path);
        let creds = create_test_credentials();

        std::env::remove_var("MECHA10_API_KEY");
        assert!(!service.is_logged_in());

        service.save(&creds).unwrap();
        assert!(service.is_logged_in());
    }

    #[test]
    fn test_credentials_validity() {
        let valid = Credentials {
            api_key: "mecha_valid123".to_string(),
            user_id: "usr_test".to_string(),
            email: "test@example.com".to_string(),
            name: None,
            authenticated_at: Utc::now(),
            auth_url: DEFAULT_AUTH_URL.to_string(),
        };
        assert!(valid.is_valid());

        let non_mecha_prefixed = Credentials {
            api_key: "9d6d598ddb4ce0e4e48eef4dc3610af".to_string(),
            ..valid.clone()
        };
        assert!(non_mecha_prefixed.is_valid());

        let empty_key = Credentials {
            api_key: "".to_string(),
            ..valid.clone()
        };
        assert!(!empty_key.is_valid());
    }
}