//! Anthropic OAuth — token refresh and credential management for Claude API access. use crate::slog; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::PathBuf; /// The client ID used by Claude Code for OAuth. const CLAUDE_CODE_CLIENT_ID: &str = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"; const TOKEN_ENDPOINT: &str = "https://platform.claude.com/v1/oauth/token"; /// OAuth credentials as stored in `~/.claude/.credentials.json`. #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct OAuthCredentials { pub access_token: String, pub refresh_token: String, pub expires_at: u64, #[serde(default)] pub scopes: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub subscription_type: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub rate_limit_tier: Option, } /// Top-level structure of `~/.claude/.credentials.json`. #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CredentialsFile { pub claude_ai_oauth: OAuthCredentials, } /// Response from the Anthropic OAuth token refresh endpoint. #[derive(Debug, Deserialize)] struct TokenRefreshResponse { access_token: String, expires_in: u64, #[allow(dead_code)] token_type: Option, } /// Error from the Anthropic OAuth token refresh endpoint. #[derive(Debug, Deserialize)] struct TokenRefreshError { #[allow(dead_code)] error: String, error_description: Option, } /// Returns the path to `~/.claude/.credentials.json`. fn credentials_path() -> Result { let home = std::env::var("HOME").map_err(|_| "HOME not set".to_string())?; Ok(PathBuf::from(home) .join(".claude") .join(".credentials.json")) } /// Read OAuth credentials from disk. pub fn read_credentials() -> Result { let path = credentials_path()?; let data = std::fs::read_to_string(&path).map_err(|e| { format!( "Cannot read {}: {e}. Run `claude login` to authenticate.", path.display() ) })?; serde_json::from_str(&data).map_err(|e| format!("Failed to parse {}: {e}", path.display())) } /// Write updated credentials back to disk with 0600 permissions. pub fn write_credentials(creds: &CredentialsFile) -> Result<(), String> { let path = credentials_path()?; let data = serde_json::to_string_pretty(creds) .map_err(|e| format!("Failed to serialize credentials: {e}"))?; std::fs::write(&path, &data).map_err(|e| format!("Failed to write {}: {e}", path.display()))?; // Restore 0600 permissions #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let perms = std::fs::Permissions::from_mode(0o600); std::fs::set_permissions(&path, perms) .map_err(|e| format!("Failed to set permissions on {}: {e}", path.display()))?; } Ok(()) } // ── Multi-account pool ──────────────────────────────────────────────────────── /// A single account entry in the multi-account login pool. /// /// Stored in the pool file, keyed by email address. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PoolAccount { /// Email address that identifies this account. pub email: String, /// OAuth access token. pub access_token: String, /// OAuth refresh token (empty string when not present). pub refresh_token: String, /// Unix-epoch milliseconds when the access token expires. pub expires_at: u64, /// Scopes granted by this token. #[serde(default)] pub scopes: Vec, /// Claude subscription tier (e.g. "max"), if known. #[serde(default, skip_serializing_if = "Option::is_none")] pub subscription_type: Option, /// Rate-limit tier string, if known. #[serde(default, skip_serializing_if = "Option::is_none")] pub rate_limit_tier: Option, /// Whether the server has observed a rate-limit response for this account. #[serde(default)] pub rate_limited: bool, } /// The multi-account login pool, stored in `~/.claude/oauth_pool.json`. /// /// Accounts are keyed by email address so that repeated logins with the same /// account update the existing entry rather than creating a duplicate. #[derive(Debug, Default, Serialize, Deserialize)] pub struct AccountPool { /// Email → account credentials. pub accounts: HashMap, } /// Returns the path to `~/.claude/oauth_pool.json`. fn pool_path() -> Result { let home = std::env::var("HOME").map_err(|_| "HOME not set".to_string())?; Ok(PathBuf::from(home).join(".claude").join("oauth_pool.json")) } /// Read the account pool from disk. /// /// Returns an empty pool when no file exists yet. pub fn read_pool() -> Result { let path = pool_path()?; if !path.exists() { return Ok(AccountPool::default()); } let data = std::fs::read_to_string(&path) .map_err(|e| format!("Cannot read {}: {e}", path.display()))?; serde_json::from_str(&data).map_err(|e| format!("Failed to parse {}: {e}", path.display())) } /// Write the account pool to disk with 0600 permissions. pub fn write_pool(pool: &AccountPool) -> Result<(), String> { let path = pool_path()?; let data = serde_json::to_string_pretty(pool).map_err(|e| format!("Failed to serialize pool: {e}"))?; std::fs::write(&path, &data).map_err(|e| format!("Failed to write {}: {e}", path.display()))?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let perms = std::fs::Permissions::from_mode(0o600); std::fs::set_permissions(&path, perms) .map_err(|e| format!("Failed to set permissions on {}: {e}", path.display()))?; } Ok(()) } /// Insert or update an account in the pool, then persist to disk. /// /// If an account with the same email already exists its credentials are /// overwritten; all other accounts are left unchanged. pub fn upsert_pool_account(account: PoolAccount) -> Result<(), String> { let mut pool = read_pool()?; pool.accounts.insert(account.email.clone(), account); write_pool(&pool) } /// Refresh the OAuth access token using the stored refresh token. /// /// On success, updates `~/.claude/.credentials.json` with the new access /// token and expiry, then returns `Ok(())`. /// /// On failure (e.g. refresh token expired), returns an error string. pub async fn refresh_access_token() -> Result<(), String> { slog!("[oauth] Attempting to refresh OAuth access token"); let mut creds = read_credentials()?; let refresh_token = creds.claude_ai_oauth.refresh_token.clone(); if refresh_token.is_empty() { return Err("No refresh token found. Run `claude login` to authenticate.".to_string()); } let client = reqwest::Client::new(); let resp = client .post(TOKEN_ENDPOINT) .json(&serde_json::json!({ "grant_type": "refresh_token", "refresh_token": refresh_token, "client_id": CLAUDE_CODE_CLIENT_ID, })) .send() .await .map_err(|e| format!("OAuth refresh request failed: {e}"))?; let status = resp.status(); let body = resp .text() .await .map_err(|e| format!("Failed to read refresh response: {e}"))?; if !status.is_success() { // Try to parse a structured error if let Ok(err) = serde_json::from_str::(&body) { let desc = err .error_description .unwrap_or_else(|| "unknown error".to_string()); slog!("[oauth] Refresh failed: {desc} (full body: {body})"); return Err(format!( "OAuth session expired. Please run `claude login` to re-authenticate. ({desc})" )); } return Err(format!( "OAuth session expired. Please run `claude login` to re-authenticate. (HTTP {status})" )); } let token_resp: TokenRefreshResponse = serde_json::from_str(&body) .map_err(|e| format!("Failed to parse refresh response: {e}"))?; let now_ms = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_millis() as u64) .unwrap_or(0); creds.claude_ai_oauth.access_token = token_resp.access_token; creds.claude_ai_oauth.expires_at = now_ms + (token_resp.expires_in * 1000); write_credentials(&creds)?; slog!( "[oauth] Successfully refreshed access token, expires at {}", creds.claude_ai_oauth.expires_at ); Ok(()) } /// Extract the OAuth login URL from an error message produced by the Claude Code provider. /// /// The provider returns errors like: /// `"OAuth session expired or credentials missing. Please log in: http://localhost:3001/oauth/authorize"` /// /// Returns the URL portion when the error indicates missing or expired credentials, /// `None` otherwise. pub fn extract_login_url_from_error(err: &str) -> Option<&str> { let marker = "Please log in: "; let start = err.find(marker)?; Some(err[start + marker.len()..].trim()) } #[cfg(test)] mod tests { use super::*; #[test] fn extract_login_url_from_oauth_error() { let err = "OAuth session expired or credentials missing. Please log in: http://localhost:3001/oauth/authorize"; let url = extract_login_url_from_error(err); assert_eq!(url, Some("http://localhost:3001/oauth/authorize")); } #[test] fn extract_login_url_returns_none_for_unrelated_error() { let err = "Some other error occurred"; assert!(extract_login_url_from_error(err).is_none()); } #[test] fn extract_login_url_with_different_port() { let err = "OAuth session expired or credentials missing. Please log in: http://localhost:3002/oauth/authorize"; let url = extract_login_url_from_error(err); assert_eq!(url, Some("http://localhost:3002/oauth/authorize")); } #[test] fn parse_credentials_file() { let json = r#"{ "claudeAiOauth": { "accessToken": "sk-ant-oat01-test", "refreshToken": "sk-ant-ort01-test", "expiresAt": 1774466144677, "scopes": ["user:inference"], "subscriptionType": "max", "rateLimitTier": "default_claude_max_20x" } }"#; let creds: CredentialsFile = serde_json::from_str(json).unwrap(); assert_eq!(creds.claude_ai_oauth.access_token, "sk-ant-oat01-test"); assert_eq!(creds.claude_ai_oauth.refresh_token, "sk-ant-ort01-test"); assert_eq!(creds.claude_ai_oauth.expires_at, 1774466144677); assert_eq!( creds.claude_ai_oauth.subscription_type.as_deref(), Some("max") ); } #[test] fn serialize_credentials_roundtrip() { let creds = CredentialsFile { claude_ai_oauth: OAuthCredentials { access_token: "access".to_string(), refresh_token: "refresh".to_string(), expires_at: 12345, scopes: vec!["user:inference".to_string()], subscription_type: Some("max".to_string()), rate_limit_tier: None, }, }; let json = serde_json::to_string(&creds).unwrap(); let parsed: CredentialsFile = serde_json::from_str(&json).unwrap(); assert_eq!(parsed.claude_ai_oauth.access_token, "access"); assert_eq!(parsed.claude_ai_oauth.refresh_token, "refresh"); // rate_limit_tier should be omitted from JSON (skip_serializing_if) assert!(!json.contains("rateLimitTier")); } #[test] fn credentials_path_uses_home() { // Just verify it doesn't panic and returns a path ending in .credentials.json if std::env::var("HOME").is_ok() { let path = credentials_path().unwrap(); assert!(path.ends_with(".claude/.credentials.json")); } } }