claude_code_toolkit/config/
credentials.rs

1use crate::{ error::*, types::* };
2use dirs::home_dir;
3use std::path::PathBuf;
4use tokio::fs;
5use tracing::{ debug, warn };
6
7pub struct CredentialsManager {
8  credentials_path: PathBuf,
9}
10
11impl CredentialsManager {
12  pub fn new() -> Result<Self> {
13    // Default path for backward compatibility
14    let credentials_path = home_dir()
15      .ok_or("Could not determine home directory")?
16      .join(".claude")
17      .join(".credentials.json");
18
19    Ok(Self { credentials_path })
20  }
21
22  pub fn with_path(path: PathBuf) -> Self {
23    Self {
24      credentials_path: path,
25    }
26  }
27
28  pub async fn read_credentials(&self) -> Result<ClaudeCredentials> {
29    if !self.credentials_path.exists() {
30      return Err(ClaudeCodeError::CredentialsNotFound {
31        path: self.credentials_path.display().to_string(),
32      });
33    }
34
35    let content = fs::read_to_string(&self.credentials_path).await?;
36    let credentials: ClaudeCredentials = serde_json
37      ::from_str(&content)
38      .map_err(|e| ClaudeCodeError::InvalidCredentials(e.to_string()))?;
39
40    debug!("Successfully read Claude credentials");
41    Ok(credentials)
42  }
43
44  pub async fn get_access_token(&self) -> Result<String> {
45    let credentials = self.read_credentials().await?;
46    Ok(credentials.claude_ai_oauth.access_token)
47  }
48
49  pub async fn get_session_info(&self) -> Result<SessionInfo> {
50    let credentials = self.read_credentials().await?;
51    let oauth = credentials.claude_ai_oauth;
52    let now = chrono::Utc::now().timestamp() * 1000; // Convert to milliseconds
53    let time_remaining = oauth.expires_at - now;
54
55    let session_info = SessionInfo {
56      expires_at: oauth.expires_at,
57      time_remaining,
58      is_expired: time_remaining <= 0,
59      subscription_type: oauth.subscription_type,
60    };
61
62    if session_info.is_expired {
63      warn!("Claude session has expired");
64    } else {
65      debug!("Claude session expires in {}", Self::format_time_remaining(time_remaining));
66    }
67
68    Ok(session_info)
69  }
70
71  pub async fn get_expiry_time(&self) -> Result<i64> {
72    let credentials = self.read_credentials().await?;
73    Ok(credentials.claude_ai_oauth.expires_at)
74  }
75
76  pub fn format_time_remaining(milliseconds: i64) -> String {
77    if milliseconds <= 0 {
78      return "Expired".to_string();
79    }
80
81    let seconds = milliseconds / 1000;
82    let minutes = seconds / 60;
83    let hours = minutes / 60;
84    let days = hours / 24;
85
86    if days > 0 {
87      format!("{}d {}h {}m", days, hours % 24, minutes % 60)
88    } else if hours > 0 {
89      format!("{}h {}m {}s", hours, minutes % 60, seconds % 60)
90    } else if minutes > 0 {
91      format!("{}m {}s", minutes, seconds % 60)
92    } else {
93      format!("{}s", seconds)
94    }
95  }
96
97  pub fn credentials_path(&self) -> &std::path::Path {
98    &self.credentials_path
99  }
100}
101
102#[cfg(test)]
103mod tests {
104  use super::*;
105  use std::io::Write;
106  use tempfile::NamedTempFile;
107
108  #[tokio::test]
109  async fn test_format_time_remaining() {
110    assert_eq!(CredentialsManager::format_time_remaining(-1), "Expired");
111    assert_eq!(CredentialsManager::format_time_remaining(0), "Expired");
112    assert_eq!(CredentialsManager::format_time_remaining(5000), "5s");
113    assert_eq!(CredentialsManager::format_time_remaining(65000), "1m 5s");
114    assert_eq!(CredentialsManager::format_time_remaining(3665000), "1h 1m 5s");
115    assert_eq!(CredentialsManager::format_time_remaining(90065000), "1d 1h 1m");
116  }
117
118  #[tokio::test]
119  async fn test_read_credentials_file_not_found() {
120    let credentials_manager = CredentialsManager {
121      credentials_path: PathBuf::from("/non/existent/path"),
122    };
123
124    let result = credentials_manager.read_credentials().await;
125    assert!(result.is_err());
126    match result.unwrap_err() {
127      ClaudeCodeError::CredentialsNotFound { .. } => {}
128      _ => panic!("Expected CredentialsNotFound error"),
129    }
130  }
131
132  #[tokio::test]
133  async fn test_read_valid_credentials() {
134    let mut temp_file = NamedTempFile::new().unwrap();
135    let credentials_json =
136      r#"{
137            "claudeAiOauth": {
138                "accessToken": "test-access-token",
139                "refreshToken": "test-refresh-token",
140                "expiresAt": 1750255977327,
141                "scopes": ["user:inference", "user:profile"],
142                "subscriptionType": "max"
143            }
144        }"#;
145
146    temp_file.write_all(credentials_json.as_bytes()).unwrap();
147
148    let credentials_manager = CredentialsManager {
149      credentials_path: temp_file.path().to_path_buf(),
150    };
151
152    let credentials = credentials_manager.read_credentials().await.unwrap();
153    assert_eq!(credentials.claude_ai_oauth.access_token, "test-access-token");
154    assert_eq!(credentials.claude_ai_oauth.refresh_token, "test-refresh-token");
155    assert_eq!(credentials.claude_ai_oauth.expires_at, 1750255977327);
156    assert_eq!(credentials.claude_ai_oauth.subscription_type, "max");
157  }
158
159  #[tokio::test]
160  async fn test_invalid_credentials_format() {
161    let mut temp_file = NamedTempFile::new().unwrap();
162    temp_file.write_all(b"invalid json").unwrap();
163
164    let credentials_manager = CredentialsManager {
165      credentials_path: temp_file.path().to_path_buf(),
166    };
167
168    let result = credentials_manager.read_credentials().await;
169    assert!(result.is_err());
170    match result.unwrap_err() {
171      ClaudeCodeError::InvalidCredentials(_) => {}
172      _ => panic!("Expected InvalidCredentials error"),
173    }
174  }
175
176  #[tokio::test]
177  async fn test_get_session_info() {
178    let mut temp_file = NamedTempFile::new().unwrap();
179    let now = chrono::Utc::now().timestamp() * 1000;
180    let future_time = now + 3600000; // 1 hour from now
181
182    let credentials_json =
183      format!(r#"{{
184            "claudeAiOauth": {{
185                "accessToken": "test-token",
186                "refreshToken": "test-refresh",
187                "expiresAt": {},
188                "scopes": ["user:inference"],
189                "subscriptionType": "max"
190            }}
191        }}"#, future_time);
192
193    temp_file.write_all(credentials_json.as_bytes()).unwrap();
194
195    let credentials_manager = CredentialsManager {
196      credentials_path: temp_file.path().to_path_buf(),
197    };
198
199    let session_info = credentials_manager.get_session_info().await.unwrap();
200    assert_eq!(session_info.expires_at, future_time);
201    assert!(!session_info.is_expired);
202    assert!(session_info.time_remaining > 0);
203    assert_eq!(session_info.subscription_type, "max");
204  }
205}