mecha10_auth/
credentials.rs1use crate::types::{AuthError, Credentials};
6use anyhow::{Context, Result};
7use mecha10_core::fs_utils::home_subpath;
8use std::path::PathBuf;
9
10pub 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 pub fn new() -> Self {
22 Self {
23 credentials_path: default_credentials_path(),
24 }
25 }
26
27 pub fn with_path(path: PathBuf) -> Self {
29 Self { credentials_path: path }
30 }
31
32 pub fn credentials_path(&self) -> &PathBuf {
34 &self.credentials_path
35 }
36
37 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 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 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 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 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 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}