Skip to main content

mecha10_auth/
credentials.rs

1//! Credentials service for managing user authentication credentials
2//!
3//! Handles loading, saving, and deleting credentials stored at ~/.mecha10/credentials.json
4
5use crate::types::{AuthError, Credentials};
6use anyhow::{Context, Result};
7use mecha10_core::fs_utils::home_subpath;
8use std::path::PathBuf;
9
10/// Get the default credentials path (~/.mecha10/credentials.json)
11pub fn default_credentials_path() -> PathBuf {
12    home_subpath(&[".mecha10", "credentials.json"])
13}
14
15pub struct CredentialsService {
16    credentials_path: PathBuf,
17}
18
19impl CredentialsService {
20    /// Create a new CredentialsService with default path (~/.mecha10/credentials.json)
21    pub fn new() -> Self {
22        Self {
23            credentials_path: default_credentials_path(),
24        }
25    }
26
27    /// Create a CredentialsService with a custom credentials path (for testing)
28    pub fn with_path(path: PathBuf) -> Self {
29        Self { credentials_path: path }
30    }
31
32    /// Get the path to the credentials file
33    pub fn credentials_path(&self) -> &PathBuf {
34        &self.credentials_path
35    }
36
37    /// Load credentials from disk, or synthesize them from `MECHA10_API_KEY` if set.
38    ///
39    /// The env var takes priority and is the non-interactive path for an ephemeral/service
40    /// context (e.g. a Kestra-dispatched trainer container carrying the control-plane
41    /// service key) that has no `~/.mecha10/credentials.json` from an interactive
42    /// `auth login`. Checking it here - not just in a higher-level `get_api_key` helper -
43    /// means every existing caller (`get_api_key`, `is_logged_in`, `get_user_info`, and
44    /// direct `load()` callers like `handle_config_pull`'s `RobotConfigClient::with_user_id`)
45    /// picks it up with no call-site changes. `user_id`/`email` are sentinels: nothing
46    /// downstream of this crate treats them as a real identity for a service-keyed caller
47    /// (mirrors `mecha10-api`'s own `AuthUser { user_id: "control-plane", .. }` for the same
48    /// key, `jobs/auth.rs`) - `mecha10-api` disambiguates a service key from a real user key
49    /// by the key's value, not by whatever `user_id` accompanies it.
50    ///
51    /// Returns `None` if neither the env var nor the credentials file is present.
52    pub fn load(&self) -> Result<Option<Credentials>> {
53        if let Ok(api_key) = std::env::var("MECHA10_API_KEY") {
54            if !api_key.is_empty() {
55                return Ok(Some(Credentials {
56                    api_key,
57                    user_id: "control-plane".to_string(),
58                    email: "control-plane@mecha.industries".to_string(),
59                    name: None,
60                    authenticated_at: chrono::Utc::now(),
61                    auth_url: crate::DEFAULT_AUTH_URL.to_string(),
62                }));
63            }
64        }
65
66        if !self.credentials_path.exists() {
67            return Ok(None);
68        }
69
70        let content = std::fs::read_to_string(&self.credentials_path)
71            .with_context(|| format!("Failed to read credentials from {}", self.credentials_path.display()))?;
72
73        let credentials: Credentials = serde_json::from_str(&content).map_err(|e| AuthError::InvalidCredentials {
74            message: format!("Failed to parse credentials: {}", e),
75        })?;
76
77        Ok(Some(credentials))
78    }
79
80    /// Save credentials to disk
81    ///
82    /// Creates the ~/.mecha10 directory if it doesn't exist
83    pub fn save(&self, credentials: &Credentials) -> Result<()> {
84        if let Some(parent) = self.credentials_path.parent() {
85            std::fs::create_dir_all(parent)
86                .with_context(|| format!("Failed to create directory {}", parent.display()))?;
87        }
88
89        let content = serde_json::to_string_pretty(credentials).with_context(|| "Failed to serialize credentials")?;
90
91        std::fs::write(&self.credentials_path, content)
92            .with_context(|| format!("Failed to write credentials to {}", self.credentials_path.display()))?;
93
94        #[cfg(unix)]
95        {
96            use std::os::unix::fs::PermissionsExt;
97            let permissions = std::fs::Permissions::from_mode(0o600);
98            std::fs::set_permissions(&self.credentials_path, permissions)
99                .with_context(|| "Failed to set credentials file permissions")?;
100        }
101
102        Ok(())
103    }
104
105    /// Delete credentials from disk
106    pub fn delete(&self) -> Result<()> {
107        if self.credentials_path.exists() {
108            std::fs::remove_file(&self.credentials_path)
109                .with_context(|| format!("Failed to delete credentials from {}", self.credentials_path.display()))?;
110        }
111        Ok(())
112    }
113
114    /// Get API key from stored credentials (or `MECHA10_API_KEY` - see `load`'s doc comment).
115    ///
116    /// Returns None if not logged in or credentials are invalid
117    pub fn get_api_key(&self) -> Result<Option<String>> {
118        match self.load()? {
119            Some(creds) if creds.is_valid() => Ok(Some(creds.api_key)),
120            _ => Ok(None),
121        }
122    }
123
124    /// Check if user is logged in (has valid credentials)
125    pub fn is_logged_in(&self) -> bool {
126        self.load()
127            .map(|creds| creds.map(|c| c.is_valid()).unwrap_or(false))
128            .unwrap_or(false)
129    }
130
131    /// Get user info if logged in
132    pub fn get_user_info(&self) -> Result<Option<(String, String, Option<String>)>> {
133        match self.load()? {
134            Some(creds) if creds.is_valid() => Ok(Some((creds.user_id, creds.email, creds.name))),
135            _ => Ok(None),
136        }
137    }
138}
139
140impl Default for CredentialsService {
141    fn default() -> Self {
142        Self::new()
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149    use crate::DEFAULT_AUTH_URL;
150    use chrono::Utc;
151    use serial_test::serial;
152    use tempfile::TempDir;
153
154    fn create_test_credentials() -> Credentials {
155        Credentials {
156            api_key: "mecha_test123abc456def".to_string(),
157            user_id: "usr_test123".to_string(),
158            email: "test@example.com".to_string(),
159            name: Some("Test User".to_string()),
160            authenticated_at: Utc::now(),
161            auth_url: DEFAULT_AUTH_URL.to_string(),
162        }
163    }
164
165    #[test]
166    #[serial]
167    fn test_save_and_load_credentials() {
168        let temp_dir = TempDir::new().unwrap();
169        let creds_path = temp_dir.path().join("credentials.json");
170
171        let service = CredentialsService::with_path(creds_path);
172        let creds = create_test_credentials();
173
174        service.save(&creds).unwrap();
175
176        let loaded = service.load().unwrap().unwrap();
177        assert_eq!(loaded.api_key, creds.api_key);
178        assert_eq!(loaded.user_id, creds.user_id);
179        assert_eq!(loaded.email, creds.email);
180    }
181
182    #[test]
183    #[serial]
184    fn test_delete_credentials() {
185        let temp_dir = TempDir::new().unwrap();
186        let creds_path = temp_dir.path().join("credentials.json");
187
188        let service = CredentialsService::with_path(creds_path.clone());
189        let creds = create_test_credentials();
190
191        service.save(&creds).unwrap();
192        assert!(creds_path.exists());
193
194        service.delete().unwrap();
195        assert!(!creds_path.exists());
196
197        let loaded = service.load().unwrap();
198        assert!(loaded.is_none());
199    }
200
201    #[test]
202    #[serial]
203    fn test_get_api_key() {
204        let temp_dir = TempDir::new().unwrap();
205        let creds_path = temp_dir.path().join("credentials.json");
206
207        let service = CredentialsService::with_path(creds_path);
208        let creds = create_test_credentials();
209
210        assert!(service.get_api_key().unwrap().is_none());
211
212        service.save(&creds).unwrap();
213        assert_eq!(service.get_api_key().unwrap().unwrap(), creds.api_key);
214    }
215
216    #[test]
217    #[serial]
218    fn get_api_key_prefers_mecha10_api_key_env_over_the_credentials_file() {
219        let temp_dir = TempDir::new().unwrap();
220        let creds_path = temp_dir.path().join("credentials.json");
221
222        let service = CredentialsService::with_path(creds_path);
223        service.save(&create_test_credentials()).unwrap();
224
225        std::env::set_var("MECHA10_API_KEY", "9d6d598ddb4ce0e4e48eef4dc3610af");
226        let result = service.get_api_key();
227        std::env::remove_var("MECHA10_API_KEY");
228
229        assert_eq!(result.unwrap().unwrap(), "9d6d598ddb4ce0e4e48eef4dc3610af");
230    }
231
232    #[test]
233    #[serial]
234    fn get_api_key_falls_back_to_the_credentials_file_when_env_is_unset() {
235        let temp_dir = TempDir::new().unwrap();
236        let creds_path = temp_dir.path().join("credentials.json");
237
238        let service = CredentialsService::with_path(creds_path);
239        let creds = create_test_credentials();
240        service.save(&creds).unwrap();
241
242        std::env::remove_var("MECHA10_API_KEY");
243
244        assert_eq!(service.get_api_key().unwrap().unwrap(), creds.api_key);
245    }
246
247    #[test]
248    #[serial]
249    fn test_is_logged_in() {
250        let temp_dir = TempDir::new().unwrap();
251        let creds_path = temp_dir.path().join("credentials.json");
252
253        let service = CredentialsService::with_path(creds_path);
254        let creds = create_test_credentials();
255
256        std::env::remove_var("MECHA10_API_KEY");
257        assert!(!service.is_logged_in());
258
259        service.save(&creds).unwrap();
260        assert!(service.is_logged_in());
261    }
262
263    #[test]
264    fn test_credentials_validity() {
265        let valid = Credentials {
266            api_key: "mecha_valid123".to_string(),
267            user_id: "usr_test".to_string(),
268            email: "test@example.com".to_string(),
269            name: None,
270            authenticated_at: Utc::now(),
271            auth_url: DEFAULT_AUTH_URL.to_string(),
272        };
273        assert!(valid.is_valid());
274
275        let non_mecha_prefixed = Credentials {
276            api_key: "9d6d598ddb4ce0e4e48eef4dc3610af".to_string(),
277            ..valid.clone()
278        };
279        assert!(non_mecha_prefixed.is_valid());
280
281        let empty_key = Credentials {
282            api_key: "".to_string(),
283            ..valid.clone()
284        };
285        assert!(!empty_key.is_valid());
286    }
287}