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