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_URLS: &[&str] = &[
22 "https://platform.claude.com/v1/oauth/token",
23 "https://console.anthropic.com/v1/oauth/token",
24];
25
26const EXPIRY_SKEW_MS: i64 = 60_000;
29
30#[derive(Deserialize)]
31struct RefreshResponse {
32 access_token: String,
33 refresh_token: Option<String>,
34 expires_in: Option<i64>,
35}
36
37#[derive(Clone, Debug, PartialEq, Eq)]
41enum TokenStore {
42 #[cfg(feature = "rs-ai")]
44 Shared,
45 ClaudeCode(PathBuf),
47}
48
49#[derive(Clone)]
51pub struct OAuthTokenCache {
52 token: Arc<RwLock<Option<String>>>,
53 refresh_token: Arc<RwLock<Option<String>>>,
54 expires_at: Arc<RwLock<i64>>,
55 store: Arc<RwLock<Option<TokenStore>>>,
56 token_urls: Arc<Vec<String>>,
57}
58
59fn default_token_urls() -> Arc<Vec<String>> {
60 Arc::new(CLAUDE_TOKEN_URLS.iter().map(|u| u.to_string()).collect())
61}
62
63impl OAuthTokenCache {
64 pub fn new(initial_token: String, refresh_token: Option<String>, expires_at: i64) -> Self {
65 Self {
66 token: Arc::new(RwLock::new(Some(initial_token))),
67 refresh_token: Arc::new(RwLock::new(refresh_token)),
68 expires_at: Arc::new(RwLock::new(expires_at)),
69 store: Arc::new(RwLock::new(None)),
70 token_urls: default_token_urls(),
71 }
72 }
73
74 #[cfg(test)]
77 fn with_token_url(self, url: impl Into<String>) -> Self {
78 self.with_token_urls(vec![url.into()])
79 }
80
81 #[cfg(test)]
82 fn with_token_urls(mut self, urls: Vec<String>) -> Self {
83 self.token_urls = Arc::new(urls);
84 self
85 }
86
87 fn from_parts(tokens: (String, Option<String>, i64), store: TokenStore) -> Self {
88 let (token, refresh_token, expires_at) = tokens;
89 Self {
90 token: Arc::new(RwLock::new(Some(token))),
91 refresh_token: Arc::new(RwLock::new(refresh_token)),
92 expires_at: Arc::new(RwLock::new(expires_at)),
93 store: Arc::new(RwLock::new(Some(store))),
94 token_urls: default_token_urls(),
95 }
96 }
97
98 pub fn from_credentials_file() -> anyhow::Result<Self> {
103 #[cfg(feature = "rs-ai")]
104 if let Some(tokens) = shared_claude_tokens() {
105 return Ok(Self::from_parts(tokens, TokenStore::Shared));
106 }
107
108 let path = claude_credentials_path()?;
109 let tokens = load_oauth_token_from_path(&path)?;
110 Ok(Self::from_parts(tokens, TokenStore::ClaudeCode(path)))
111 }
112
113 pub async fn get_token(&self) -> anyhow::Result<String> {
115 let token = self.token.read().await;
116 if let Some(t) = token.as_ref() {
117 let expires = *self.expires_at.read().await;
118 if expires > chrono::Utc::now().timestamp_millis() + EXPIRY_SKEW_MS {
119 return Ok(t.clone());
120 }
121 }
122 drop(token);
123
124 self.refresh().await?;
126
127 let token = self.token.read().await;
128 token
129 .clone()
130 .ok_or_else(|| anyhow::anyhow!("Failed to get valid token"))
131 }
132
133 async fn refresh(&self) -> anyhow::Result<()> {
135 let refresh_token = {
136 let rt = self.refresh_token.read().await;
137 rt.clone()
138 .ok_or_else(|| anyhow::anyhow!("No refresh token available"))?
139 };
140
141 let body = Self::fetch_new_token(&self.token_urls, &refresh_token).await?;
142
143 let expires_in = body.expires_in.unwrap_or(3600) * 1000; let new_expires = chrono::Utc::now().timestamp_millis() + expires_in;
145 let new_refresh = body.refresh_token.unwrap_or(refresh_token);
146
147 *self.token.write().await = Some(body.access_token.clone());
148 *self.refresh_token.write().await = Some(new_refresh.clone());
149 *self.expires_at.write().await = new_expires;
150
151 if let Some(store) = self.store.read().await.clone() {
152 let persisted = match &store {
153 #[cfg(feature = "rs-ai")]
154 TokenStore::Shared => crate::providers::shared_credentials::save(
155 rs_ai_oauth::OAuthProvider::Claude,
156 &body.access_token,
157 &new_refresh,
158 new_expires,
159 ),
160 TokenStore::ClaudeCode(path) => {
161 persist_oauth_token(path, &body.access_token, &new_refresh, new_expires)
162 }
163 };
164 if let Err(e) = persisted {
165 tracing::warn!("failed to persist refreshed OAuth token: {}", e);
166 }
167 }
168
169 Ok(())
170 }
171
172 async fn fetch_new_token(
176 urls: &[String],
177 refresh_token: &str,
178 ) -> anyhow::Result<RefreshResponse> {
179 let mut last: Option<anyhow::Error> = None;
180 for url in urls {
181 match Self::fetch_new_token_from(url, refresh_token).await {
182 Ok(body) => return Ok(body),
183 Err(e) => {
184 tracing::debug!("OAuth token refresh failed against {url}: {e}");
185 last = Some(e);
186 }
187 }
188 }
189 Err(last.unwrap_or_else(|| anyhow::anyhow!("no OAuth token endpoint configured")))
190 }
191
192 async fn fetch_new_token_from(
193 url: &str,
194 refresh_token: &str,
195 ) -> anyhow::Result<RefreshResponse> {
196 let client = crate::http::standard();
197
198 let response = client
199 .post(url)
200 .json(&serde_json::json!({
201 "grant_type": "refresh_token",
202 "refresh_token": refresh_token,
203 "client_id": CLAUDE_CLIENT_ID,
204 }))
205 .send()
206 .await?;
207
208 if !response.status().is_success() {
209 return Err(anyhow::anyhow!(
210 "OAuth token refresh failed: {}",
211 response.status()
212 ));
213 }
214
215 let body = response.json::<RefreshResponse>().await?;
216 Ok(body)
217 }
218}
219
220fn persist_oauth_token(
224 path: &Path,
225 access_token: &str,
226 refresh_token: &str,
227 expires_at: i64,
228) -> anyhow::Result<()> {
229 let content = std::fs::read_to_string(path)?;
230 let mut creds: Value = serde_json::from_str(&content)?;
231
232 let oauth = creds
233 .get_mut("claudeAiOauth")
234 .ok_or_else(|| anyhow::anyhow!("no claudeAiOauth section in credentials"))?;
235 oauth["accessToken"] = Value::String(access_token.to_string());
236 oauth["refreshToken"] = Value::String(refresh_token.to_string());
237 oauth["expiresAt"] = Value::Number(expires_at.into());
238
239 let tmp = path.with_extension("tmp");
240 std::fs::write(&tmp, serde_json::to_string_pretty(&creds)?)?;
241 restrict_to_owner(&tmp)?;
242 std::fs::rename(&tmp, path)?;
243 Ok(())
244}
245
246#[cfg(unix)]
247fn restrict_to_owner(path: &Path) -> anyhow::Result<()> {
248 use std::os::unix::fs::PermissionsExt;
249 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
250 Ok(())
251}
252
253#[cfg(not(unix))]
254fn restrict_to_owner(_path: &Path) -> anyhow::Result<()> {
255 Ok(())
256}
257
258pub fn claude_credentials_path() -> anyhow::Result<PathBuf> {
260 Ok(dirs::home_dir()
261 .ok_or_else(|| anyhow::anyhow!("Cannot determine home directory"))?
262 .join(".claude")
263 .join(".credentials.json"))
264}
265
266#[cfg(feature = "rs-ai")]
268fn shared_claude_tokens() -> Option<(String, Option<String>, i64)> {
269 crate::providers::shared_credentials::load(rs_ai_oauth::OAuthProvider::Claude)
270}
271
272pub fn load_oauth_token_from_file() -> anyhow::Result<(String, Option<String>, i64)> {
279 #[cfg(feature = "rs-ai")]
280 if let Some(tokens) = shared_claude_tokens() {
281 return Ok(tokens);
282 }
283
284 load_oauth_token_from_path(&claude_credentials_path()?)
285}
286
287fn load_oauth_token_from_path(
288 credentials_path: &Path,
289) -> anyhow::Result<(String, Option<String>, i64)> {
290 let content = std::fs::read_to_string(credentials_path)
291 .map_err(|e| anyhow::anyhow!("Failed to read Claude credentials: {}", e))?;
292
293 let creds: Value = serde_json::from_str(&content)
294 .map_err(|e| anyhow::anyhow!("Failed to parse Claude credentials: {}", e))?;
295
296 let oauth = &creds["claudeAiOauth"];
297 let access_token = oauth["accessToken"]
298 .as_str()
299 .ok_or_else(|| anyhow::anyhow!("No accessToken in credentials"))?
300 .to_string();
301
302 let refresh_token = oauth["refreshToken"].as_str().map(|s| s.to_string());
303 let expires_at = oauth["expiresAt"]
304 .as_i64()
305 .unwrap_or_else(|| chrono::Utc::now().timestamp_millis() + 3600 * 1000);
306
307 if refresh_token.is_none() && expires_at <= chrono::Utc::now().timestamp_millis() {
313 anyhow::bail!(
314 "The Claude OAuth credential in {} has expired and has no refresh token, \
315 so apollo cannot renew it. On macOS, Claude Code keeps its live \
316 credentials in the login keychain rather than this file, and apollo \
317 does not read the keychain. Log in again with `apollo login`, or set an \
318 Anthropic API key with `apollo config set provider.api_key sk-ant-...` \
319 or the ANTHROPIC_API_KEY environment variable.",
320 credentials_path.display()
321 );
322 }
323
324 Ok((access_token, refresh_token, expires_at))
325}
326
327#[cfg(test)]
328mod tests {
329 use super::*;
330
331 #[test]
332 fn test_oauth_cache() {
333 let cache = OAuthTokenCache::new(
334 "token123".to_string(),
335 None,
336 chrono::Utc::now().timestamp_millis() + 3600 * 1000,
337 );
338
339 assert!(!cache.token.blocking_read().is_none());
341 }
342
343 #[test]
344 fn persist_updates_tokens_and_keeps_other_keys() {
345 let dir = tempfile::tempdir().unwrap();
346 let path = dir.path().join(".credentials.json");
347 std::fs::write(
348 &path,
349 r#"{"claudeAiOauth":{"accessToken":"old","refreshToken":"oldr","expiresAt":1,"scopes":["a"]},"otherTool":{"keep":true}}"#,
350 )
351 .unwrap();
352
353 persist_oauth_token(&path, "new", "newr", 42).unwrap();
354
355 let v: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
356 assert_eq!(v["claudeAiOauth"]["accessToken"], "new");
357 assert_eq!(v["claudeAiOauth"]["refreshToken"], "newr");
358 assert_eq!(v["claudeAiOauth"]["expiresAt"], 42);
359 assert_eq!(v["claudeAiOauth"]["scopes"][0], "a");
360 assert_eq!(v["otherTool"]["keep"], true);
361 }
362
363 #[test]
364 fn persist_leaves_file_intact_without_oauth_section() {
365 let dir = tempfile::tempdir().unwrap();
366 let path = dir.path().join(".credentials.json");
367 std::fs::write(&path, r#"{"otherTool":{"keep":true}}"#).unwrap();
368
369 assert!(persist_oauth_token(&path, "new", "newr", 42).is_err());
370 let v: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
371 assert_eq!(v["otherTool"]["keep"], true);
372 }
373
374 #[test]
375 fn load_reads_tokens_from_path() {
376 let dir = tempfile::tempdir().unwrap();
377 let path = dir.path().join(".credentials.json");
378 std::fs::write(
379 &path,
380 r#"{"claudeAiOauth":{"accessToken":"tok","refreshToken":"ref","expiresAt":99}}"#,
381 )
382 .unwrap();
383
384 let (token, refresh, expires) = load_oauth_token_from_path(&path).unwrap();
385 assert_eq!(token, "tok");
386 assert_eq!(refresh.as_deref(), Some("ref"));
387 assert_eq!(expires, 99);
388 }
389
390 async fn token_server(payload: Value) -> (String, Arc<std::sync::atomic::AtomicUsize>) {
392 use std::sync::atomic::{AtomicUsize, Ordering};
393 use tokio::io::{AsyncReadExt, AsyncWriteExt};
394
395 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
396 let addr = listener.local_addr().unwrap();
397 let seen = Arc::new(AtomicUsize::new(0));
398 let counter = Arc::clone(&seen);
399
400 tokio::spawn(async move {
401 loop {
402 let Ok((mut socket, _)) = listener.accept().await else {
403 return;
404 };
405 counter.fetch_add(1, Ordering::SeqCst);
406 let mut buf = vec![0u8; 8192];
407 let _ = socket.read(&mut buf).await;
408 let body = payload.to_string();
409 let response = format!(
410 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\n\
411 content-length: {}\r\nconnection: close\r\n\r\n{}",
412 body.len(),
413 body
414 );
415 let _ = socket.write_all(response.as_bytes()).await;
416 let _ = socket.flush().await;
417 }
418 });
419
420 (format!("http://{addr}/v1/oauth/token"), seen)
421 }
422
423 #[tokio::test]
424 async fn an_expired_token_is_refreshed_rather_than_used() {
425 use std::sync::atomic::Ordering;
426
427 let (url, seen) = token_server(serde_json::json!({
428 "access_token": "fresh",
429 "refresh_token": "next-refresh",
430 "expires_in": 3600,
431 }))
432 .await;
433
434 let cache = OAuthTokenCache::new(
435 "stale".to_string(),
436 Some("refresh".to_string()),
437 chrono::Utc::now().timestamp_millis() - 1,
438 )
439 .with_token_url(url);
440
441 assert_eq!(cache.get_token().await.unwrap(), "fresh");
442 assert_eq!(seen.load(Ordering::SeqCst), 1);
443 assert_eq!(
446 cache.refresh_token.read().await.as_deref(),
447 Some("next-refresh")
448 );
449 assert_eq!(cache.get_token().await.unwrap(), "fresh");
451 assert_eq!(seen.load(Ordering::SeqCst), 1);
452 }
453
454 #[tokio::test]
455 async fn a_dead_first_endpoint_falls_through_to_the_next() {
456 use std::sync::atomic::Ordering;
457
458 let dead = {
460 let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
461 format!("http://{}/v1/oauth/token", l.local_addr().unwrap())
462 };
463 let (live, seen) = token_server(serde_json::json!({
464 "access_token": "fresh",
465 "expires_in": 3600,
466 }))
467 .await;
468
469 let cache = OAuthTokenCache::new("stale".to_string(), Some("refresh".to_string()), 1)
470 .with_token_urls(vec![dead, live]);
471
472 assert_eq!(cache.get_token().await.unwrap(), "fresh");
473 assert_eq!(seen.load(Ordering::SeqCst), 1);
474 }
475
476 #[tokio::test]
477 async fn an_expired_token_without_a_refresh_token_reports_why() {
478 let cache = OAuthTokenCache::new("stale".to_string(), None, 1);
479 let err = cache.get_token().await.unwrap_err().to_string();
480 assert!(err.contains("No refresh token available"), "got {err}");
481 }
482
483 #[test]
484 fn a_dead_file_credential_says_why_it_is_dead() {
485 let dir = tempfile::tempdir().unwrap();
486 let path = dir.path().join(".credentials.json");
487 std::fs::write(
488 &path,
489 r#"{"claudeAiOauth":{"accessToken":"tok","expiresAt":1}}"#,
490 )
491 .unwrap();
492
493 let err = load_oauth_token_from_path(&path).unwrap_err().to_string();
494 assert!(err.contains("expired"), "got {err}");
495 assert!(err.contains("keychain"), "got {err}");
496 assert!(err.contains("ANTHROPIC_API_KEY"), "got {err}");
497 }
498
499 #[test]
500 fn an_expired_file_credential_with_a_refresh_token_still_loads() {
501 let dir = tempfile::tempdir().unwrap();
502 let path = dir.path().join(".credentials.json");
503 std::fs::write(
504 &path,
505 r#"{"claudeAiOauth":{"accessToken":"tok","refreshToken":"r","expiresAt":1}}"#,
506 )
507 .unwrap();
508
509 assert!(load_oauth_token_from_path(&path).is_ok());
511 }
512
513 #[test]
514 fn test_load_oauth_fails_gracefully() {
515 let result = load_oauth_token_from_file();
517 let _ = result;
519 }
520}