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 endpoints, tried in order.
14///
15/// `platform.claude.com` is the current host and the one that answers a
16/// `grant_type` probe directly; the `console.anthropic.com` host it replaced
17/// still resolves but now sits behind a bot challenge that can answer a POST
18/// with an HTML interstitial instead of a token. Keeping it as a fallback
19/// costs one extra request only when the new host has already failed, and
20/// keeps a refresh working if the migration is not finished everywhere.
21const CLAUDE_TOKEN_URLS: &[&str] = &[
22    "https://platform.claude.com/v1/oauth/token",
23    "https://console.anthropic.com/v1/oauth/token",
24];
25
26/// Refresh this long before actual expiry so an in-flight request does not
27/// race the deadline.
28const 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/// Where a cache's tokens came from, and so where a refreshed token is
38/// written back. Without this a long-running process refreshes in memory only
39/// and every restart starts from an expired token.
40#[derive(Clone, Debug, PartialEq, Eq)]
41enum TokenStore {
42    /// The store shared with telekinesis, via `rs_ai_oauth::credentials`.
43    #[cfg(feature = "rs-ai")]
44    Shared,
45    /// Claude Code's own `~/.claude/.credentials.json`.
46    ClaudeCode(PathBuf),
47}
48
49/// OAuth token cache (refreshed as needed)
50#[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    /// Point the refresh at another token endpoint. Used by the tests to hit a
75    /// local server instead of Anthropic.
76    #[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    /// Build a cache from an on-disk login: the store shared with telekinesis
99    /// first, then Claude Code's own credentials file. Refreshed tokens are
100    /// written back to whichever one supplied them, so neither tool's login is
101    /// rewritten in the other's format.
102    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    /// Get current token, refresh if expired
114    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        // Token expired, try refresh
125        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    /// Refresh token from Anthropic OAuth endpoint
134    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; // Convert to ms
144        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    /// Refresh against each configured endpoint in turn, returning the first
173    /// that answers with a token. A failure on the last one is the error the
174    /// caller sees, so a genuinely rejected refresh token still reports as one.
175    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
220/// Write refreshed tokens back into the credentials file, preserving every
221/// other key. Written to a sibling temp file and renamed so a crash mid-write
222/// cannot truncate the caller's credentials.
223fn 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
258/// Path to the Claude credentials file.
259pub 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/// Claude tokens from the store shared with telekinesis, if any.
267#[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
272/// Load OAuth token: the shared `rs_ai` store first, then Claude.dev's own
273/// credentials file.
274///
275/// Preferring the shared store means logging in once with either apollo or
276/// telekinesis serves both; falling back keeps every setup that predates the
277/// shared store working untouched.
278pub 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    // A token that has expired and carries nothing to refresh with is dead.
308    // Returning it produces a 401 several layers away from the cause, so say
309    // what is actually wrong here. On macOS this is the common case: Claude
310    // Code keeps its live credentials in the login keychain and leaves this
311    // file behind at whatever it last held. apollo does not read the keychain.
312    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        // Should not panic
340        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    /// Serve one canned token response and record how many requests arrived.
391    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        // The rotated refresh token replaces the old one, so the next refresh
444        // does not replay a token the server has already retired.
445        assert_eq!(
446            cache.refresh_token.read().await.as_deref(),
447            Some("next-refresh")
448        );
449        // Still valid, so a second call must not hit the endpoint again.
450        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        // Bound and dropped, so the port is closed and the request fails.
459        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        // The cache refreshes it; refusing here would break a working setup.
510        assert!(load_oauth_token_from_path(&path).is_ok());
511    }
512
513    #[test]
514    fn test_load_oauth_fails_gracefully() {
515        // This will fail if credentials don't exist, which is expected
516        let result = load_oauth_token_from_file();
517        // Either it succeeds or it fails gracefully
518        let _ = result;
519    }
520}