claude_code_toolkit/config/
credentials.rs1use 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 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; 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; 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}