garmin_cli/config/
credentials.rs

1use crate::client::{OAuth1Token, OAuth2Token};
2use crate::error::{GarminError, Result};
3use std::fs;
4use std::path::PathBuf;
5
6const OAUTH1_FILENAME: &str = "oauth1_token.json";
7const OAUTH2_FILENAME: &str = "oauth2_token.json";
8const SERVICE_NAME: &str = "garmin-cli";
9
10/// Manages credential storage for Garmin tokens.
11/// Supports file-based storage with optional keyring integration.
12pub struct CredentialStore {
13    profile: String,
14    base_dir: PathBuf,
15}
16
17impl CredentialStore {
18    /// Create a new credential store for the given profile
19    pub fn new(profile: Option<String>) -> Result<Self> {
20        let profile = profile.unwrap_or_else(|| "default".to_string());
21        let base_dir = super::data_dir()?.join(&profile);
22        super::ensure_dir(&base_dir)?;
23
24        Ok(Self { profile, base_dir })
25    }
26
27    /// Create a credential store with a custom base directory (for testing)
28    pub fn with_dir(profile: impl Into<String>, base_dir: PathBuf) -> Result<Self> {
29        let profile = profile.into();
30        let dir = base_dir.join(&profile);
31        super::ensure_dir(&dir)?;
32
33        Ok(Self {
34            profile,
35            base_dir: dir,
36        })
37    }
38
39    /// Get the profile name
40    pub fn profile(&self) -> &str {
41        &self.profile
42    }
43
44    /// Save OAuth1 token to storage
45    pub fn save_oauth1(&self, token: &OAuth1Token) -> Result<()> {
46        let path = self.base_dir.join(OAUTH1_FILENAME);
47        let json = serde_json::to_string_pretty(token)?;
48        fs::write(&path, json)?;
49
50        // Set restrictive permissions on Unix
51        #[cfg(unix)]
52        {
53            use std::os::unix::fs::PermissionsExt;
54            fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?;
55        }
56
57        Ok(())
58    }
59
60    /// Load OAuth1 token from storage
61    pub fn load_oauth1(&self) -> Result<Option<OAuth1Token>> {
62        let path = self.base_dir.join(OAUTH1_FILENAME);
63        if !path.exists() {
64            return Ok(None);
65        }
66
67        let json = fs::read_to_string(&path)?;
68        let token: OAuth1Token = serde_json::from_str(&json)?;
69        Ok(Some(token))
70    }
71
72    /// Save OAuth2 token to storage
73    pub fn save_oauth2(&self, token: &OAuth2Token) -> Result<()> {
74        let path = self.base_dir.join(OAUTH2_FILENAME);
75        let json = serde_json::to_string_pretty(token)?;
76        fs::write(&path, json)?;
77
78        // Set restrictive permissions on Unix
79        #[cfg(unix)]
80        {
81            use std::os::unix::fs::PermissionsExt;
82            fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?;
83        }
84
85        Ok(())
86    }
87
88    /// Load OAuth2 token from storage
89    pub fn load_oauth2(&self) -> Result<Option<OAuth2Token>> {
90        let path = self.base_dir.join(OAUTH2_FILENAME);
91        if !path.exists() {
92            return Ok(None);
93        }
94
95        let json = fs::read_to_string(&path)?;
96        let token: OAuth2Token = serde_json::from_str(&json)?;
97        Ok(Some(token))
98    }
99
100    /// Save both tokens
101    pub fn save_tokens(&self, oauth1: &OAuth1Token, oauth2: &OAuth2Token) -> Result<()> {
102        self.save_oauth1(oauth1)?;
103        self.save_oauth2(oauth2)?;
104        Ok(())
105    }
106
107    /// Load both tokens, returns None if either is missing
108    pub fn load_tokens(&self) -> Result<Option<(OAuth1Token, OAuth2Token)>> {
109        let oauth1 = self.load_oauth1()?;
110        let oauth2 = self.load_oauth2()?;
111
112        match (oauth1, oauth2) {
113            (Some(o1), Some(o2)) => Ok(Some((o1, o2))),
114            _ => Ok(None),
115        }
116    }
117
118    /// Check if credentials exist
119    pub fn has_credentials(&self) -> bool {
120        self.base_dir.join(OAUTH1_FILENAME).exists()
121            && self.base_dir.join(OAUTH2_FILENAME).exists()
122    }
123
124    /// Clear all stored credentials
125    pub fn clear(&self) -> Result<()> {
126        let oauth1_path = self.base_dir.join(OAUTH1_FILENAME);
127        let oauth2_path = self.base_dir.join(OAUTH2_FILENAME);
128
129        if oauth1_path.exists() {
130            fs::remove_file(oauth1_path)?;
131        }
132        if oauth2_path.exists() {
133            fs::remove_file(oauth2_path)?;
134        }
135
136        Ok(())
137    }
138
139    /// Try to store OAuth1 token secret in system keyring
140    /// Falls back silently if keyring is not available
141    pub fn store_secret_in_keyring(&self, secret: &str) -> Result<()> {
142        let entry = keyring::Entry::new(SERVICE_NAME, &self.profile)
143            .map_err(|e| GarminError::Keyring(e.to_string()))?;
144
145        entry
146            .set_password(secret)
147            .map_err(|e| GarminError::Keyring(e.to_string()))?;
148
149        Ok(())
150    }
151
152    /// Try to load OAuth1 token secret from system keyring
153    pub fn load_secret_from_keyring(&self) -> Result<Option<String>> {
154        let entry = keyring::Entry::new(SERVICE_NAME, &self.profile)
155            .map_err(|e| GarminError::Keyring(e.to_string()))?;
156
157        match entry.get_password() {
158            Ok(secret) => Ok(Some(secret)),
159            Err(keyring::Error::NoEntry) => Ok(None),
160            Err(e) => Err(GarminError::Keyring(e.to_string())),
161        }
162    }
163
164    /// Delete secret from system keyring
165    pub fn delete_secret_from_keyring(&self) -> Result<()> {
166        let entry = keyring::Entry::new(SERVICE_NAME, &self.profile)
167            .map_err(|e| GarminError::Keyring(e.to_string()))?;
168
169        match entry.delete_credential() {
170            Ok(()) => Ok(()),
171            Err(keyring::Error::NoEntry) => Ok(()), // Already deleted
172            Err(e) => Err(GarminError::Keyring(e.to_string())),
173        }
174    }
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180    use chrono::Utc;
181    use tempfile::TempDir;
182
183    fn create_test_oauth1() -> OAuth1Token {
184        OAuth1Token::new("test_token".to_string(), "test_secret".to_string())
185    }
186
187    fn create_test_oauth2() -> OAuth2Token {
188        OAuth2Token {
189            scope: "test_scope".to_string(),
190            jti: "test_jti".to_string(),
191            token_type: "Bearer".to_string(),
192            access_token: "test_access".to_string(),
193            refresh_token: "test_refresh".to_string(),
194            expires_in: 3600,
195            expires_at: Utc::now().timestamp() + 3600,
196            refresh_token_expires_in: 86400,
197            refresh_token_expires_at: Utc::now().timestamp() + 86400,
198        }
199    }
200
201    #[test]
202    fn test_credential_store_creation() {
203        let temp_dir = TempDir::new().unwrap();
204        let store = CredentialStore::with_dir("test_profile", temp_dir.path().to_path_buf());
205        assert!(store.is_ok());
206        assert_eq!(store.unwrap().profile(), "test_profile");
207    }
208
209    #[test]
210    fn test_save_and_load_oauth1() {
211        let temp_dir = TempDir::new().unwrap();
212        let store =
213            CredentialStore::with_dir("test_profile", temp_dir.path().to_path_buf()).unwrap();
214
215        let token = create_test_oauth1();
216        store.save_oauth1(&token).unwrap();
217
218        let loaded = store.load_oauth1().unwrap();
219        assert!(loaded.is_some());
220        assert_eq!(loaded.unwrap(), token);
221    }
222
223    #[test]
224    fn test_save_and_load_oauth2() {
225        let temp_dir = TempDir::new().unwrap();
226        let store =
227            CredentialStore::with_dir("test_profile", temp_dir.path().to_path_buf()).unwrap();
228
229        let token = create_test_oauth2();
230        store.save_oauth2(&token).unwrap();
231
232        let loaded = store.load_oauth2().unwrap();
233        assert!(loaded.is_some());
234        // Note: We can't compare directly because expires_at may differ by a few ms
235        let loaded_token = loaded.unwrap();
236        assert_eq!(loaded_token.access_token, token.access_token);
237        assert_eq!(loaded_token.refresh_token, token.refresh_token);
238    }
239
240    #[test]
241    fn test_load_missing_oauth1() {
242        let temp_dir = TempDir::new().unwrap();
243        let store =
244            CredentialStore::with_dir("test_profile", temp_dir.path().to_path_buf()).unwrap();
245
246        let loaded = store.load_oauth1().unwrap();
247        assert!(loaded.is_none());
248    }
249
250    #[test]
251    fn test_load_missing_oauth2() {
252        let temp_dir = TempDir::new().unwrap();
253        let store =
254            CredentialStore::with_dir("test_profile", temp_dir.path().to_path_buf()).unwrap();
255
256        let loaded = store.load_oauth2().unwrap();
257        assert!(loaded.is_none());
258    }
259
260    #[test]
261    fn test_save_and_load_both_tokens() {
262        let temp_dir = TempDir::new().unwrap();
263        let store =
264            CredentialStore::with_dir("test_profile", temp_dir.path().to_path_buf()).unwrap();
265
266        let oauth1 = create_test_oauth1();
267        let oauth2 = create_test_oauth2();
268        store.save_tokens(&oauth1, &oauth2).unwrap();
269
270        let loaded = store.load_tokens().unwrap();
271        assert!(loaded.is_some());
272        let (loaded_oauth1, loaded_oauth2) = loaded.unwrap();
273        assert_eq!(loaded_oauth1, oauth1);
274        assert_eq!(loaded_oauth2.access_token, oauth2.access_token);
275    }
276
277    #[test]
278    fn test_has_credentials() {
279        let temp_dir = TempDir::new().unwrap();
280        let store =
281            CredentialStore::with_dir("test_profile", temp_dir.path().to_path_buf()).unwrap();
282
283        assert!(!store.has_credentials());
284
285        let oauth1 = create_test_oauth1();
286        let oauth2 = create_test_oauth2();
287        store.save_tokens(&oauth1, &oauth2).unwrap();
288
289        assert!(store.has_credentials());
290    }
291
292    #[test]
293    fn test_clear_credentials() {
294        let temp_dir = TempDir::new().unwrap();
295        let store =
296            CredentialStore::with_dir("test_profile", temp_dir.path().to_path_buf()).unwrap();
297
298        let oauth1 = create_test_oauth1();
299        let oauth2 = create_test_oauth2();
300        store.save_tokens(&oauth1, &oauth2).unwrap();
301        assert!(store.has_credentials());
302
303        store.clear().unwrap();
304        assert!(!store.has_credentials());
305    }
306
307    #[test]
308    fn test_partial_tokens_returns_none() {
309        let temp_dir = TempDir::new().unwrap();
310        let store =
311            CredentialStore::with_dir("test_profile", temp_dir.path().to_path_buf()).unwrap();
312
313        // Only save OAuth1
314        let oauth1 = create_test_oauth1();
315        store.save_oauth1(&oauth1).unwrap();
316
317        // load_tokens should return None because OAuth2 is missing
318        let loaded = store.load_tokens().unwrap();
319        assert!(loaded.is_none());
320    }
321}