use std::path::PathBuf;
use std::sync::{Arc, PoisonError, RwLock};
use std::time::{SystemTime, UNIX_EPOCH};
use base64::Engine as _;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use sha2::Digest as _;
pub const CLIENT_ID: &str = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
pub const AUTHORIZE_URL_CONSOLE: &str = "https://platform.claude.com/oauth/authorize";
pub const AUTHORIZE_URL_MAX: &str = "https://claude.com/cai/oauth/authorize";
pub const CODE_CALLBACK_URL: &str = "https://platform.claude.com/oauth/code/callback";
pub const TOKEN_URL: &str = "https://platform.claude.com/v1/oauth/token";
pub const OAUTH_SCOPES: &str = "org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload";
pub const REFRESH_SCOPE: &str =
"user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload";
pub const OAUTH_BETA: &str = "oauth-2025-04-20";
pub const ANTHROPIC_VERSION: &str = "2023-06-01";
pub const TOKEN_USER_AGENT: &str = "axios/1.15.2";
pub const CLAUDE_CODE_USER_AGENT: &str = "claude-cli/2.1.302 (external, cli)";
pub const REFRESH_SKEW_MS: i64 = 300_000;
pub const TOKEN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
pub const OPT_IN_ENV: &str = "INFINITY_CLAUDE_OAUTH";
pub const STATIC_TOKEN_ENV: &str = "ANTHROPIC_OAUTH_TOKEN";
pub const BASE_URL_ENV: &str = "CLAUDE_OAUTH_BASE_URL";
type BoxError = Box<dyn std::error::Error + Send + Sync>;
#[derive(Debug)]
pub struct OAuthError {
pub message: String,
pub status: Option<u16>,
pub retryable: bool,
}
impl std::fmt::Display for OAuthError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.message)
}
}
impl std::error::Error for OAuthError {}
#[derive(Debug)]
pub struct IssuedTokens {
pub access_token: String,
pub refresh_token: Option<String>,
pub expires_in_secs: i64,
}
#[derive(Debug)]
pub struct OAuthSession {
pub access_token: String,
pub refresh_token: String,
pub expires_at_ms: i64,
pub persist_path: Option<PathBuf>,
}
pub type OAuthSessionHandle = Arc<RwLock<OAuthSession>>;
#[must_use]
pub fn now_ms() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis())
.ok()
.and_then(|millis| i64::try_from(millis).ok())
.unwrap_or(0)
}
#[must_use]
pub const fn needs_refresh(expires_at_ms: i64, now: i64) -> bool {
expires_at_ms.saturating_sub(now) < REFRESH_SKEW_MS
}
fn unlocked<T>(result: Result<T, PoisonError<T>>) -> T {
result.unwrap_or_else(PoisonError::into_inner)
}
#[derive(Debug, Serialize, Deserialize)]
struct StoredCredentials {
access_token: String,
#[serde(default)]
refresh_token: String,
expires_at_ms: i64,
#[serde(default)]
mode: Option<String>,
}
#[derive(Debug, Deserialize)]
struct ClaudeCodeFile {
#[serde(rename = "claudeAiOauth")]
oauth: Option<ClaudeCodeOAuth>,
}
#[derive(Debug, Deserialize)]
struct ClaudeCodeOAuth {
#[serde(rename = "accessToken")]
access_token: Option<String>,
#[serde(rename = "refreshToken")]
refresh_token: Option<String>,
#[serde(rename = "expiresAt")]
expires_at_ms: Option<i64>,
}
#[derive(Debug)]
pub struct LoadedCredentials {
pub access_token: String,
pub refresh_token: String,
pub expires_at_ms: i64,
pub persist_path: Option<PathBuf>,
}
#[must_use]
pub fn own_credentials_path() -> Option<PathBuf> {
dirs::home_dir().map(|home| home.join(".infinity").join("claude-oauth.json"))
}
#[must_use]
pub fn claude_code_credentials_path() -> Option<PathBuf> {
if let Ok(dir) = std::env::var("CLAUDE_CONFIG_DIR") {
if !dir.trim().is_empty() {
return Some(PathBuf::from(dir).join(".credentials.json"));
}
}
dirs::home_dir().map(|home| home.join(".claude").join(".credentials.json"))
}
#[must_use]
pub fn load_credentials() -> Option<LoadedCredentials> {
if let Some(path) = own_credentials_path() {
if let Ok(contents) = std::fs::read_to_string(&path) {
if let Ok(stored) = serde_json::from_str::<StoredCredentials>(&contents) {
if !stored.access_token.trim().is_empty() {
return Some(LoadedCredentials {
access_token: stored.access_token,
refresh_token: stored.refresh_token,
expires_at_ms: stored.expires_at_ms,
persist_path: Some(path),
});
}
}
}
}
if let Some(path) = claude_code_credentials_path() {
if let Ok(contents) = std::fs::read_to_string(&path) {
if let Ok(file) = serde_json::from_str::<ClaudeCodeFile>(&contents) {
if let Some(oauth) = file.oauth {
if let Some(access) = oauth.access_token {
if !access.trim().is_empty() {
return Some(LoadedCredentials {
access_token: access,
refresh_token: oauth.refresh_token.unwrap_or_default(),
expires_at_ms: oauth.expires_at_ms.unwrap_or(0),
persist_path: None,
});
}
}
}
}
}
}
None
}
fn write_credentials_file(path: &PathBuf, creds: &StoredCredentials) -> Result<(), BoxError> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let contents = serde_json::to_string_pretty(creds)?;
std::fs::write(path, contents)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
}
Ok(())
}
pub fn store_login(
mode: AccountMode,
issued: &IssuedTokens,
now: i64,
) -> Result<PathBuf, BoxError> {
let Some(path) = own_credentials_path() else {
return Err("could not determine home directory".into());
};
let mode_name = match mode {
AccountMode::Max => "max".to_owned(),
AccountMode::Console => "console".to_owned(),
};
let creds = StoredCredentials {
access_token: issued.access_token.clone(),
refresh_token: issued.refresh_token.clone().unwrap_or_default(),
expires_at_ms: now.saturating_add(issued.expires_in_secs.saturating_mul(1000)),
mode: Some(mode_name),
};
write_credentials_file(&path, &creds)?;
Ok(path)
}
fn persist(handle: &OAuthSession, now: i64, expires_in_secs: i64) {
let Some(path) = handle.persist_path.clone() else {
return;
};
let creds = StoredCredentials {
access_token: handle.access_token.clone(),
refresh_token: handle.refresh_token.clone(),
expires_at_ms: now.saturating_add(expires_in_secs.saturating_mul(1000)),
mode: None,
};
if let Err(e) = write_credentials_file(&path, &creds) {
tracing::warn!("oauth: failed to persist refreshed tokens: {e}");
}
}
async fn refresh_tokens(client: &Client, refresh_token: &str) -> Result<IssuedTokens, OAuthError> {
#[derive(Serialize)]
struct RefreshRequest<'a> {
grant_type: &'a str,
refresh_token: &'a str,
client_id: &'a str,
scope: &'a str,
}
#[derive(Deserialize)]
struct RefreshResponse {
access_token: String,
#[serde(default)]
refresh_token: Option<String>,
expires_in: i64,
}
let resp = client
.post(TOKEN_URL)
.timeout(TOKEN_TIMEOUT)
.header("Content-Type", "application/json")
.header("Accept", "application/json, text/plain, */*")
.header("User-Agent", TOKEN_USER_AGENT)
.json(&RefreshRequest {
grant_type: "refresh_token",
refresh_token,
client_id: CLIENT_ID,
scope: REFRESH_SCOPE,
})
.send()
.await
.map_err(|e| OAuthError {
message: format!("oauth refresh request failed: {e}"),
status: None,
retryable: true,
})?;
if !resp.status().is_success() {
let status = resp.status().as_u16();
let text = resp.text().await.unwrap_or_default();
return Err(OAuthError {
message: format!("oauth refresh failed: HTTP {status}: {}", truncate(&text)),
status: Some(status),
retryable: status >= 500,
});
}
let body: RefreshResponse = resp.json().await.map_err(|e| OAuthError {
message: format!("oauth refresh returned invalid JSON: {e}"),
status: None,
retryable: true,
})?;
Ok(IssuedTokens {
access_token: body.access_token,
refresh_token: body.refresh_token,
expires_in_secs: body.expires_in,
})
}
fn truncate(text: &str) -> String {
text.trim().chars().take(300).collect()
}
pub async fn ensure_fresh_token(
client: &Client,
handle: &OAuthSessionHandle,
) -> Result<String, OAuthError> {
let refresh_token = {
let session = unlocked(handle.read());
if session.access_token.trim().is_empty() {
return Err(OAuthError {
message: "oauth session has no access token; sign in again".to_owned(),
status: None,
retryable: false,
});
}
if session.refresh_token.trim().is_empty()
|| !needs_refresh(session.expires_at_ms, now_ms())
{
return Ok(session.access_token.clone());
}
session.refresh_token.clone()
};
store_refreshed(client, handle, &refresh_token).await
}
pub async fn force_refresh(
client: &Client,
handle: &OAuthSessionHandle,
) -> Result<String, OAuthError> {
let refresh_token = {
let session = unlocked(handle.read());
if session.refresh_token.trim().is_empty() {
return Err(OAuthError {
message: "oauth token rejected and no refresh token available".to_owned(),
status: None,
retryable: false,
});
}
session.refresh_token.clone()
};
store_refreshed(client, handle, &refresh_token).await
}
async fn store_refreshed(
client: &Client,
handle: &OAuthSessionHandle,
refresh_token: &str,
) -> Result<String, OAuthError> {
let issued = refresh_tokens(client, refresh_token).await?;
let now = now_ms();
let mut session = unlocked(handle.write());
if !needs_refresh(session.expires_at_ms, now) && !session.access_token.trim().is_empty() {
return Ok(session.access_token.clone());
}
session.access_token.clone_from(&issued.access_token);
if let Some(rotated) = issued.refresh_token {
session.refresh_token = rotated;
}
session.expires_at_ms = now.saturating_add(issued.expires_in_secs.saturating_mul(1000));
persist(&session, now, issued.expires_in_secs);
Ok(session.access_token.clone())
}
#[derive(Debug)]
pub struct Pkce {
pub verifier: String,
pub challenge: String,
}
fn generate_pkce() -> Result<Pkce, BoxError> {
let mut random = [0_u8; 64];
getrandom::fill(&mut random).map_err(|e| format!("oauth: randomness unavailable: {e}"))?;
let verifier = URL_SAFE_NO_PAD.encode(random);
let digest = sha2::Sha256::digest(verifier.as_bytes());
Ok(Pkce {
verifier,
challenge: URL_SAFE_NO_PAD.encode(digest),
})
}
fn generate_state() -> Result<String, BoxError> {
let mut random = [0_u8; 16];
getrandom::fill(&mut random).map_err(|e| format!("oauth: randomness unavailable: {e}"))?;
Ok(random
.iter()
.fold(String::with_capacity(32), |mut hex, byte| {
use std::fmt::Write as _;
let _ = write!(hex, "{byte:02x}");
hex
}))
}
#[derive(Debug)]
pub struct Authorization {
pub url: String,
pub state: String,
pub verifier: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AccountMode {
Max,
Console,
}
impl AccountMode {
#[must_use]
pub const fn authorize_url(&self) -> &'static str {
match self {
Self::Max => AUTHORIZE_URL_MAX,
Self::Console => AUTHORIZE_URL_CONSOLE,
}
}
}
impl std::str::FromStr for AccountMode {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.trim().to_ascii_lowercase().as_str() {
"max" => Ok(Self::Max),
"console" => Ok(Self::Console),
other => Err(format!(
"unknown account mode: {other} (expected max|console)"
)),
}
}
}
pub fn authorize(mode: AccountMode) -> Result<Authorization, BoxError> {
let pkce = generate_pkce()?;
let state = generate_state()?;
let scopes = OAUTH_SCOPES.replace(' ', "%20");
let url = format!(
"{}?code=true&client_id={CLIENT_ID}&response_type=code&redirect_uri={CODE_CALLBACK_URL}\
&scope={scopes}&code_challenge={}&code_challenge_method=S256&state={state}",
mode.authorize_url(),
pkce.challenge,
);
Ok(Authorization {
url,
state,
verifier: pkce.verifier,
})
}
#[derive(Debug, PartialEq, Eq)]
pub struct Callback {
pub code: String,
pub state: String,
}
pub fn parse_callback(input: &str) -> Option<Callback> {
let trimmed = input.trim();
if trimmed.is_empty() {
return None;
}
let query = trimmed.split_once('?').map_or(trimmed, |(_, query)| {
query.split('#').next().unwrap_or(query)
});
if let Some(found) = from_pairs(query) {
return Some(found);
}
if let Some((code, state)) = trimmed.split_once('#') {
let code = code.trim().to_owned();
let state = state.trim().to_owned();
if !code.is_empty() && !state.is_empty() && !code.contains(char::is_whitespace) {
return Some(Callback { code, state });
}
}
None
}
fn from_pairs(query: &str) -> Option<Callback> {
let mut code: Option<String> = None;
let mut state: Option<String> = None;
for pair in query.split('&') {
let (key, value) = pair.split_once('=')?;
match key.trim() {
"code" => code = Some(value.trim().to_owned()),
"state" => state = Some(value.trim().to_owned()),
_ => {}
}
}
match (code, state) {
(Some(code), Some(state)) if !code.is_empty() && !state.is_empty() => {
Some(Callback { code, state })
}
_ => None,
}
}
pub async fn exchange_code(
client: &Client,
callback: &Callback,
verifier: &str,
) -> Result<Option<IssuedTokens>, OAuthError> {
#[derive(Serialize)]
struct ExchangeRequest<'a> {
code: &'a str,
state: &'a str,
grant_type: &'a str,
client_id: &'a str,
redirect_uri: &'a str,
code_verifier: &'a str,
}
#[derive(Deserialize)]
struct ExchangeResponse {
access_token: String,
#[serde(default)]
refresh_token: Option<String>,
expires_in: i64,
}
let resp = client
.post(TOKEN_URL)
.timeout(TOKEN_TIMEOUT)
.header("Content-Type", "application/json")
.header("Accept", "application/json, text/plain, */*")
.header("User-Agent", TOKEN_USER_AGENT)
.json(&ExchangeRequest {
code: &callback.code,
state: &callback.state,
grant_type: "authorization_code",
client_id: CLIENT_ID,
redirect_uri: CODE_CALLBACK_URL,
code_verifier: verifier,
})
.send()
.await
.map_err(|e| OAuthError {
message: format!("oauth exchange request failed: {e}"),
status: None,
retryable: true,
})?;
if !resp.status().is_success() {
let status = resp.status().as_u16();
let text = resp.text().await.unwrap_or_default();
if status >= 500 {
return Err(OAuthError {
message: format!("oauth exchange failed: HTTP {status}: {}", truncate(&text)),
status: Some(status),
retryable: true,
});
}
return Ok(None);
}
let body: ExchangeResponse = resp.json().await.map_err(|e| OAuthError {
message: format!("oauth exchange returned invalid JSON: {e}"),
status: None,
retryable: true,
})?;
Ok(Some(IssuedTokens {
access_token: body.access_token,
refresh_token: body.refresh_token,
expires_in_secs: body.expires_in,
}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_full_callback_url() {
let callback = parse_callback(
"https://platform.claude.com/oauth/code/callback?code=abc123&state=xyz789",
);
assert_eq!(
callback,
Some(Callback {
code: "abc123".to_owned(),
state: "xyz789".to_owned(),
})
);
}
#[test]
fn parses_query_pairs_without_url() {
let callback = parse_callback("code=abc123&state=xyz789");
assert_eq!(
callback,
Some(Callback {
code: "abc123".to_owned(),
state: "xyz789".to_owned(),
})
);
}
#[test]
fn parses_legacy_hash_shape() {
let callback = parse_callback("abc123#xyz789");
assert_eq!(
callback,
Some(Callback {
code: "abc123".to_owned(),
state: "xyz789".to_owned(),
})
);
}
#[test]
fn rejects_blank_and_partial_input() {
assert!(parse_callback("").is_none());
assert!(parse_callback(" ").is_none());
assert!(parse_callback("code=only").is_none());
assert!(parse_callback("abc123#").is_none());
assert!(parse_callback("#xyz789").is_none());
}
#[test]
fn refresh_skew_applies() {
let now = 1_000_000;
assert!(needs_refresh(now, now));
assert!(needs_refresh(now.saturating_add(60_000), now));
assert!(!needs_refresh(
now.saturating_add(REFRESH_SKEW_MS).saturating_add(1),
now
));
assert!(needs_refresh(0, now));
}
#[test]
fn authorize_url_carries_oauth_params() {
let auth = authorize(AccountMode::Max).unwrap();
assert!(auth.url.starts_with(AUTHORIZE_URL_MAX));
assert!(auth.url.contains(CLIENT_ID));
assert!(auth.url.contains("code_challenge_method=S256"));
assert!(auth.url.contains("code_challenge="));
assert_ne!(auth.verifier, String::new());
assert_ne!(auth.state, String::new());
}
}