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/// Where a cache's tokens came from, and so where a refreshed token is
28/// written back. Without this a long-running process refreshes in memory only
29/// and every restart starts from an expired token.
30#[derive(Clone, Debug, PartialEq, Eq)]
31enum TokenStore {
32    /// The store shared with telekinesis, via `rs_ai_oauth::credentials`.
33    #[cfg(feature = "rs-ai")]
34    Shared,
35    /// Claude Code's own `~/.claude/.credentials.json`.
36    ClaudeCode(PathBuf),
37}
38
39/// OAuth token cache (refreshed as needed)
40#[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    /// Build a cache from an on-disk login: the store shared with telekinesis
69    /// first, then Claude Code's own credentials file. Refreshed tokens are
70    /// written back to whichever one supplied them, so neither tool's login is
71    /// rewritten in the other's format.
72    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    /// Get current token, refresh if expired
84    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        // Token expired, try refresh
95        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    /// Refresh token from Anthropic OAuth endpoint
104    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; // Convert to ms
114        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
167/// Write refreshed tokens back into the credentials file, preserving every
168/// other key. Written to a sibling temp file and renamed so a crash mid-write
169/// cannot truncate the caller's credentials.
170fn 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
205/// Path to the Claude credentials file.
206pub 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/// Claude tokens from the store shared with telekinesis, if any.
214#[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
219/// Load OAuth token: the shared `rs_ai` store first, then Claude.dev's own
220/// credentials file.
221///
222/// Preferring the shared store means logging in once with either apollo or
223/// telekinesis serves both; falling back keeps every setup that predates the
224/// shared store working untouched.
225pub 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        // Should not panic
270        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        // This will fail if credentials don't exist, which is expected
323        let result = load_oauth_token_from_file();
324        // Either it succeeds or it fails gracefully
325        let _ = result;
326    }
327}