1use std::path::PathBuf;
17use std::sync::{Arc, PoisonError, RwLock};
18use std::time::{SystemTime, UNIX_EPOCH};
19
20use base64::Engine as _;
21use base64::engine::general_purpose::URL_SAFE_NO_PAD;
22use reqwest::Client;
23use serde::{Deserialize, Serialize};
24use sha2::Digest as _;
25
26pub const CLIENT_ID: &str = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
28
29pub const AUTHORIZE_URL_CONSOLE: &str = "https://platform.claude.com/oauth/authorize";
31pub const AUTHORIZE_URL_MAX: &str = "https://claude.com/cai/oauth/authorize";
33
34pub const CODE_CALLBACK_URL: &str = "https://platform.claude.com/oauth/code/callback";
36
37pub const TOKEN_URL: &str = "https://platform.claude.com/v1/oauth/token";
39
40pub const OAUTH_SCOPES: &str = "org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload";
42pub const REFRESH_SCOPE: &str =
44 "user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload";
45
46pub const OAUTH_BETA: &str = "oauth-2025-04-20";
48pub const ANTHROPIC_VERSION: &str = "2023-06-01";
50
51pub const TOKEN_USER_AGENT: &str = "axios/1.15.2";
53pub const CLAUDE_CODE_USER_AGENT: &str = "claude-cli/2.1.302 (external, cli)";
55
56pub const REFRESH_SKEW_MS: i64 = 300_000;
58pub const TOKEN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
60
61pub const OPT_IN_ENV: &str = "INFINITY_CLAUDE_OAUTH";
63pub const STATIC_TOKEN_ENV: &str = "ANTHROPIC_OAUTH_TOKEN";
65pub const BASE_URL_ENV: &str = "CLAUDE_OAUTH_BASE_URL";
67
68type BoxError = Box<dyn std::error::Error + Send + Sync>;
69
70#[derive(Debug)]
72pub struct OAuthError {
73 pub message: String,
75 pub status: Option<u16>,
77 pub retryable: bool,
79}
80
81impl std::fmt::Display for OAuthError {
82 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83 write!(f, "{}", self.message)
84 }
85}
86
87impl std::error::Error for OAuthError {}
88
89#[derive(Debug)]
91pub struct IssuedTokens {
92 pub access_token: String,
94 pub refresh_token: Option<String>,
96 pub expires_in_secs: i64,
98}
99
100#[derive(Debug)]
102pub struct OAuthSession {
103 pub access_token: String,
105 pub refresh_token: String,
108 pub expires_at_ms: i64,
110 pub persist_path: Option<PathBuf>,
113}
114
115pub type OAuthSessionHandle = Arc<RwLock<OAuthSession>>;
117
118#[must_use]
120pub fn now_ms() -> i64 {
121 SystemTime::now()
122 .duration_since(UNIX_EPOCH)
123 .map(|d| d.as_millis())
124 .ok()
125 .and_then(|millis| i64::try_from(millis).ok())
126 .unwrap_or(0)
127}
128
129#[must_use]
131pub const fn needs_refresh(expires_at_ms: i64, now: i64) -> bool {
132 expires_at_ms.saturating_sub(now) < REFRESH_SKEW_MS
133}
134
135fn unlocked<T>(result: Result<T, PoisonError<T>>) -> T {
137 result.unwrap_or_else(PoisonError::into_inner)
138}
139
140#[derive(Debug, Serialize, Deserialize)]
142struct StoredCredentials {
143 access_token: String,
144 #[serde(default)]
145 refresh_token: String,
146 expires_at_ms: i64,
147 #[serde(default)]
148 mode: Option<String>,
149}
150
151#[derive(Debug, Deserialize)]
153struct ClaudeCodeFile {
154 #[serde(rename = "claudeAiOauth")]
155 oauth: Option<ClaudeCodeOAuth>,
156}
157
158#[derive(Debug, Deserialize)]
160struct ClaudeCodeOAuth {
161 #[serde(rename = "accessToken")]
162 access_token: Option<String>,
163 #[serde(rename = "refreshToken")]
164 refresh_token: Option<String>,
165 #[serde(rename = "expiresAt")]
166 expires_at_ms: Option<i64>,
167}
168
169#[derive(Debug)]
171pub struct LoadedCredentials {
172 pub access_token: String,
174 pub refresh_token: String,
176 pub expires_at_ms: i64,
178 pub persist_path: Option<PathBuf>,
180}
181
182#[must_use]
184pub fn own_credentials_path() -> Option<PathBuf> {
185 dirs::home_dir().map(|home| home.join(".infinity").join("claude-oauth.json"))
186}
187
188#[must_use]
190pub fn claude_code_credentials_path() -> Option<PathBuf> {
191 if let Ok(dir) = std::env::var("CLAUDE_CONFIG_DIR") {
192 if !dir.trim().is_empty() {
193 return Some(PathBuf::from(dir).join(".credentials.json"));
194 }
195 }
196 dirs::home_dir().map(|home| home.join(".claude").join(".credentials.json"))
197}
198
199#[must_use]
201pub fn load_credentials() -> Option<LoadedCredentials> {
202 if let Some(path) = own_credentials_path() {
203 if let Ok(contents) = std::fs::read_to_string(&path) {
204 if let Ok(stored) = serde_json::from_str::<StoredCredentials>(&contents) {
205 if !stored.access_token.trim().is_empty() {
206 return Some(LoadedCredentials {
207 access_token: stored.access_token,
208 refresh_token: stored.refresh_token,
209 expires_at_ms: stored.expires_at_ms,
210 persist_path: Some(path),
211 });
212 }
213 }
214 }
215 }
216 if let Some(path) = claude_code_credentials_path() {
217 if let Ok(contents) = std::fs::read_to_string(&path) {
218 if let Ok(file) = serde_json::from_str::<ClaudeCodeFile>(&contents) {
219 if let Some(oauth) = file.oauth {
220 if let Some(access) = oauth.access_token {
221 if !access.trim().is_empty() {
222 return Some(LoadedCredentials {
223 access_token: access,
224 refresh_token: oauth.refresh_token.unwrap_or_default(),
225 expires_at_ms: oauth.expires_at_ms.unwrap_or(0),
226 persist_path: None,
227 });
228 }
229 }
230 }
231 }
232 }
233 }
234 None
235}
236
237fn write_credentials_file(path: &PathBuf, creds: &StoredCredentials) -> Result<(), BoxError> {
239 if let Some(parent) = path.parent() {
240 std::fs::create_dir_all(parent)?;
241 }
242 let contents = serde_json::to_string_pretty(creds)?;
243 std::fs::write(path, contents)?;
244 #[cfg(unix)]
245 {
246 use std::os::unix::fs::PermissionsExt as _;
247 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
248 }
249 Ok(())
250}
251
252pub fn store_login(
259 mode: AccountMode,
260 issued: &IssuedTokens,
261 now: i64,
262) -> Result<PathBuf, BoxError> {
263 let Some(path) = own_credentials_path() else {
264 return Err("could not determine home directory".into());
265 };
266 let mode_name = match mode {
267 AccountMode::Max => "max".to_owned(),
268 AccountMode::Console => "console".to_owned(),
269 };
270 let creds = StoredCredentials {
271 access_token: issued.access_token.clone(),
272 refresh_token: issued.refresh_token.clone().unwrap_or_default(),
273 expires_at_ms: now.saturating_add(issued.expires_in_secs.saturating_mul(1000)),
274 mode: Some(mode_name),
275 };
276 write_credentials_file(&path, &creds)?;
277 Ok(path)
278}
279
280fn persist(handle: &OAuthSession, now: i64, expires_in_secs: i64) {
282 let Some(path) = handle.persist_path.clone() else {
283 return;
284 };
285 let creds = StoredCredentials {
286 access_token: handle.access_token.clone(),
287 refresh_token: handle.refresh_token.clone(),
288 expires_at_ms: now.saturating_add(expires_in_secs.saturating_mul(1000)),
289 mode: None,
290 };
291 if let Err(e) = write_credentials_file(&path, &creds) {
292 tracing::warn!("oauth: failed to persist refreshed tokens: {e}");
293 }
294}
295
296async fn refresh_tokens(client: &Client, refresh_token: &str) -> Result<IssuedTokens, OAuthError> {
298 #[derive(Serialize)]
299 struct RefreshRequest<'a> {
300 grant_type: &'a str,
301 refresh_token: &'a str,
302 client_id: &'a str,
303 scope: &'a str,
304 }
305 #[derive(Deserialize)]
306 struct RefreshResponse {
307 access_token: String,
308 #[serde(default)]
309 refresh_token: Option<String>,
310 expires_in: i64,
311 }
312 let resp = client
313 .post(TOKEN_URL)
314 .timeout(TOKEN_TIMEOUT)
315 .header("Content-Type", "application/json")
316 .header("Accept", "application/json, text/plain, */*")
317 .header("User-Agent", TOKEN_USER_AGENT)
318 .json(&RefreshRequest {
319 grant_type: "refresh_token",
320 refresh_token,
321 client_id: CLIENT_ID,
322 scope: REFRESH_SCOPE,
323 })
324 .send()
325 .await
326 .map_err(|e| OAuthError {
327 message: format!("oauth refresh request failed: {e}"),
328 status: None,
329 retryable: true,
330 })?;
331 if !resp.status().is_success() {
332 let status = resp.status().as_u16();
333 let text = resp.text().await.unwrap_or_default();
334 return Err(OAuthError {
335 message: format!("oauth refresh failed: HTTP {status}: {}", truncate(&text)),
336 status: Some(status),
337 retryable: status >= 500,
338 });
339 }
340 let body: RefreshResponse = resp.json().await.map_err(|e| OAuthError {
341 message: format!("oauth refresh returned invalid JSON: {e}"),
342 status: None,
343 retryable: true,
344 })?;
345 Ok(IssuedTokens {
346 access_token: body.access_token,
347 refresh_token: body.refresh_token,
348 expires_in_secs: body.expires_in,
349 })
350}
351
352fn truncate(text: &str) -> String {
353 text.trim().chars().take(300).collect()
354}
355
356pub async fn ensure_fresh_token(
365 client: &Client,
366 handle: &OAuthSessionHandle,
367) -> Result<String, OAuthError> {
368 let refresh_token = {
369 let session = unlocked(handle.read());
370 if session.access_token.trim().is_empty() {
371 return Err(OAuthError {
372 message: "oauth session has no access token; sign in again".to_owned(),
373 status: None,
374 retryable: false,
375 });
376 }
377 if session.refresh_token.trim().is_empty()
378 || !needs_refresh(session.expires_at_ms, now_ms())
379 {
380 return Ok(session.access_token.clone());
381 }
382 session.refresh_token.clone()
383 };
384 store_refreshed(client, handle, &refresh_token).await
385}
386
387pub async fn force_refresh(
394 client: &Client,
395 handle: &OAuthSessionHandle,
396) -> Result<String, OAuthError> {
397 let refresh_token = {
398 let session = unlocked(handle.read());
399 if session.refresh_token.trim().is_empty() {
400 return Err(OAuthError {
401 message: "oauth token rejected and no refresh token available".to_owned(),
402 status: None,
403 retryable: false,
404 });
405 }
406 session.refresh_token.clone()
407 };
408 store_refreshed(client, handle, &refresh_token).await
409}
410
411async fn store_refreshed(
412 client: &Client,
413 handle: &OAuthSessionHandle,
414 refresh_token: &str,
415) -> Result<String, OAuthError> {
416 let issued = refresh_tokens(client, refresh_token).await?;
417 let now = now_ms();
418 let mut session = unlocked(handle.write());
419 if !needs_refresh(session.expires_at_ms, now) && !session.access_token.trim().is_empty() {
421 return Ok(session.access_token.clone());
422 }
423 session.access_token.clone_from(&issued.access_token);
424 if let Some(rotated) = issued.refresh_token {
425 session.refresh_token = rotated;
426 }
427 session.expires_at_ms = now.saturating_add(issued.expires_in_secs.saturating_mul(1000));
428 persist(&session, now, issued.expires_in_secs);
429 Ok(session.access_token.clone())
430}
431
432#[derive(Debug)]
434pub struct Pkce {
435 pub verifier: String,
437 pub challenge: String,
439}
440
441fn generate_pkce() -> Result<Pkce, BoxError> {
443 let mut random = [0_u8; 64];
444 getrandom::fill(&mut random).map_err(|e| format!("oauth: randomness unavailable: {e}"))?;
445 let verifier = URL_SAFE_NO_PAD.encode(random);
446 let digest = sha2::Sha256::digest(verifier.as_bytes());
447 Ok(Pkce {
448 verifier,
449 challenge: URL_SAFE_NO_PAD.encode(digest),
450 })
451}
452
453fn generate_state() -> Result<String, BoxError> {
455 let mut random = [0_u8; 16];
456 getrandom::fill(&mut random).map_err(|e| format!("oauth: randomness unavailable: {e}"))?;
457 Ok(random
458 .iter()
459 .fold(String::with_capacity(32), |mut hex, byte| {
460 use std::fmt::Write as _;
461 let _ = write!(hex, "{byte:02x}");
462 hex
463 }))
464}
465
466#[derive(Debug)]
468pub struct Authorization {
469 pub url: String,
471 pub state: String,
473 pub verifier: String,
475}
476
477#[derive(Debug, Clone, Copy, PartialEq, Eq)]
479pub enum AccountMode {
480 Max,
482 Console,
484}
485
486impl AccountMode {
487 #[must_use]
488 pub const fn authorize_url(&self) -> &'static str {
489 match self {
490 Self::Max => AUTHORIZE_URL_MAX,
491 Self::Console => AUTHORIZE_URL_CONSOLE,
492 }
493 }
494}
495
496impl std::str::FromStr for AccountMode {
497 type Err = String;
498
499 fn from_str(s: &str) -> Result<Self, Self::Err> {
500 match s.trim().to_ascii_lowercase().as_str() {
501 "max" => Ok(Self::Max),
502 "console" => Ok(Self::Console),
503 other => Err(format!(
504 "unknown account mode: {other} (expected max|console)"
505 )),
506 }
507 }
508}
509
510pub fn authorize(mode: AccountMode) -> Result<Authorization, BoxError> {
516 let pkce = generate_pkce()?;
517 let state = generate_state()?;
518 let scopes = OAUTH_SCOPES.replace(' ', "%20");
519 let url = format!(
520 "{}?code=true&client_id={CLIENT_ID}&response_type=code&redirect_uri={CODE_CALLBACK_URL}\
521 &scope={scopes}&code_challenge={}&code_challenge_method=S256&state={state}",
522 mode.authorize_url(),
523 pkce.challenge,
524 );
525 Ok(Authorization {
526 url,
527 state,
528 verifier: pkce.verifier,
529 })
530}
531
532#[derive(Debug, PartialEq, Eq)]
534pub struct Callback {
535 pub code: String,
537 pub state: String,
539}
540
541pub fn parse_callback(input: &str) -> Option<Callback> {
543 let trimmed = input.trim();
544 if trimmed.is_empty() {
545 return None;
546 }
547 let query = trimmed.split_once('?').map_or(trimmed, |(_, query)| {
549 query.split('#').next().unwrap_or(query)
550 });
551 if let Some(found) = from_pairs(query) {
552 return Some(found);
553 }
554 if let Some((code, state)) = trimmed.split_once('#') {
556 let code = code.trim().to_owned();
557 let state = state.trim().to_owned();
558 if !code.is_empty() && !state.is_empty() && !code.contains(char::is_whitespace) {
559 return Some(Callback { code, state });
560 }
561 }
562 None
563}
564
565fn from_pairs(query: &str) -> Option<Callback> {
566 let mut code: Option<String> = None;
567 let mut state: Option<String> = None;
568 for pair in query.split('&') {
569 let (key, value) = pair.split_once('=')?;
570 match key.trim() {
571 "code" => code = Some(value.trim().to_owned()),
572 "state" => state = Some(value.trim().to_owned()),
573 _ => {}
574 }
575 }
576 match (code, state) {
577 (Some(code), Some(state)) if !code.is_empty() && !state.is_empty() => {
578 Some(Callback { code, state })
579 }
580 _ => None,
581 }
582}
583
584pub async fn exchange_code(
593 client: &Client,
594 callback: &Callback,
595 verifier: &str,
596) -> Result<Option<IssuedTokens>, OAuthError> {
597 #[derive(Serialize)]
598 struct ExchangeRequest<'a> {
599 code: &'a str,
600 state: &'a str,
601 grant_type: &'a str,
602 client_id: &'a str,
603 redirect_uri: &'a str,
604 code_verifier: &'a str,
605 }
606 #[derive(Deserialize)]
607 struct ExchangeResponse {
608 access_token: String,
609 #[serde(default)]
610 refresh_token: Option<String>,
611 expires_in: i64,
612 }
613 let resp = client
614 .post(TOKEN_URL)
615 .timeout(TOKEN_TIMEOUT)
616 .header("Content-Type", "application/json")
617 .header("Accept", "application/json, text/plain, */*")
618 .header("User-Agent", TOKEN_USER_AGENT)
619 .json(&ExchangeRequest {
620 code: &callback.code,
621 state: &callback.state,
622 grant_type: "authorization_code",
623 client_id: CLIENT_ID,
624 redirect_uri: CODE_CALLBACK_URL,
625 code_verifier: verifier,
626 })
627 .send()
628 .await
629 .map_err(|e| OAuthError {
630 message: format!("oauth exchange request failed: {e}"),
631 status: None,
632 retryable: true,
633 })?;
634 if !resp.status().is_success() {
635 let status = resp.status().as_u16();
636 let text = resp.text().await.unwrap_or_default();
637 if status >= 500 {
638 return Err(OAuthError {
639 message: format!("oauth exchange failed: HTTP {status}: {}", truncate(&text)),
640 status: Some(status),
641 retryable: true,
642 });
643 }
644 return Ok(None);
645 }
646 let body: ExchangeResponse = resp.json().await.map_err(|e| OAuthError {
647 message: format!("oauth exchange returned invalid JSON: {e}"),
648 status: None,
649 retryable: true,
650 })?;
651 Ok(Some(IssuedTokens {
652 access_token: body.access_token,
653 refresh_token: body.refresh_token,
654 expires_in_secs: body.expires_in,
655 }))
656}
657
658#[cfg(test)]
659mod tests {
660 use super::*;
661
662 #[test]
663 fn parses_full_callback_url() {
664 let callback = parse_callback(
665 "https://platform.claude.com/oauth/code/callback?code=abc123&state=xyz789",
666 );
667 assert_eq!(
668 callback,
669 Some(Callback {
670 code: "abc123".to_owned(),
671 state: "xyz789".to_owned(),
672 })
673 );
674 }
675
676 #[test]
677 fn parses_query_pairs_without_url() {
678 let callback = parse_callback("code=abc123&state=xyz789");
679 assert_eq!(
680 callback,
681 Some(Callback {
682 code: "abc123".to_owned(),
683 state: "xyz789".to_owned(),
684 })
685 );
686 }
687
688 #[test]
689 fn parses_legacy_hash_shape() {
690 let callback = parse_callback("abc123#xyz789");
691 assert_eq!(
692 callback,
693 Some(Callback {
694 code: "abc123".to_owned(),
695 state: "xyz789".to_owned(),
696 })
697 );
698 }
699
700 #[test]
701 fn rejects_blank_and_partial_input() {
702 assert!(parse_callback("").is_none());
703 assert!(parse_callback(" ").is_none());
704 assert!(parse_callback("code=only").is_none());
705 assert!(parse_callback("abc123#").is_none());
706 assert!(parse_callback("#xyz789").is_none());
707 }
708
709 #[test]
710 fn refresh_skew_applies() {
711 let now = 1_000_000;
712 assert!(needs_refresh(now, now));
713 assert!(needs_refresh(now.saturating_add(60_000), now));
714 assert!(!needs_refresh(
715 now.saturating_add(REFRESH_SKEW_MS).saturating_add(1),
716 now
717 ));
718 assert!(needs_refresh(0, now));
719 }
720
721 #[test]
722 fn authorize_url_carries_oauth_params() {
723 let auth = authorize(AccountMode::Max).unwrap();
724 assert!(auth.url.starts_with(AUTHORIZE_URL_MAX));
725 assert!(auth.url.contains(CLIENT_ID));
726 assert!(auth.url.contains("code_challenge_method=S256"));
727 assert!(auth.url.contains("code_challenge="));
728 assert_ne!(auth.verifier, String::new());
729 assert_ne!(auth.state, String::new());
730 }
731}