Skip to main content

apollo/providers/
oauth.rs

1//! OAuth token support for Anthropic
2//! Converts Claude.dev OAuth tokens (oat01) to API calls via token exchange
3
4use serde::Deserialize;
5use serde_json::Value;
6use std::path::{Path, PathBuf};
7use std::sync::Arc;
8use tokio::sync::RwLock;
9
10/// Public OAuth client id for the Claude CLI token endpoint.
11const CLAUDE_CLIENT_ID: &str = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
12
13/// Anthropic OAuth token endpoint (console host, not the API host).
14const CLAUDE_TOKEN_URL: &str = "https://console.anthropic.com/v1/oauth/token";
15
16/// Refresh this long before actual expiry so an in-flight request does not
17/// race the deadline.
18const 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/// OAuth token cache (refreshed as needed)
28#[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 file to write refreshed tokens back to, when the cache was
34    /// loaded from one. Without this a long-running process refreshes in
35    /// memory only and every restart starts from an expired token.
36    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    /// Build a cache from the Claude credentials file. Refreshed tokens are
50    /// written back to that same file.
51    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    /// Get current token, refresh if expired
63    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        // Token expired, try refresh
74        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    /// Refresh token from Anthropic OAuth endpoint
83    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; // Convert to ms
93        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
138/// Write refreshed tokens back into the credentials file, preserving every
139/// other key. Written to a sibling temp file and renamed so a crash mid-write
140/// cannot truncate the caller's credentials.
141fn 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
176/// Path to the Claude credentials file.
177pub 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
184/// Load OAuth token from Claude.dev credentials file
185pub 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        // Should not panic
225        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        // This will fail if credentials don't exist, which is expected
278        let result = load_oauth_token_from_file();
279        // Either it succeeds or it fails gracefully
280        let _ = result;
281    }
282}