1use serde::Deserialize;
5use serde_json::Value;
6use std::path::{Path, PathBuf};
7use std::sync::Arc;
8use tokio::sync::RwLock;
9
10const CLAUDE_CLIENT_ID: &str = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
12
13const CLAUDE_TOKEN_URL: &str = "https://console.anthropic.com/v1/oauth/token";
15
16const EXPIRY_SKEW_MS: i64 = 60_000;
19
20#[derive(Deserialize)]
21struct RefreshResponse {
22 access_token: String,
23 refresh_token: Option<String>,
24 expires_in: Option<i64>,
25}
26
27#[derive(Clone, Debug, PartialEq, Eq)]
31enum TokenStore {
32 #[cfg(feature = "rs-ai")]
34 Shared,
35 ClaudeCode(PathBuf),
37}
38
39#[derive(Clone)]
41pub struct OAuthTokenCache {
42 token: Arc<RwLock<Option<String>>>,
43 refresh_token: Arc<RwLock<Option<String>>>,
44 expires_at: Arc<RwLock<i64>>,
45 store: Arc<RwLock<Option<TokenStore>>>,
46}
47
48impl OAuthTokenCache {
49 pub fn new(initial_token: String, refresh_token: Option<String>, expires_at: i64) -> Self {
50 Self {
51 token: Arc::new(RwLock::new(Some(initial_token))),
52 refresh_token: Arc::new(RwLock::new(refresh_token)),
53 expires_at: Arc::new(RwLock::new(expires_at)),
54 store: Arc::new(RwLock::new(None)),
55 }
56 }
57
58 fn from_parts(tokens: (String, Option<String>, i64), store: TokenStore) -> Self {
59 let (token, refresh_token, expires_at) = tokens;
60 Self {
61 token: Arc::new(RwLock::new(Some(token))),
62 refresh_token: Arc::new(RwLock::new(refresh_token)),
63 expires_at: Arc::new(RwLock::new(expires_at)),
64 store: Arc::new(RwLock::new(Some(store))),
65 }
66 }
67
68 pub fn from_credentials_file() -> anyhow::Result<Self> {
73 #[cfg(feature = "rs-ai")]
74 if let Some(tokens) = shared_claude_tokens() {
75 return Ok(Self::from_parts(tokens, TokenStore::Shared));
76 }
77
78 let path = claude_credentials_path()?;
79 let tokens = load_oauth_token_from_path(&path)?;
80 Ok(Self::from_parts(tokens, TokenStore::ClaudeCode(path)))
81 }
82
83 pub async fn get_token(&self) -> anyhow::Result<String> {
85 let token = self.token.read().await;
86 if let Some(t) = token.as_ref() {
87 let expires = *self.expires_at.read().await;
88 if expires > chrono::Utc::now().timestamp_millis() + EXPIRY_SKEW_MS {
89 return Ok(t.clone());
90 }
91 }
92 drop(token);
93
94 self.refresh().await?;
96
97 let token = self.token.read().await;
98 token
99 .clone()
100 .ok_or_else(|| anyhow::anyhow!("Failed to get valid token"))
101 }
102
103 async fn refresh(&self) -> anyhow::Result<()> {
105 let refresh_token = {
106 let rt = self.refresh_token.read().await;
107 rt.clone()
108 .ok_or_else(|| anyhow::anyhow!("No refresh token available"))?
109 };
110
111 let body = Self::fetch_new_token(&refresh_token).await?;
112
113 let expires_in = body.expires_in.unwrap_or(3600) * 1000; let new_expires = chrono::Utc::now().timestamp_millis() + expires_in;
115 let new_refresh = body.refresh_token.unwrap_or(refresh_token);
116
117 *self.token.write().await = Some(body.access_token.clone());
118 *self.refresh_token.write().await = Some(new_refresh.clone());
119 *self.expires_at.write().await = new_expires;
120
121 if let Some(store) = self.store.read().await.clone() {
122 let persisted = match &store {
123 #[cfg(feature = "rs-ai")]
124 TokenStore::Shared => crate::providers::shared_credentials::save(
125 rs_ai_oauth::OAuthProvider::Claude,
126 &body.access_token,
127 &new_refresh,
128 new_expires,
129 ),
130 TokenStore::ClaudeCode(path) => {
131 persist_oauth_token(path, &body.access_token, &new_refresh, new_expires)
132 }
133 };
134 if let Err(e) = persisted {
135 tracing::warn!("failed to persist refreshed OAuth token: {}", e);
136 }
137 }
138
139 Ok(())
140 }
141
142 async fn fetch_new_token(refresh_token: &str) -> anyhow::Result<RefreshResponse> {
143 let client = crate::http::standard();
144
145 let response = client
146 .post(CLAUDE_TOKEN_URL)
147 .json(&serde_json::json!({
148 "grant_type": "refresh_token",
149 "refresh_token": refresh_token,
150 "client_id": CLAUDE_CLIENT_ID,
151 }))
152 .send()
153 .await?;
154
155 if !response.status().is_success() {
156 return Err(anyhow::anyhow!(
157 "OAuth token refresh failed: {}",
158 response.status()
159 ));
160 }
161
162 let body = response.json::<RefreshResponse>().await?;
163 Ok(body)
164 }
165}
166
167fn persist_oauth_token(
171 path: &Path,
172 access_token: &str,
173 refresh_token: &str,
174 expires_at: i64,
175) -> anyhow::Result<()> {
176 let content = std::fs::read_to_string(path)?;
177 let mut creds: Value = serde_json::from_str(&content)?;
178
179 let oauth = creds
180 .get_mut("claudeAiOauth")
181 .ok_or_else(|| anyhow::anyhow!("no claudeAiOauth section in credentials"))?;
182 oauth["accessToken"] = Value::String(access_token.to_string());
183 oauth["refreshToken"] = Value::String(refresh_token.to_string());
184 oauth["expiresAt"] = Value::Number(expires_at.into());
185
186 let tmp = path.with_extension("tmp");
187 std::fs::write(&tmp, serde_json::to_string_pretty(&creds)?)?;
188 restrict_to_owner(&tmp)?;
189 std::fs::rename(&tmp, path)?;
190 Ok(())
191}
192
193#[cfg(unix)]
194fn restrict_to_owner(path: &Path) -> anyhow::Result<()> {
195 use std::os::unix::fs::PermissionsExt;
196 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
197 Ok(())
198}
199
200#[cfg(not(unix))]
201fn restrict_to_owner(_path: &Path) -> anyhow::Result<()> {
202 Ok(())
203}
204
205pub fn claude_credentials_path() -> anyhow::Result<PathBuf> {
207 Ok(dirs::home_dir()
208 .ok_or_else(|| anyhow::anyhow!("Cannot determine home directory"))?
209 .join(".claude")
210 .join(".credentials.json"))
211}
212
213#[cfg(feature = "rs-ai")]
215fn shared_claude_tokens() -> Option<(String, Option<String>, i64)> {
216 crate::providers::shared_credentials::load(rs_ai_oauth::OAuthProvider::Claude)
217}
218
219pub fn load_oauth_token_from_file() -> anyhow::Result<(String, Option<String>, i64)> {
226 #[cfg(feature = "rs-ai")]
227 if let Some(tokens) = shared_claude_tokens() {
228 return Ok(tokens);
229 }
230
231 load_oauth_token_from_path(&claude_credentials_path()?)
232}
233
234fn load_oauth_token_from_path(
235 credentials_path: &Path,
236) -> anyhow::Result<(String, Option<String>, i64)> {
237 let content = std::fs::read_to_string(credentials_path)
238 .map_err(|e| anyhow::anyhow!("Failed to read Claude credentials: {}", e))?;
239
240 let creds: Value = serde_json::from_str(&content)
241 .map_err(|e| anyhow::anyhow!("Failed to parse Claude credentials: {}", e))?;
242
243 let oauth = &creds["claudeAiOauth"];
244 let access_token = oauth["accessToken"]
245 .as_str()
246 .ok_or_else(|| anyhow::anyhow!("No accessToken in credentials"))?
247 .to_string();
248
249 let refresh_token = oauth["refreshToken"].as_str().map(|s| s.to_string());
250 let expires_at = oauth["expiresAt"]
251 .as_i64()
252 .unwrap_or_else(|| chrono::Utc::now().timestamp_millis() + 3600 * 1000);
253
254 Ok((access_token, refresh_token, expires_at))
255}
256
257#[cfg(test)]
258mod tests {
259 use super::*;
260
261 #[test]
262 fn test_oauth_cache() {
263 let cache = OAuthTokenCache::new(
264 "token123".to_string(),
265 None,
266 chrono::Utc::now().timestamp_millis() + 3600 * 1000,
267 );
268
269 assert!(!cache.token.blocking_read().is_none());
271 }
272
273 #[test]
274 fn persist_updates_tokens_and_keeps_other_keys() {
275 let dir = tempfile::tempdir().unwrap();
276 let path = dir.path().join(".credentials.json");
277 std::fs::write(
278 &path,
279 r#"{"claudeAiOauth":{"accessToken":"old","refreshToken":"oldr","expiresAt":1,"scopes":["a"]},"otherTool":{"keep":true}}"#,
280 )
281 .unwrap();
282
283 persist_oauth_token(&path, "new", "newr", 42).unwrap();
284
285 let v: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
286 assert_eq!(v["claudeAiOauth"]["accessToken"], "new");
287 assert_eq!(v["claudeAiOauth"]["refreshToken"], "newr");
288 assert_eq!(v["claudeAiOauth"]["expiresAt"], 42);
289 assert_eq!(v["claudeAiOauth"]["scopes"][0], "a");
290 assert_eq!(v["otherTool"]["keep"], true);
291 }
292
293 #[test]
294 fn persist_leaves_file_intact_without_oauth_section() {
295 let dir = tempfile::tempdir().unwrap();
296 let path = dir.path().join(".credentials.json");
297 std::fs::write(&path, r#"{"otherTool":{"keep":true}}"#).unwrap();
298
299 assert!(persist_oauth_token(&path, "new", "newr", 42).is_err());
300 let v: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
301 assert_eq!(v["otherTool"]["keep"], true);
302 }
303
304 #[test]
305 fn load_reads_tokens_from_path() {
306 let dir = tempfile::tempdir().unwrap();
307 let path = dir.path().join(".credentials.json");
308 std::fs::write(
309 &path,
310 r#"{"claudeAiOauth":{"accessToken":"tok","refreshToken":"ref","expiresAt":99}}"#,
311 )
312 .unwrap();
313
314 let (token, refresh, expires) = load_oauth_token_from_path(&path).unwrap();
315 assert_eq!(token, "tok");
316 assert_eq!(refresh.as_deref(), Some("ref"));
317 assert_eq!(expires, 99);
318 }
319
320 #[test]
321 fn test_load_oauth_fails_gracefully() {
322 let result = load_oauth_token_from_file();
324 let _ = result;
326 }
327}