Skip to main content

rusty_commit/auth/
token_storage.rs

1use anyhow::{Context, Result};
2use dirs::home_dir;
3use serde::{Deserialize, Serialize};
4use std::fs;
5use std::path::PathBuf;
6
7/// Get current Unix timestamp in seconds.
8///
9/// Returns `None` if system time is before Unix epoch (extremely rare).
10fn current_unix_timestamp() -> Option<u64> {
11    std::time::SystemTime::now()
12        .duration_since(std::time::UNIX_EPOCH)
13        .ok()
14        .map(|d| d.as_secs())
15}
16
17/// OAuth token storage structure
18#[derive(Debug, Serialize, Deserialize, Clone)]
19pub struct TokenStorage {
20    pub access_token: String,
21    pub refresh_token: Option<String>,
22    pub expires_at: Option<u64>,
23    pub token_type: String,
24    pub scope: Option<String>,
25}
26
27impl TokenStorage {
28    /// Get the path to the auth token file
29    fn auth_file_path() -> Result<PathBuf> {
30        let config_dir = if let Ok(config_home) = std::env::var("RCO_CONFIG_HOME") {
31            PathBuf::from(config_home)
32        } else {
33            let home = home_dir().context("Could not find home directory")?;
34            home.join(".config").join("rustycommit")
35        };
36
37        // Ensure directory exists
38        if !config_dir.exists() {
39            fs::create_dir_all(&config_dir).context("Failed to create config directory")?;
40        }
41
42        Ok(config_dir.join("auth.json"))
43    }
44
45    /// Save tokens to file
46    pub fn save(&self) -> Result<()> {
47        let path = Self::auth_file_path()?;
48
49        // Serialize to JSON with pretty printing
50        let json = serde_json::to_string_pretty(self).context("Failed to serialize token data")?;
51
52        // Write to file with restricted permissions
53        fs::write(&path, json).context("Failed to write auth token file")?;
54
55        // Set file permissions to 600 (user read/write only) on Unix
56        #[cfg(unix)]
57        {
58            use std::os::unix::fs::PermissionsExt;
59            let mut perms = fs::metadata(&path)?.permissions();
60            perms.set_mode(0o600);
61            fs::set_permissions(&path, perms).context("Failed to set auth file permissions")?;
62        }
63
64        Ok(())
65    }
66
67    /// Load tokens from file
68    pub fn load() -> Result<Option<Self>> {
69        let path = Self::auth_file_path()?;
70
71        if !path.exists() {
72            return Ok(None);
73        }
74
75        let contents = fs::read_to_string(&path).context("Failed to read auth token file")?;
76
77        let storage: TokenStorage =
78            serde_json::from_str(&contents).context("Failed to parse auth token file")?;
79
80        Ok(Some(storage))
81    }
82
83    /// Delete token file
84    pub fn delete() -> Result<()> {
85        let path = Self::auth_file_path()?;
86
87        if path.exists() {
88            fs::remove_file(&path).context("Failed to delete auth token file")?;
89        }
90
91        Ok(())
92    }
93
94    /// Check if token is expired
95    pub fn is_expired(&self) -> bool {
96        if let Some(expires_at) = self.expires_at {
97            let now = current_unix_timestamp().unwrap_or(u64::MAX);
98            now >= expires_at
99        } else {
100            false
101        }
102    }
103
104    /// Check if token will expire soon (within 5 minutes)
105    #[allow(dead_code)]
106    pub fn expires_soon(&self) -> bool {
107        if let Some(expires_at) = self.expires_at {
108            let now = current_unix_timestamp().unwrap_or(u64::MAX);
109            now >= expires_at.saturating_sub(300) // 5 minutes buffer, saturating to avoid underflow
110        } else {
111            false
112        }
113    }
114}
115
116/// Store OAuth tokens (file-based storage with fallback to secure storage)
117pub fn store_tokens(
118    access_token: &str,
119    refresh_token: Option<&str>,
120    expires_in: Option<u64>,
121) -> Result<()> {
122    // First try secure storage if available
123    #[cfg(feature = "secure-storage")]
124    {
125        if crate::config::secure_storage::is_available() {
126            // Attempt secure storage; on failure fall through to file storage
127            if let Err(e) =
128                crate::config::secure_storage::store_secret("claude_access_token", access_token)
129            {
130                eprintln!(
131                    "Note: Could not store access token in secure storage: {}",
132                    e
133                );
134            } else {
135                if let Some(refresh) = refresh_token {
136                    if let Err(e) =
137                        crate::config::secure_storage::store_secret("claude_refresh_token", refresh)
138                    {
139                        eprintln!(
140                            "Note: Could not store refresh token in secure storage: {}",
141                            e
142                        );
143                    }
144                }
145
146                if let Some(expires_in) = expires_in {
147                    let expires_at = current_unix_timestamp().unwrap_or(u64::MAX) + expires_in;
148                    if let Err(e) = crate::config::secure_storage::store_secret(
149                        "claude_token_expires_at",
150                        &expires_at.to_string(),
151                    ) {
152                        eprintln!(
153                            "Note: Could not store token expiry in secure storage: {}",
154                            e
155                        );
156                    }
157                }
158
159                // If we successfully stored the access token, prefer secure storage and return
160                return Ok(());
161            }
162        }
163    }
164
165    // Fall back to file storage
166    let expires_at = expires_in.map(|exp| current_unix_timestamp().unwrap_or(u64::MAX) + exp);
167
168    let storage = TokenStorage {
169        access_token: access_token.to_string(),
170        refresh_token: refresh_token.map(|s| s.to_string()),
171        expires_at,
172        token_type: "Bearer".to_string(),
173        scope: Some("openid profile email".to_string()),
174    };
175
176    storage.save()?;
177    Ok(())
178}
179
180/// Get stored OAuth tokens (tries secure storage first, then file)
181pub fn get_tokens() -> Result<Option<TokenStorage>> {
182    // First try secure storage if available
183    #[cfg(feature = "secure-storage")]
184    {
185        if crate::config::secure_storage::is_available() {
186            if let Ok(Some(access_token)) =
187                crate::config::secure_storage::get_secret("claude_access_token")
188            {
189                let refresh_token =
190                    crate::config::secure_storage::get_secret("claude_refresh_token")
191                        .ok()
192                        .flatten();
193
194                let expires_at =
195                    crate::config::secure_storage::get_secret("claude_token_expires_at")
196                        .ok()
197                        .flatten()
198                        .and_then(|s| s.parse::<u64>().ok());
199
200                return Ok(Some(TokenStorage {
201                    access_token,
202                    refresh_token,
203                    expires_at,
204                    token_type: "Bearer".to_string(),
205                    scope: Some("openid profile email".to_string()),
206                }));
207            }
208        }
209    }
210
211    // Fall back to file storage
212    TokenStorage::load()
213}
214
215/// Delete stored OAuth tokens (from both secure storage and file)
216pub fn delete_tokens() -> Result<()> {
217    // Delete from secure storage if available
218    #[cfg(feature = "secure-storage")]
219    {
220        let _ = crate::config::secure_storage::delete_secret("claude_access_token");
221        let _ = crate::config::secure_storage::delete_secret("claude_refresh_token");
222        let _ = crate::config::secure_storage::delete_secret("claude_token_expires_at");
223    }
224
225    // Delete file storage
226    TokenStorage::delete()?;
227    Ok(())
228}
229
230/// Get just the access token (for convenience)
231pub fn get_access_token() -> Result<Option<String>> {
232    Ok(get_tokens()?.map(|t| t.access_token))
233}
234
235/// Check if we have valid (non-expired) tokens
236pub fn has_valid_token() -> bool {
237    if let Ok(Some(tokens)) = get_tokens() {
238        !tokens.is_expired()
239    } else {
240        false
241    }
242}
243
244// ============================================
245// Account-scoped token storage (for multi-account support)
246// ============================================
247
248/// Generate storage key for an account
249#[allow(dead_code)]
250fn account_storage_key(account_id: &str, key_type: &str) -> String {
251    format!("rco_account_{}_{}", account_id, key_type)
252}
253
254/// Store OAuth tokens for a specific account
255#[allow(dead_code)]
256pub fn store_tokens_for_account(
257    _account_id: &str,
258    access_token: &str,
259    refresh_token: Option<&str>,
260    expires_in: Option<u64>,
261) -> Result<()> {
262    // Use secure storage if available
263    #[cfg(feature = "secure-storage")]
264    {
265        if crate::config::secure_storage::is_available() {
266            let access_key = account_storage_key(_account_id, "access_token");
267            if let Err(e) = crate::config::secure_storage::store_secret(&access_key, access_token) {
268                eprintln!(
269                    "Note: Could not store access token in secure storage: {}",
270                    e
271                );
272            } else {
273                // Store refresh token
274                if let Some(refresh) = refresh_token {
275                    let refresh_key = account_storage_key(_account_id, "refresh_token");
276                    let _ = crate::config::secure_storage::store_secret(&refresh_key, refresh);
277                }
278
279                // Store expiry
280                if let Some(expires_in) = expires_in {
281                    let expires_at = current_unix_timestamp().unwrap_or(u64::MAX) + expires_in;
282                    let expiry_key = account_storage_key(_account_id, "token_expires_at");
283                    let _ = crate::config::secure_storage::store_secret(
284                        &expiry_key,
285                        &expires_at.to_string(),
286                    );
287                }
288
289                return Ok(());
290            }
291        }
292    }
293
294    // Fall back to file storage (not recommended for multi-account)
295    // For now, just store in the legacy location
296    store_tokens(access_token, refresh_token, expires_in)
297}
298
299/// Get OAuth tokens for a specific account
300#[allow(dead_code)]
301pub fn get_tokens_for_account(_account_id: &str) -> Result<Option<TokenStorage>> {
302    // Try secure storage first
303    #[cfg(feature = "secure-storage")]
304    {
305        if crate::config::secure_storage::is_available() {
306            let access_key = account_storage_key(_account_id, "access_token");
307            if let Ok(Some(access_token)) = crate::config::secure_storage::get_secret(&access_key) {
308                let refresh_key = account_storage_key(_account_id, "refresh_token");
309                let refresh_token = crate::config::secure_storage::get_secret(&refresh_key)
310                    .ok()
311                    .flatten();
312
313                let expiry_key = account_storage_key(_account_id, "token_expires_at");
314                let expires_at = crate::config::secure_storage::get_secret(&expiry_key)
315                    .ok()
316                    .flatten()
317                    .and_then(|s| s.parse::<u64>().ok());
318
319                return Ok(Some(TokenStorage {
320                    access_token,
321                    refresh_token,
322                    expires_at,
323                    token_type: "Bearer".to_string(),
324                    scope: Some("openid profile email".to_string()),
325                }));
326            }
327        }
328    }
329
330    // Fall back to legacy storage for backward compatibility
331    get_tokens()
332}
333
334/// Delete OAuth tokens for a specific account
335#[allow(dead_code)]
336pub fn delete_tokens_for_account(_account_id: &str) -> Result<()> {
337    #[cfg(feature = "secure-storage")]
338    {
339        if crate::config::secure_storage::is_available() {
340            for key_type in ["access_token", "refresh_token", "token_expires_at"] {
341                let key = account_storage_key(_account_id, key_type);
342                let _ = crate::config::secure_storage::delete_secret(&key);
343            }
344        }
345    }
346
347    Ok(())
348}
349
350/// Store API key for a specific account
351#[allow(dead_code)]
352pub fn store_api_key_for_account(_account_id: &str, _api_key: &str) -> Result<()> {
353    #[cfg(feature = "secure-storage")]
354    {
355        if crate::config::secure_storage::is_available() {
356            let key = account_storage_key(_account_id, "api_key");
357            crate::config::secure_storage::store_secret(&key, _api_key)?;
358            return Ok(());
359        }
360    }
361
362    // Fall back: can't store in file securely, warn user
363    anyhow::bail!(
364        "Secure storage not available. Cannot store API key for account '{}'.",
365        _account_id
366    )
367}
368
369/// Get API key for a specific account
370#[allow(dead_code)]
371pub fn get_api_key_for_account(_account_id: &str) -> Result<Option<String>> {
372    #[cfg(feature = "secure-storage")]
373    {
374        if crate::config::secure_storage::is_available() {
375            let key = account_storage_key(_account_id, "api_key");
376            return crate::config::secure_storage::get_secret(&key);
377        }
378    }
379
380    Ok(None)
381}
382
383/// Store bearer token for a specific account
384#[allow(dead_code)]
385pub fn store_bearer_token_for_account(_account_id: &str, _token: &str) -> Result<()> {
386    #[cfg(feature = "secure-storage")]
387    {
388        if crate::config::secure_storage::is_available() {
389            let key = account_storage_key(_account_id, "bearer_token");
390            crate::config::secure_storage::store_secret(&key, _token)?;
391            return Ok(());
392        }
393    }
394
395    anyhow::bail!(
396        "Secure storage not available. Cannot store bearer token for account '{}'.",
397        _account_id
398    )
399}
400
401/// Get bearer token for a specific account
402#[allow(dead_code)]
403pub fn get_bearer_token_for_account(_account_id: &str) -> Result<Option<String>> {
404    #[cfg(feature = "secure-storage")]
405    {
406        if crate::config::secure_storage::is_available() {
407            let key = account_storage_key(_account_id, "bearer_token");
408            return crate::config::secure_storage::get_secret(&key);
409        }
410    }
411
412    Ok(None)
413}
414
415/// Delete all stored credentials for an account
416#[allow(dead_code)]
417pub fn delete_all_for_account(_account_id: &str) -> Result<()> {
418    delete_tokens_for_account(_account_id)?;
419
420    #[cfg(feature = "secure-storage")]
421    {
422        if crate::config::secure_storage::is_available() {
423            for key_type in ["api_key", "bearer_token"] {
424                let key = account_storage_key(_account_id, key_type);
425                let _ = crate::config::secure_storage::delete_secret(&key);
426            }
427        }
428    }
429
430    Ok(())
431}