apollo/providers/
oauth.rs1use 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)]
29pub struct OAuthTokenCache {
30 token: Arc<RwLock<Option<String>>>,
31 refresh_token: Arc<RwLock<Option<String>>>,
32 expires_at: Arc<RwLock<i64>>,
33 credentials_path: Arc<RwLock<Option<PathBuf>>>,
37}
38
39impl OAuthTokenCache {
40 pub fn new(initial_token: String, refresh_token: Option<String>, expires_at: i64) -> Self {
41 Self {
42 token: Arc::new(RwLock::new(Some(initial_token))),
43 refresh_token: Arc::new(RwLock::new(refresh_token)),
44 expires_at: Arc::new(RwLock::new(expires_at)),
45 credentials_path: Arc::new(RwLock::new(None)),
46 }
47 }
48
49 pub fn from_credentials_file() -> anyhow::Result<Self> {
52 let path = claude_credentials_path()?;
53 let (token, refresh_token, expires_at) = load_oauth_token_from_path(&path)?;
54 Ok(Self {
55 token: Arc::new(RwLock::new(Some(token))),
56 refresh_token: Arc::new(RwLock::new(refresh_token)),
57 expires_at: Arc::new(RwLock::new(expires_at)),
58 credentials_path: Arc::new(RwLock::new(Some(path))),
59 })
60 }
61
62 pub async fn get_token(&self) -> anyhow::Result<String> {
64 let token = self.token.read().await;
65 if let Some(t) = token.as_ref() {
66 let expires = *self.expires_at.read().await;
67 if expires > chrono::Utc::now().timestamp_millis() + EXPIRY_SKEW_MS {
68 return Ok(t.clone());
69 }
70 }
71 drop(token);
72
73 self.refresh().await?;
75
76 let token = self.token.read().await;
77 token
78 .clone()
79 .ok_or_else(|| anyhow::anyhow!("Failed to get valid token"))
80 }
81
82 async fn refresh(&self) -> anyhow::Result<()> {
84 let refresh_token = {
85 let rt = self.refresh_token.read().await;
86 rt.clone()
87 .ok_or_else(|| anyhow::anyhow!("No refresh token available"))?
88 };
89
90 let body = Self::fetch_new_token(&refresh_token).await?;
91
92 let expires_in = body.expires_in.unwrap_or(3600) * 1000; let new_expires = chrono::Utc::now().timestamp_millis() + expires_in;
94 let new_refresh = body.refresh_token.unwrap_or(refresh_token);
95
96 *self.token.write().await = Some(body.access_token.clone());
97 *self.refresh_token.write().await = Some(new_refresh.clone());
98 *self.expires_at.write().await = new_expires;
99
100 if let Some(path) = self.credentials_path.read().await.clone() {
101 if let Err(e) =
102 persist_oauth_token(&path, &body.access_token, &new_refresh, new_expires)
103 {
104 tracing::warn!("failed to persist refreshed OAuth token: {}", e);
105 }
106 }
107
108 Ok(())
109 }
110
111 async fn fetch_new_token(refresh_token: &str) -> anyhow::Result<RefreshResponse> {
112 let client = reqwest::Client::builder()
113 .timeout(std::time::Duration::from_secs(30))
114 .build()?;
115
116 let response = client
117 .post(CLAUDE_TOKEN_URL)
118 .json(&serde_json::json!({
119 "grant_type": "refresh_token",
120 "refresh_token": refresh_token,
121 "client_id": CLAUDE_CLIENT_ID,
122 }))
123 .send()
124 .await?;
125
126 if !response.status().is_success() {
127 return Err(anyhow::anyhow!(
128 "OAuth token refresh failed: {}",
129 response.status()
130 ));
131 }
132
133 let body = response.json::<RefreshResponse>().await?;
134 Ok(body)
135 }
136}
137
138fn persist_oauth_token(
142 path: &Path,
143 access_token: &str,
144 refresh_token: &str,
145 expires_at: i64,
146) -> anyhow::Result<()> {
147 let content = std::fs::read_to_string(path)?;
148 let mut creds: Value = serde_json::from_str(&content)?;
149
150 let oauth = creds
151 .get_mut("claudeAiOauth")
152 .ok_or_else(|| anyhow::anyhow!("no claudeAiOauth section in credentials"))?;
153 oauth["accessToken"] = Value::String(access_token.to_string());
154 oauth["refreshToken"] = Value::String(refresh_token.to_string());
155 oauth["expiresAt"] = Value::Number(expires_at.into());
156
157 let tmp = path.with_extension("tmp");
158 std::fs::write(&tmp, serde_json::to_string_pretty(&creds)?)?;
159 restrict_to_owner(&tmp)?;
160 std::fs::rename(&tmp, path)?;
161 Ok(())
162}
163
164#[cfg(unix)]
165fn restrict_to_owner(path: &Path) -> anyhow::Result<()> {
166 use std::os::unix::fs::PermissionsExt;
167 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
168 Ok(())
169}
170
171#[cfg(not(unix))]
172fn restrict_to_owner(_path: &Path) -> anyhow::Result<()> {
173 Ok(())
174}
175
176pub fn claude_credentials_path() -> anyhow::Result<PathBuf> {
178 Ok(dirs::home_dir()
179 .ok_or_else(|| anyhow::anyhow!("Cannot determine home directory"))?
180 .join(".claude")
181 .join(".credentials.json"))
182}
183
184pub fn load_oauth_token_from_file() -> anyhow::Result<(String, Option<String>, i64)> {
186 load_oauth_token_from_path(&claude_credentials_path()?)
187}
188
189fn load_oauth_token_from_path(
190 credentials_path: &Path,
191) -> anyhow::Result<(String, Option<String>, i64)> {
192 let content = std::fs::read_to_string(credentials_path)
193 .map_err(|e| anyhow::anyhow!("Failed to read Claude credentials: {}", e))?;
194
195 let creds: Value = serde_json::from_str(&content)
196 .map_err(|e| anyhow::anyhow!("Failed to parse Claude credentials: {}", e))?;
197
198 let oauth = &creds["claudeAiOauth"];
199 let access_token = oauth["accessToken"]
200 .as_str()
201 .ok_or_else(|| anyhow::anyhow!("No accessToken in credentials"))?
202 .to_string();
203
204 let refresh_token = oauth["refreshToken"].as_str().map(|s| s.to_string());
205 let expires_at = oauth["expiresAt"]
206 .as_i64()
207 .unwrap_or_else(|| chrono::Utc::now().timestamp_millis() + 3600 * 1000);
208
209 Ok((access_token, refresh_token, expires_at))
210}
211
212#[cfg(test)]
213mod tests {
214 use super::*;
215
216 #[test]
217 fn test_oauth_cache() {
218 let cache = OAuthTokenCache::new(
219 "token123".to_string(),
220 None,
221 chrono::Utc::now().timestamp_millis() + 3600 * 1000,
222 );
223
224 assert!(!cache.token.blocking_read().is_none());
226 }
227
228 #[test]
229 fn persist_updates_tokens_and_keeps_other_keys() {
230 let dir = tempfile::tempdir().unwrap();
231 let path = dir.path().join(".credentials.json");
232 std::fs::write(
233 &path,
234 r#"{"claudeAiOauth":{"accessToken":"old","refreshToken":"oldr","expiresAt":1,"scopes":["a"]},"otherTool":{"keep":true}}"#,
235 )
236 .unwrap();
237
238 persist_oauth_token(&path, "new", "newr", 42).unwrap();
239
240 let v: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
241 assert_eq!(v["claudeAiOauth"]["accessToken"], "new");
242 assert_eq!(v["claudeAiOauth"]["refreshToken"], "newr");
243 assert_eq!(v["claudeAiOauth"]["expiresAt"], 42);
244 assert_eq!(v["claudeAiOauth"]["scopes"][0], "a");
245 assert_eq!(v["otherTool"]["keep"], true);
246 }
247
248 #[test]
249 fn persist_leaves_file_intact_without_oauth_section() {
250 let dir = tempfile::tempdir().unwrap();
251 let path = dir.path().join(".credentials.json");
252 std::fs::write(&path, r#"{"otherTool":{"keep":true}}"#).unwrap();
253
254 assert!(persist_oauth_token(&path, "new", "newr", 42).is_err());
255 let v: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
256 assert_eq!(v["otherTool"]["keep"], true);
257 }
258
259 #[test]
260 fn load_reads_tokens_from_path() {
261 let dir = tempfile::tempdir().unwrap();
262 let path = dir.path().join(".credentials.json");
263 std::fs::write(
264 &path,
265 r#"{"claudeAiOauth":{"accessToken":"tok","refreshToken":"ref","expiresAt":99}}"#,
266 )
267 .unwrap();
268
269 let (token, refresh, expires) = load_oauth_token_from_path(&path).unwrap();
270 assert_eq!(token, "tok");
271 assert_eq!(refresh.as_deref(), Some("ref"));
272 assert_eq!(expires, 99);
273 }
274
275 #[test]
276 fn test_load_oauth_fails_gracefully() {
277 let result = load_oauth_token_from_file();
279 let _ = result;
281 }
282}