Skip to main content

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() && self.base_dir.join(OAUTH2_FILENAME).exists()
121    }
122
123    /// Clear all stored credentials
124    pub fn clear(&self) -> Result<()> {
125        let oauth1_path = self.base_dir.join(OAUTH1_FILENAME);
126        let oauth2_path = self.base_dir.join(OAUTH2_FILENAME);
127
128        if oauth1_path.exists() {
129            fs::remove_file(oauth1_path)?;
130        }
131        if oauth2_path.exists() {
132            fs::remove_file(oauth2_path)?;
133        }
134
135        Ok(())
136    }
137
138    /// Try to store OAuth1 token secret in system keyring
139    /// Falls back silently if keyring is not available
140    pub fn store_secret_in_keyring(&self, secret: &str) -> Result<()> {
141        let entry = keyring::Entry::new(SERVICE_NAME, &self.profile)
142            .map_err(|e| GarminError::Keyring(e.to_string()))?;
143
144        entry
145            .set_password(secret)
146            .map_err(|e| GarminError::Keyring(e.to_string()))?;
147
148        Ok(())
149    }
150
151    /// Try to load OAuth1 token secret from system keyring
152    pub fn load_secret_from_keyring(&self) -> Result<Option<String>> {
153        let entry = keyring::Entry::new(SERVICE_NAME, &self.profile)
154            .map_err(|e| GarminError::Keyring(e.to_string()))?;
155
156        match entry.get_password() {
157            Ok(secret) => Ok(Some(secret)),
158            Err(keyring::Error::NoEntry) => Ok(None),
159            Err(e) => Err(GarminError::Keyring(e.to_string())),
160        }
161    }
162
163    /// Delete secret from system keyring
164    pub fn delete_secret_from_keyring(&self) -> Result<()> {
165        let entry = keyring::Entry::new(SERVICE_NAME, &self.profile)
166            .map_err(|e| GarminError::Keyring(e.to_string()))?;
167
168        match entry.delete_credential() {
169            Ok(()) => Ok(()),
170            Err(keyring::Error::NoEntry) => Ok(()), // Already deleted
171            Err(e) => Err(GarminError::Keyring(e.to_string())),
172        }
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179    use chrono::Utc;
180    use tempfile::TempDir;
181
182    fn create_test_oauth1() -> OAuth1Token {
183        OAuth1Token::new("test_token".to_string(), "test_secret".to_string())
184    }
185
186    fn create_test_oauth2() -> OAuth2Token {
187        OAuth2Token {
188            scope: "test_scope".to_string(),
189            jti: "test_jti".to_string(),
190            token_type: "Bearer".to_string(),
191            access_token: "test_access".to_string(),
192            refresh_token: "test_refresh".to_string(),
193            expires_in: 3600,
194            expires_at: Utc::now().timestamp() + 3600,
195            refresh_token_expires_in: 86400,
196            refresh_token_expires_at: Utc::now().timestamp() + 86400,
197        }
198    }
199
200    #[test]
201    fn test_credential_store_creation() {
202        let temp_dir = TempDir::new().unwrap();
203        let store = CredentialStore::with_dir("test_profile", temp_dir.path().to_path_buf());
204        assert!(store.is_ok());
205        assert_eq!(store.unwrap().profile(), "test_profile");
206    }
207
208    #[test]
209    fn test_save_and_load_oauth1() {
210        let temp_dir = TempDir::new().unwrap();
211        let store =
212            CredentialStore::with_dir("test_profile", temp_dir.path().to_path_buf()).unwrap();
213
214        let token = create_test_oauth1();
215        store.save_oauth1(&token).unwrap();
216
217        let loaded = store.load_oauth1().unwrap();
218        assert!(loaded.is_some());
219        assert_eq!(loaded.unwrap(), token);
220    }
221
222    #[test]
223    fn test_save_and_load_oauth2() {
224        let temp_dir = TempDir::new().unwrap();
225        let store =
226            CredentialStore::with_dir("test_profile", temp_dir.path().to_path_buf()).unwrap();
227
228        let token = create_test_oauth2();
229        store.save_oauth2(&token).unwrap();
230
231        let loaded = store.load_oauth2().unwrap();
232        assert!(loaded.is_some());
233        // Note: We can't compare directly because expires_at may differ by a few ms
234        let loaded_token = loaded.unwrap();
235        assert_eq!(loaded_token.access_token, token.access_token);
236        assert_eq!(loaded_token.refresh_token, token.refresh_token);
237    }
238
239    #[test]
240    fn test_load_missing_oauth1() {
241        let temp_dir = TempDir::new().unwrap();
242        let store =
243            CredentialStore::with_dir("test_profile", temp_dir.path().to_path_buf()).unwrap();
244
245        let loaded = store.load_oauth1().unwrap();
246        assert!(loaded.is_none());
247    }
248
249    #[test]
250    fn test_load_missing_oauth2() {
251        let temp_dir = TempDir::new().unwrap();
252        let store =
253            CredentialStore::with_dir("test_profile", temp_dir.path().to_path_buf()).unwrap();
254
255        let loaded = store.load_oauth2().unwrap();
256        assert!(loaded.is_none());
257    }
258
259    #[test]
260    fn test_save_and_load_both_tokens() {
261        let temp_dir = TempDir::new().unwrap();
262        let store =
263            CredentialStore::with_dir("test_profile", temp_dir.path().to_path_buf()).unwrap();
264
265        let oauth1 = create_test_oauth1();
266        let oauth2 = create_test_oauth2();
267        store.save_tokens(&oauth1, &oauth2).unwrap();
268
269        let loaded = store.load_tokens().unwrap();
270        assert!(loaded.is_some());
271        let (loaded_oauth1, loaded_oauth2) = loaded.unwrap();
272        assert_eq!(loaded_oauth1, oauth1);
273        assert_eq!(loaded_oauth2.access_token, oauth2.access_token);
274    }
275
276    #[test]
277    fn test_has_credentials() {
278        let temp_dir = TempDir::new().unwrap();
279        let store =
280            CredentialStore::with_dir("test_profile", temp_dir.path().to_path_buf()).unwrap();
281
282        assert!(!store.has_credentials());
283
284        let oauth1 = create_test_oauth1();
285        let oauth2 = create_test_oauth2();
286        store.save_tokens(&oauth1, &oauth2).unwrap();
287
288        assert!(store.has_credentials());
289    }
290
291    #[test]
292    fn test_clear_credentials() {
293        let temp_dir = TempDir::new().unwrap();
294        let store =
295            CredentialStore::with_dir("test_profile", temp_dir.path().to_path_buf()).unwrap();
296
297        let oauth1 = create_test_oauth1();
298        let oauth2 = create_test_oauth2();
299        store.save_tokens(&oauth1, &oauth2).unwrap();
300        assert!(store.has_credentials());
301
302        store.clear().unwrap();
303        assert!(!store.has_credentials());
304    }
305
306    #[test]
307    fn test_partial_tokens_returns_none() {
308        let temp_dir = TempDir::new().unwrap();
309        let store =
310            CredentialStore::with_dir("test_profile", temp_dir.path().to_path_buf()).unwrap();
311
312        // Only save OAuth1
313        let oauth1 = create_test_oauth1();
314        store.save_oauth1(&oauth1).unwrap();
315
316        // load_tokens should return None because OAuth2 is missing
317        let loaded = store.load_tokens().unwrap();
318        assert!(loaded.is_none());
319    }
320}