use crate::{
config::{Auth, AuthProviderRecord, ProviderCredential},
output::redact_sensitive_text,
persistence::{CrossProcessFileLock, atomic_write_with_permissions},
};
use chrono::Utc;
#[cfg(test)]
use reqwest::StatusCode;
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[cfg(test)]
use std::io::Read;
#[cfg(target_os = "macos")]
use std::process::{self as proc_mod, Output, Stdio};
use std::{
fmt, fs,
path::{Path, PathBuf},
sync::Arc,
time::Duration,
};
#[cfg(target_os = "macos")]
use std::{thread, time::Instant};
const CLIENT_ID: &str = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
const PRIMARY_TOKEN_URL: &str = "https://platform.claude.com/v1/oauth/token";
const FALLBACK_TOKEN_URL: &str = "https://console.anthropic.com/v1/oauth/token";
const OAUTH_REFRESH_SKEW_SECS: i64 = 300;
const OAUTH_REFRESH_RESPONSE_BODY_MAX_BYTES: u64 = 64 * 1024;
#[cfg(target_os = "macos")]
const KEYCHAIN_READ_TIMEOUT: Duration = Duration::from_secs(5);
pub(crate) const CLAUDE_CODE_CREDENTIALS_PATH_ENV: &str = "MC_CLAUDE_CODE_CREDENTIALS_PATH";
#[derive(Clone, PartialEq, Eq)]
pub(crate) enum ClaudeCodeAuth {
OAuth(ClaudeOAuthCredential),
ApiKey { key: String },
}
impl fmt::Debug for ClaudeCodeAuth {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::OAuth(credential) => formatter.debug_tuple("OAuth").field(credential).finish(),
Self::ApiKey { .. } => formatter
.debug_struct("ApiKey")
.field("key", &"<redacted>")
.finish(),
}
}
}
impl ClaudeCodeAuth {
pub(crate) fn readiness_credential(auth: &Auth) -> anyhow::Result<Option<ProviderCredential>> {
let store = ClaudeCodeCredentialStore::default();
if let Some(oauth) = store.read_oauth_credential()? {
return Ok(Some(ProviderCredential::OAuth {
access: oauth.access_token,
account_id: None,
}));
}
Ok(
match auth.providers.get(crate::providers::CLAUDE_CODE_PROVIDER) {
Some(AuthProviderRecord::ApiKey { key }) => {
Some(ProviderCredential::ApiKey { key: key.clone() })
}
_ => None,
},
)
}
pub(crate) fn resolve_oauth() -> anyhow::Result<Self> {
let store = ClaudeCodeCredentialStore::default();
match store.read_oauth_credential()? {
Some(oauth) => Ok(Self::OAuth(oauth)),
None => anyhow::bail!(missing_claude_code_auth_message()),
}
}
pub(crate) fn resolve_with_api_key_fallback(auth: &Auth) -> anyhow::Result<Self> {
let store = ClaudeCodeCredentialStore::default();
if let Some(oauth) = store.read_oauth_credential()? {
return Ok(Self::OAuth(oauth));
}
match auth.providers.get(crate::providers::CLAUDE_CODE_PROVIDER) {
Some(AuthProviderRecord::ApiKey { key }) => Ok(Self::ApiKey { key: key.clone() }),
_ => anyhow::bail!(missing_claude_code_auth_message()),
}
}
pub(crate) fn access_mode(&self) -> ClaudeCodeAccessMode<'_> {
match self {
Self::OAuth(oauth) => ClaudeCodeAccessMode::OAuth(&oauth.access_token),
Self::ApiKey { key } => ClaudeCodeAccessMode::ApiKey(key),
}
}
pub(crate) fn resync_and_refresh_snapshot_if_needed(
&mut self,
) -> anyhow::Result<Option<ClaudeOAuthRefreshSnapshot>> {
let Self::OAuth(oauth) = self else {
return Ok(None);
};
if let Some(current) = oauth.read_current_source_credential()? {
*oauth = current;
}
Ok(oauth.refresh_snapshot_if_needed())
}
pub(crate) fn commit_refreshed_snapshot_if_current(
&mut self,
snapshot: &ClaudeOAuthRefreshSnapshot,
refreshed: RefreshedClaudeToken,
) -> anyhow::Result<Option<ClaudeOAuthCredential>> {
let Self::OAuth(oauth) = self else {
return Ok(None);
};
oauth.commit_refreshed_snapshot_if_current(snapshot, refreshed)
}
pub(crate) fn refresh_if_needed(
&mut self,
cancellation: &crate::agent::cancellation::AgentCancellation,
) -> anyhow::Result<()> {
let Some(snapshot) = self.resync_and_refresh_snapshot_if_needed()? else {
return Ok(());
};
let refreshed = snapshot.refresh(cancellation)?;
self.commit_refreshed_snapshot_if_current(&snapshot, refreshed)?;
Ok(())
}
}
pub(crate) enum ClaudeCodeAccessMode<'a> {
OAuth(&'a str),
ApiKey(&'a str),
}
impl ClaudeCodeAccessMode<'_> {
pub(crate) fn is_oauth(&self) -> bool {
matches!(self, ClaudeCodeAccessMode::OAuth(_))
}
}
#[derive(Clone, PartialEq, Eq)]
pub(crate) struct ClaudeOAuthCredential {
pub(crate) access_token: String,
pub(crate) refresh_token: String,
pub(crate) expires_at_ms: Option<i64>,
pub(crate) source: ClaudeCredentialSource,
pub(crate) store: Arc<ClaudeCodeCredentialStore>,
}
impl fmt::Debug for ClaudeOAuthCredential {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("ClaudeOAuthCredential")
.field("access_token", &"<redacted>")
.field("refresh_token", &"<redacted>")
.field("expires_at_ms", &self.expires_at_ms)
.field("source", &self.source)
.finish()
}
}
#[derive(Clone)]
pub(crate) struct ClaudeOAuthRefreshSnapshot {
credential: ClaudeOAuthCredential,
}
impl fmt::Debug for ClaudeOAuthRefreshSnapshot {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("ClaudeOAuthRefreshSnapshot")
.field("credential", &self.credential)
.finish()
}
}
impl ClaudeOAuthRefreshSnapshot {
pub(crate) fn refresh(
&self,
cancellation: &crate::agent::cancellation::AgentCancellation,
) -> anyhow::Result<RefreshedClaudeToken> {
cancellation.check()?;
self.credential
.store
.refresh_transport
.refresh(&self.credential.refresh_token, cancellation)
}
}
impl ClaudeOAuthCredential {
fn refresh_snapshot_if_needed(&self) -> Option<ClaudeOAuthRefreshSnapshot> {
self.requires_refresh().then(|| ClaudeOAuthRefreshSnapshot {
credential: self.clone(),
})
}
fn read_current_source_credential(&self) -> anyhow::Result<Option<ClaudeOAuthCredential>> {
let current = match &self.source {
ClaudeCredentialSource::File(path) => self
.store
.read_file_oauth_credential_at(path)?
.filter(|credential| matches!(credential.source, ClaudeCredentialSource::File(_))),
ClaudeCredentialSource::Keychain => self
.store
.read_oauth_credential()?
.filter(|credential| matches!(credential.source, ClaudeCredentialSource::Keychain)),
};
Ok(current.filter(|credential| credential != self))
}
fn commit_refreshed_snapshot_if_current(
&mut self,
snapshot: &ClaudeOAuthRefreshSnapshot,
refreshed: RefreshedClaudeToken,
) -> anyhow::Result<Option<ClaudeOAuthCredential>> {
if *self != snapshot.credential {
return Ok(None);
}
if let Some(current) = snapshot.credential.read_current_source_credential()? {
*self = current;
return Ok(None);
}
let mut updated = self.clone();
updated.access_token = refreshed.access_token;
if let Some(refresh_token) = refreshed.refresh_token {
updated.refresh_token = refresh_token;
}
if let Some(expires_at_ms) = refreshed.expires_at_ms {
updated.expires_at_ms = Some(expires_at_ms);
}
updated.write_file_if_file_sourced()?;
*self = updated;
Ok(Some(self.clone()))
}
fn write_file_if_file_sourced(&self) -> anyhow::Result<()> {
if let ClaudeCredentialSource::File(path) = &self.source {
self.store.write_file_credential(path, self)?;
}
Ok(())
}
fn requires_refresh(&self) -> bool {
self.expires_at_ms.is_none_or(|expires_at_ms| {
let expires_at_secs = expires_at_ms / 1000;
expires_at_secs <= Utc::now().timestamp() + OAUTH_REFRESH_SKEW_SECS
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum ClaudeCredentialSource {
Keychain,
File(PathBuf),
}
pub(crate) trait ClaudeTokenRefreshTransport: Send + Sync + fmt::Debug {
fn refresh(
&self,
refresh_token: &str,
cancellation: &crate::agent::cancellation::AgentCancellation,
) -> anyhow::Result<RefreshedClaudeToken>;
}
#[derive(Clone, PartialEq, Eq)]
pub(crate) struct RefreshedClaudeToken {
pub(crate) access_token: String,
pub(crate) refresh_token: Option<String>,
pub(crate) expires_at_ms: Option<i64>,
}
impl fmt::Debug for RefreshedClaudeToken {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("RefreshedClaudeToken")
.field("access_token", &"<redacted>")
.field(
"refresh_token",
&self.refresh_token.as_ref().map(|_| "<redacted>"),
)
.field("expires_at_ms", &self.expires_at_ms)
.finish()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RefreshFailureCategory {
Transport,
HttpStatus(u16),
Body,
Parse,
Canceled,
}
#[derive(Debug)]
struct RefreshAttemptError {
category: RefreshFailureCategory,
error: anyhow::Error,
}
impl fmt::Display for RefreshAttemptError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
self.error.fmt(formatter)
}
}
impl std::error::Error for RefreshAttemptError {}
impl RefreshAttemptError {
fn fallback_allowed(&self) -> bool {
matches!(
self.category,
RefreshFailureCategory::Transport
| RefreshFailureCategory::HttpStatus(404 | 405 | 500..=599)
)
}
}
#[derive(Debug, Clone)]
struct ReqwestClaudeTokenRefreshTransport;
impl ClaudeTokenRefreshTransport for ReqwestClaudeTokenRefreshTransport {
fn refresh(
&self,
refresh_token: &str,
cancellation: &crate::agent::cancellation::AgentCancellation,
) -> anyhow::Result<RefreshedClaudeToken> {
match post_refresh_token(PRIMARY_TOKEN_URL, refresh_token, cancellation) {
Ok(token) => Ok(token),
Err(primary) if primary.fallback_allowed() => {
cancellation.check()?;
match post_refresh_token(FALLBACK_TOKEN_URL, refresh_token, cancellation) {
Ok(token) => Ok(token),
Err(fallback) if fallback.category == RefreshFailureCategory::Canceled => {
Err(fallback.error)
}
Err(fallback) => Err(anyhow::anyhow!(
"Claude Code OAuth refresh failed at primary and fallback endpoints: {}; fallback: {}",
redact_sensitive_text(&primary.to_string()),
redact_sensitive_text(&fallback.to_string())
)),
}
}
Err(error) => Err(error.error),
}
}
}
fn post_refresh_token(
url: &str,
refresh_token: &str,
cancellation: &crate::agent::cancellation::AgentCancellation,
) -> Result<RefreshedClaudeToken, RefreshAttemptError> {
cancellation.check().map_err(|error| RefreshAttemptError {
category: RefreshFailureCategory::Canceled,
error,
})?;
let client = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(30))
.build()
.map_err(|error| RefreshAttemptError {
category: RefreshFailureCategory::Transport,
error: error.into(),
})?;
let request = client.post(url).form(&[
("grant_type", "refresh_token"),
("refresh_token", refresh_token),
("client_id", CLIENT_ID),
]);
let response = crate::providers::transport::run_provider_header_wait_with_idle_timeout(
move || {
request.send().map_err(|error| {
anyhow::anyhow!("Claude Code OAuth refresh request failed: {error}")
})
},
Duration::from_secs(30),
cancellation,
)
.map_err(|error| RefreshAttemptError {
category: if cancellation.is_canceled() {
RefreshFailureCategory::Canceled
} else {
RefreshFailureCategory::Transport
},
error,
})?;
cancellation.check().map_err(|error| RefreshAttemptError {
category: RefreshFailureCategory::Canceled,
error,
})?;
let status = response.status();
let mut text = String::new();
crate::providers::transport::stream_reader_with_idle_timeout(
response,
Duration::from_secs(30),
cancellation,
None,
&mut |chunk| {
if text.len().saturating_add(chunk.len()) > OAUTH_REFRESH_RESPONSE_BODY_MAX_BYTES as usize {
anyhow::bail!("Claude Code OAuth refresh response body exceeded {OAUTH_REFRESH_RESPONSE_BODY_MAX_BYTES} bytes");
}
text.push_str(chunk);
Ok(())
},
).map_err(|error| RefreshAttemptError { category: if cancellation.is_canceled() { RefreshFailureCategory::Canceled } else { RefreshFailureCategory::Body }, error })?;
cancellation.check().map_err(|error| RefreshAttemptError {
category: RefreshFailureCategory::Canceled,
error,
})?;
if !status.is_success() {
return Err(RefreshAttemptError {
category: RefreshFailureCategory::HttpStatus(status.as_u16()),
error: anyhow::anyhow!(
"Claude Code OAuth refresh failed with status {status}: {}",
redacted_snippet(&text)
),
});
}
parse_refresh_response(&text).map_err(|error| RefreshAttemptError {
category: RefreshFailureCategory::Parse,
error,
})
}
#[cfg(test)]
fn read_limited_refresh_response_body<R: Read>(
reader: R,
max_bytes: u64,
) -> anyhow::Result<String> {
let mut reader = reader.take(max_bytes + 1);
let mut text = String::new();
reader.read_to_string(&mut text)?;
if text.len() as u64 > max_bytes {
anyhow::bail!("Claude Code OAuth refresh response body exceeded {max_bytes} bytes");
}
Ok(text)
}
#[cfg(test)]
fn handle_refresh_response_text(
status: StatusCode,
text: Result<String, String>,
) -> anyhow::Result<RefreshedClaudeToken> {
let text = text.map_err(|error| {
anyhow::anyhow!(
"Claude Code OAuth refresh response body read failed with status {status}: {}",
redacted_snippet(&error)
)
})?;
parse_refresh_http_response(status, &text)
}
#[cfg(test)]
fn parse_refresh_http_response(
status: StatusCode,
text: &str,
) -> anyhow::Result<RefreshedClaudeToken> {
if !status.is_success() {
anyhow::bail!(
"Claude Code OAuth refresh failed with status {status}: {}",
redacted_snippet(text)
);
}
parse_refresh_response(text)
}
fn parse_refresh_response(text: &str) -> anyhow::Result<RefreshedClaudeToken> {
let value: Value = serde_json::from_str(text)?;
let access_token = value
.get("access_token")
.or_else(|| value.get("accessToken"))
.and_then(Value::as_str)
.filter(|token| !token.is_empty())
.ok_or_else(|| anyhow::anyhow!("Claude Code OAuth refresh response missing access token"))?
.to_string();
let refresh_token = value
.get("refresh_token")
.or_else(|| value.get("refreshToken"))
.and_then(Value::as_str)
.filter(|token| !token.is_empty())
.map(str::to_string);
let expires_at_ms = if let Some(expires_at) = value
.get("expires_at")
.or_else(|| value.get("expiresAt"))
.and_then(Value::as_i64)
{
Some(expires_at)
} else {
value
.get("expires_in")
.map(|expires_in| {
if !expires_in.is_number() {
return Ok(None);
}
let seconds = if let Some(seconds) = expires_in.as_i64() {
seconds
} else if let Some(seconds) = expires_in.as_u64() {
i64::try_from(seconds).map_err(|_| {
anyhow::anyhow!(
"Claude Code OAuth refresh response expires_in is too large"
)
})?
} else {
return Err(anyhow::anyhow!(
"Claude Code OAuth refresh response expires_in is too large"
));
};
Utc::now()
.timestamp()
.checked_add(seconds)
.and_then(|expires_at| expires_at.checked_mul(1000))
.map(Some)
.ok_or_else(|| {
anyhow::anyhow!(
"Claude Code OAuth refresh response expires_in is too large"
)
})
})
.transpose()?
.flatten()
};
Ok(RefreshedClaudeToken {
access_token,
refresh_token,
expires_at_ms,
})
}
#[derive(Debug, Clone)]
pub(crate) struct ClaudeCodeCredentialStore {
credentials_path: Option<PathBuf>,
keychain_reader: Arc<dyn ClaudeKeychainReader>,
refresh_transport: Arc<dyn ClaudeTokenRefreshTransport>,
}
impl Default for ClaudeCodeCredentialStore {
fn default() -> Self {
if let Some(path) =
std::env::var_os(CLAUDE_CODE_CREDENTIALS_PATH_ENV).filter(|value| !value.is_empty())
{
return Self {
credentials_path: Some(PathBuf::from(path)),
keychain_reader: Arc::new(DisabledKeychainReader),
refresh_transport: Arc::new(ReqwestClaudeTokenRefreshTransport),
};
}
Self {
credentials_path: default_credentials_path(),
keychain_reader: Arc::new(SecurityCliKeychainReader),
refresh_transport: Arc::new(ReqwestClaudeTokenRefreshTransport),
}
}
}
impl PartialEq for ClaudeCodeCredentialStore {
fn eq(&self, other: &Self) -> bool {
self.credentials_path == other.credentials_path
}
}
impl Eq for ClaudeCodeCredentialStore {}
impl ClaudeCodeCredentialStore {
#[cfg(test)]
pub(crate) fn with_credentials_path(path: PathBuf) -> Self {
Self {
credentials_path: Some(path),
keychain_reader: Arc::new(NoopKeychainReader),
refresh_transport: Arc::new(ReqwestClaudeTokenRefreshTransport),
}
}
#[cfg(test)]
pub(crate) fn with_readers(
path: PathBuf,
keychain_reader: Arc<dyn ClaudeKeychainReader>,
refresh_transport: Arc<dyn ClaudeTokenRefreshTransport>,
) -> Self {
Self {
credentials_path: Some(path),
keychain_reader,
refresh_transport,
}
}
pub(crate) fn read_oauth_credential(&self) -> anyhow::Result<Option<ClaudeOAuthCredential>> {
if let Some(text) = self.keychain_reader.read()? {
return parse_oauth_credential_json(
&text,
ClaudeCredentialSource::Keychain,
Arc::new(self.clone()),
)
.map(Some);
}
let Some(path) = &self.credentials_path else {
return Ok(None);
};
match fs::read_to_string(path) {
Ok(text) => parse_oauth_credential_json(
&text,
ClaudeCredentialSource::File(path.clone()),
Arc::new(self.clone()),
)
.map(Some),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(anyhow::anyhow!(
"failed reading Claude Code credentials at {}: {error}",
path.display()
)),
}
}
fn read_file_oauth_credential_at(
&self,
path: &Path,
) -> anyhow::Result<Option<ClaudeOAuthCredential>> {
match fs::read_to_string(path) {
Ok(text) => parse_oauth_credential_json(
&text,
ClaudeCredentialSource::File(path.to_path_buf()),
Arc::new(self.clone()),
)
.map(Some),
Err(error) => Err(anyhow::anyhow!(
"failed reading Claude Code credentials at {}: {error}",
path.display()
)),
}
}
fn write_file_credential(
&self,
path: &Path,
credential: &ClaudeOAuthCredential,
) -> anyhow::Result<()> {
let _guard = CrossProcessFileLock::acquire(path)?;
let scopes = read_existing_scopes(path)?;
let document = ClaudeCredentialsFile {
claude_ai_oauth: ClaudeAiOauth {
access_token: credential.access_token.clone(),
refresh_token: credential.refresh_token.clone(),
expires_at: credential.expires_at_ms,
scopes,
},
};
atomic_write_with_permissions(
path,
serde_json::to_string_pretty(&document)?.as_bytes(),
Some(0o600),
)
}
}
fn read_existing_scopes(path: &Path) -> anyhow::Result<Vec<String>> {
match fs::read_to_string(path) {
Ok(text) => serde_json::from_str::<ClaudeCredentialsFile>(&text)
.map(|document| document.claude_ai_oauth.scopes)
.map_err(|error| {
anyhow::anyhow!(
"invalid Claude Code credential JSON while preserving scopes at {}: {error}",
path.display()
)
}),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
Err(error) => Err(anyhow::anyhow!(
"failed reading Claude Code credentials while preserving scopes at {}: {error}",
path.display()
)),
}
}
#[cfg(target_os = "macos")]
fn run_command_with_timeout(
command: &mut proc_mod::Command,
timeout: Duration,
) -> anyhow::Result<Output> {
let mut child = command
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
let start = Instant::now();
loop {
if child.try_wait()?.is_some() {
return child.wait_with_output().map_err(Into::into);
}
if start.elapsed() >= timeout {
let _ = child.kill();
let _ = child.wait();
anyhow::bail!("timed out after {}ms", timeout.as_millis());
}
thread::sleep(Duration::from_millis(10));
}
}
pub(crate) trait ClaudeKeychainReader: Send + Sync + fmt::Debug {
fn read(&self) -> anyhow::Result<Option<String>>;
}
#[derive(Debug, Clone)]
struct DisabledKeychainReader;
impl ClaudeKeychainReader for DisabledKeychainReader {
fn read(&self) -> anyhow::Result<Option<String>> {
Ok(None)
}
}
#[cfg(target_os = "macos")]
type SecurityProcess = proc_mod::Command;
#[derive(Debug, Clone)]
struct SecurityCliKeychainReader;
impl ClaudeKeychainReader for SecurityCliKeychainReader {
fn read(&self) -> anyhow::Result<Option<String>> {
#[cfg(target_os = "macos")]
{
let output = run_command_with_timeout(
SecurityProcess::new("security").args([
"find-generic-password",
"-s",
"Claude Code-credentials",
"-w",
]),
KEYCHAIN_READ_TIMEOUT,
)
.map_err(|error| {
anyhow::anyhow!(
"failed to query macOS Keychain for Claude Code credentials: {error}"
)
})?;
if output.status.success() {
let text = String::from_utf8(output.stdout).map_err(|_| {
anyhow::anyhow!("Claude Code Keychain credential is not valid UTF-8")
})?;
return Ok(Some(text.trim().to_string()).filter(|value| !value.is_empty()));
}
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("could not be found") || output.status.code() == Some(44) {
return Ok(None);
}
anyhow::bail!(
"failed to query macOS Keychain for Claude Code credentials: status {}; stderr: {}",
output.status,
redacted_snippet(&stderr)
);
}
#[cfg(not(target_os = "macos"))]
{
Ok(None)
}
}
}
#[cfg(test)]
#[derive(Debug, Clone)]
struct NoopKeychainReader;
#[cfg(test)]
impl ClaudeKeychainReader for NoopKeychainReader {
fn read(&self) -> anyhow::Result<Option<String>> {
Ok(None)
}
}
#[derive(Deserialize, Serialize)]
struct ClaudeCredentialsFile {
#[serde(rename = "claudeAiOauth")]
claude_ai_oauth: ClaudeAiOauth,
}
impl fmt::Debug for ClaudeCredentialsFile {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("ClaudeCredentialsFile")
.field("claude_ai_oauth", &self.claude_ai_oauth)
.finish()
}
}
#[derive(Deserialize, Serialize)]
struct ClaudeAiOauth {
#[serde(rename = "accessToken")]
access_token: String,
#[serde(rename = "refreshToken")]
refresh_token: String,
#[serde(rename = "expiresAt", default, skip_serializing_if = "Option::is_none")]
expires_at: Option<i64>,
#[serde(default)]
scopes: Vec<String>,
}
impl fmt::Debug for ClaudeAiOauth {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("ClaudeAiOauth")
.field("access_token", &"<redacted>")
.field("refresh_token", &"<redacted>")
.field("expires_at", &self.expires_at)
.field("scopes", &self.scopes)
.finish()
}
}
fn parse_oauth_credential_json(
text: &str,
source: ClaudeCredentialSource,
store: Arc<ClaudeCodeCredentialStore>,
) -> anyhow::Result<ClaudeOAuthCredential> {
let file: ClaudeCredentialsFile = serde_json::from_str(text).map_err(|error| {
anyhow::anyhow!(
"invalid Claude Code credential JSON from {}: {error}",
source_label(&source)
)
})?;
let access_token = file.claude_ai_oauth.access_token.trim().to_string();
let refresh_token = file.claude_ai_oauth.refresh_token.trim().to_string();
if access_token.is_empty() || refresh_token.is_empty() {
anyhow::bail!(
"Claude Code OAuth credential from {} is missing access or refresh token",
source_label(&source)
);
}
if !is_oauth_token(&access_token) {
anyhow::bail!(
"Claude Code credential from {} is not an OAuth access token",
source_label(&source)
);
}
Ok(ClaudeOAuthCredential {
access_token,
refresh_token,
expires_at_ms: file.claude_ai_oauth.expires_at,
source,
store,
})
}
pub(crate) fn missing_claude_code_auth_message() -> String {
"provider 'claude-code' requires Claude Code OAuth credentials from macOS Keychain service 'Claude Code-credentials' or ~/.claude/.credentials.json, or explicit provider-keyed API key auth in magi-code auth.json; API-key fallback bills Anthropic API credits".to_string()
}
pub(crate) fn is_oauth_token(token: &str) -> bool {
(token.starts_with("sk-ant-") && !token.starts_with("sk-ant-api"))
|| token.starts_with("eyJ")
|| token.starts_with("cc-")
}
#[cfg(test)]
pub(crate) fn is_api_key_token(token: &str) -> bool {
token.starts_with("sk-ant-api")
}
fn source_label(source: &ClaudeCredentialSource) -> String {
match source {
ClaudeCredentialSource::Keychain => "macOS Keychain".to_string(),
ClaudeCredentialSource::File(path) => path.display().to_string(),
}
}
fn default_credentials_path() -> Option<PathBuf> {
dirs::home_dir().map(|home| home.join(".claude").join(".credentials.json"))
}
fn redacted_snippet(text: &str) -> String {
let redacted = redact_sensitive_text(text);
let mut snippet = redacted.chars().take(300).collect::<String>();
if redacted.chars().count() > 300 {
snippet.push_str("...");
}
snippet.replace('\n', "\\n")
}
#[cfg(test)]
pub(crate) mod test_support {
use super::*;
use std::sync::{Arc, Mutex};
#[derive(Debug)]
pub(crate) struct StaticKeychainReader(pub(crate) Option<String>);
impl ClaudeKeychainReader for StaticKeychainReader {
fn read(&self) -> anyhow::Result<Option<String>> {
Ok(self.0.clone())
}
}
#[derive(Debug)]
pub(crate) struct ScriptedRefreshTransport {
pub(crate) calls: Arc<Mutex<usize>>,
pub(crate) response: RefreshedClaudeToken,
}
impl ClaudeTokenRefreshTransport for ScriptedRefreshTransport {
fn refresh(
&self,
_refresh_token: &str,
_cancellation: &crate::agent::cancellation::AgentCancellation,
) -> anyhow::Result<RefreshedClaudeToken> {
*self.calls.lock().unwrap() += 1;
Ok(self.response.clone())
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agent::cancellation::{AgentCancellation, is_run_canceled};
use std::{
io::Write,
net::TcpListener,
sync::{
Arc, Mutex,
atomic::{AtomicBool, Ordering},
},
thread,
time::Instant,
};
fn spawn_stalled_oauth_server(
send_headers: bool,
) -> (String, Arc<AtomicBool>, thread::JoinHandle<()>) {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let url = format!("http://{}/oauth/token", listener.local_addr().unwrap());
let released = Arc::new(AtomicBool::new(false));
let server_released = Arc::clone(&released);
let server = thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
if send_headers {
stream
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 128\r\n\r\n{\"access_token\":\"partial")
.unwrap();
}
while !server_released.load(Ordering::SeqCst) {
thread::sleep(std::time::Duration::from_millis(5));
}
let _ = stream.shutdown(std::net::Shutdown::Both);
});
(url, released, server)
}
fn assert_refresh_cancels_during_stalled_request(send_headers: bool) {
let (url, released, server) = spawn_stalled_oauth_server(send_headers);
let (cancellation, handle) = AgentCancellation::default().child_token();
let started = Instant::now();
let canceler = thread::spawn(move || {
thread::sleep(std::time::Duration::from_millis(50));
handle.cancel();
});
let error = post_refresh_token(&url, "synthetic-refresh-token", &cancellation)
.unwrap_err()
.error;
canceler.join().unwrap();
released.store(true, Ordering::SeqCst);
server.join().unwrap();
assert!(is_run_canceled(&error));
assert!(started.elapsed() < std::time::Duration::from_secs(1));
}
#[test]
fn oauth_refresh_cancels_before_response_headers() {
assert_refresh_cancels_during_stalled_request(false);
}
#[test]
fn oauth_refresh_cancels_during_stalled_response_body() {
assert_refresh_cancels_during_stalled_request(true);
}
#[derive(Debug)]
struct ScriptedKeychainReader {
reads: Mutex<Vec<Option<String>>>,
}
impl ClaudeKeychainReader for ScriptedKeychainReader {
fn read(&self) -> anyhow::Result<Option<String>> {
let mut reads = self.reads.lock().unwrap();
if reads.len() > 1 {
Ok(reads.remove(0))
} else {
Ok(reads.first().cloned().unwrap_or(None))
}
}
}
fn credential_json(access: &str, refresh: &str, expires_at: i64) -> String {
format!(
r#"{{"claudeAiOauth":{{"accessToken":"{access}","refreshToken":"{refresh}","expiresAt":{expires_at}}}}}"#
)
}
#[test]
fn claude_code_secret_debug_output_redacts_token_values() {
let refreshed = RefreshedClaudeToken {
access_token: "cc-access-secret".to_string(),
refresh_token: Some("cc-refresh-secret".to_string()),
expires_at_ms: Some(4102444800000),
};
let file = ClaudeCredentialsFile {
claude_ai_oauth: ClaudeAiOauth {
access_token: "cc-file-access-secret".to_string(),
refresh_token: "cc-file-refresh-secret".to_string(),
expires_at: Some(4102444800000),
scopes: vec!["user:inference".to_string()],
},
};
let oauth = ClaudeOAuthCredential {
access_token: "cc-oauth-access-secret".to_string(),
refresh_token: "cc-oauth-refresh-secret".to_string(),
expires_at_ms: Some(4102444800000),
source: ClaudeCredentialSource::Keychain,
store: Arc::new(ClaudeCodeCredentialStore::with_credentials_path(
PathBuf::from("/tmp/unused"),
)),
};
let auth = ClaudeCodeAuth::ApiKey {
key: "sk-ant-api03-secret".to_string(),
};
let debug = format!("{refreshed:?} {file:?} {oauth:?} {auth:?}");
for secret in [
"cc-access-secret",
"cc-refresh-secret",
"cc-file-access-secret",
"cc-file-refresh-secret",
"cc-oauth-access-secret",
"cc-oauth-refresh-secret",
"sk-ant-api03-secret",
] {
assert!(!debug.contains(secret), "{debug}");
}
assert!(debug.contains("<redacted>"), "{debug}");
}
#[test]
fn claude_code_token_prefix_detection_separates_oauth_and_api_key() {
assert!(is_oauth_token("sk-ant-oauth-fake"));
assert!(is_oauth_token("eyJfake.header.signature"));
assert!(is_oauth_token("cc-fake"));
assert!(is_api_key_token("sk-ant-api03-fake"));
assert!(!is_oauth_token("sk-ant-api03-fake"));
}
#[test]
fn claude_code_file_source_resync_reads_exact_path() {
let temp = tempfile::TempDir::new().unwrap();
let path = temp.path().join(".credentials.json");
fs::write(&path, credential_json("cc-old", "cc-refresh-old", 1)).unwrap();
let store = Arc::new(ClaudeCodeCredentialStore::with_credentials_path(
path.clone(),
));
let credential = store.read_oauth_credential().unwrap().unwrap();
fs::write(&path, credential_json("cc-new", "cc-refresh-new", 2)).unwrap();
let current = credential
.read_current_source_credential()
.unwrap()
.unwrap();
assert_eq!(current.access_token, "cc-new");
assert_eq!(current.refresh_token, "cc-refresh-new");
assert_eq!(current.expires_at_ms, Some(2));
assert_eq!(current.source, ClaudeCredentialSource::File(path));
}
#[test]
fn claude_code_keychain_source_resync_accepts_only_keychain_source() {
let temp = tempfile::TempDir::new().unwrap();
let path = temp.path().join(".credentials.json");
fs::write(&path, credential_json("cc-file", "cc-refresh-file", 2)).unwrap();
let file_only_store = Arc::new(ClaudeCodeCredentialStore::with_readers(
path.clone(),
Arc::new(test_support::StaticKeychainReader(None)),
Arc::new(ReqwestClaudeTokenRefreshTransport),
));
let credential = ClaudeOAuthCredential {
access_token: "cc-keychain-old".to_string(),
refresh_token: "cc-refresh-keychain-old".to_string(),
expires_at_ms: Some(1),
source: ClaudeCredentialSource::Keychain,
store: file_only_store,
};
assert!(
credential
.read_current_source_credential()
.unwrap()
.is_none()
);
let keychain_store = Arc::new(ClaudeCodeCredentialStore::with_readers(
path,
Arc::new(test_support::StaticKeychainReader(Some(credential_json(
"cc-keychain-new",
"cc-refresh-keychain-new",
3,
)))),
Arc::new(ReqwestClaudeTokenRefreshTransport),
));
let credential = ClaudeOAuthCredential {
store: keychain_store,
..credential
};
let current = credential
.read_current_source_credential()
.unwrap()
.unwrap();
assert_eq!(current.access_token, "cc-keychain-new");
assert_eq!(current.refresh_token, "cc-refresh-keychain-new");
assert_eq!(current.source, ClaudeCredentialSource::Keychain);
}
#[test]
fn claude_code_oauth_file_parse_success() {
let temp = tempfile::TempDir::new().unwrap();
let path = temp.path().join(".credentials.json");
fs::write(&path, r#"{"claudeAiOauth":{"accessToken":"cc-access","refreshToken":"cc-refresh","expiresAt":4102444800000,"scopes":["user:inference"]}}"#).unwrap();
let store = ClaudeCodeCredentialStore::with_credentials_path(path);
let credential = store.read_oauth_credential().unwrap().unwrap();
assert_eq!(credential.access_token, "cc-access");
assert_eq!(credential.refresh_token, "cc-refresh");
assert_eq!(credential.expires_at_ms, Some(4102444800000));
}
#[test]
fn claude_code_oauth_file_parse_rejects_missing_tokens_without_leaking_values() {
let temp = tempfile::TempDir::new().unwrap();
let path = temp.path().join(".credentials.json");
fs::write(
&path,
r#"{"claudeAiOauth":{"accessToken":"cc-secret","refreshToken":"","expiresAt":1}}"#,
)
.unwrap();
let store = ClaudeCodeCredentialStore::with_credentials_path(path);
let error = store.read_oauth_credential().unwrap_err().to_string();
assert!(error.contains("missing access or refresh token"), "{error}");
assert!(!error.contains("cc-secret"), "{error}");
}
#[test]
fn claude_code_keychain_has_priority_over_file() {
let temp = tempfile::TempDir::new().unwrap();
let path = temp.path().join(".credentials.json");
fs::write(
&path,
r#"{"claudeAiOauth":{"accessToken":"cc-file","refreshToken":"cc-file-refresh"}}"#,
)
.unwrap();
let store = ClaudeCodeCredentialStore::with_readers(
path,
Arc::new(test_support::StaticKeychainReader(Some(r#"{"claudeAiOauth":{"accessToken":"cc-keychain","refreshToken":"cc-keychain-refresh"}}"#.to_string()))),
Arc::new(ReqwestClaudeTokenRefreshTransport),
);
assert_eq!(
store.read_oauth_credential().unwrap().unwrap().access_token,
"cc-keychain"
);
}
#[test]
fn claude_code_expired_file_token_refreshes_and_persists_0600() {
let temp = tempfile::TempDir::new().unwrap();
let path = temp.path().join(".credentials.json");
fs::write(&path, r#"{"claudeAiOauth":{"accessToken":"cc-old","refreshToken":"cc-refresh","expiresAt":1}}"#).unwrap();
let calls = Arc::new(Mutex::new(0));
let store = ClaudeCodeCredentialStore::with_readers(
path.clone(),
Arc::new(test_support::StaticKeychainReader(None)),
Arc::new(test_support::ScriptedRefreshTransport {
calls: Arc::clone(&calls),
response: RefreshedClaudeToken {
access_token: "cc-new".to_string(),
refresh_token: Some("cc-refresh-new".to_string()),
expires_at_ms: Some(4102444800000),
},
}),
);
let mut auth = ClaudeCodeAuth::OAuth(store.read_oauth_credential().unwrap().unwrap());
auth.refresh_if_needed(&crate::agent::cancellation::AgentCancellation::default())
.unwrap();
assert_eq!(*calls.lock().unwrap(), 1);
let text = fs::read_to_string(&path).unwrap();
assert!(text.contains("cc-new"));
assert!(text.contains("cc-refresh-new"));
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(
fs::metadata(&path).unwrap().permissions().mode() & 0o777,
0o600
);
}
}
#[test]
fn claude_code_expired_file_token_refresh_preserves_existing_scopes() {
let temp = tempfile::TempDir::new().unwrap();
let path = temp.path().join(".credentials.json");
fs::write(&path, r#"{"claudeAiOauth":{"accessToken":"cc-old","refreshToken":"cc-refresh","expiresAt":1,"scopes":["user:inference","org:read"]}}"#).unwrap();
let calls = Arc::new(Mutex::new(0));
let store = ClaudeCodeCredentialStore::with_readers(
path.clone(),
Arc::new(test_support::StaticKeychainReader(None)),
Arc::new(test_support::ScriptedRefreshTransport {
calls: Arc::clone(&calls),
response: RefreshedClaudeToken {
access_token: "cc-new".to_string(),
refresh_token: None,
expires_at_ms: Some(4102444800000),
},
}),
);
let mut auth = ClaudeCodeAuth::OAuth(store.read_oauth_credential().unwrap().unwrap());
auth.refresh_if_needed(&crate::agent::cancellation::AgentCancellation::default())
.unwrap();
assert_eq!(*calls.lock().unwrap(), 1);
let document: ClaudeCredentialsFile =
serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(
document.claude_ai_oauth.scopes,
vec!["user:inference".to_string(), "org:read".to_string()]
);
}
#[test]
fn claude_code_stale_oauth_refresh_snapshot_is_not_committed() {
let store = Arc::new(ClaudeCodeCredentialStore::with_credentials_path(
PathBuf::from("/tmp/unused"),
));
let mut auth = ClaudeCodeAuth::OAuth(ClaudeOAuthCredential {
access_token: "cc-old".to_string(),
refresh_token: "cc-refresh".to_string(),
expires_at_ms: Some(1),
source: ClaudeCredentialSource::Keychain,
store,
});
let ClaudeCodeAuth::OAuth(oauth) = &mut auth else {
unreachable!();
};
let snapshot = oauth.refresh_snapshot_if_needed().unwrap();
oauth.access_token = "cc-newer".to_string();
let credential_to_write = auth
.commit_refreshed_snapshot_if_current(
&snapshot,
RefreshedClaudeToken {
access_token: "cc-stale".to_string(),
refresh_token: Some("cc-stale-refresh".to_string()),
expires_at_ms: Some(4102444800000),
},
)
.unwrap();
assert!(credential_to_write.is_none());
let ClaudeCodeAuth::OAuth(oauth) = auth else {
unreachable!();
};
assert_eq!(oauth.access_token, "cc-newer");
assert_eq!(oauth.refresh_token, "cc-refresh");
}
#[test]
fn claude_code_keychain_rotation_before_refresh_uses_latest_credential() {
let calls = Arc::new(Mutex::new(0));
let store = ClaudeCodeCredentialStore::with_readers(
PathBuf::from("/tmp/unused"),
Arc::new(ScriptedKeychainReader {
reads: Mutex::new(vec![
Some(credential_json("cc-old", "cc-refresh-old", 1)),
Some(credential_json("cc-new", "cc-refresh-new", 4102444800000)),
]),
}),
Arc::new(test_support::ScriptedRefreshTransport {
calls: Arc::clone(&calls),
response: RefreshedClaudeToken {
access_token: "cc-should-not-refresh".to_string(),
refresh_token: None,
expires_at_ms: None,
},
}),
);
let mut auth = ClaudeCodeAuth::OAuth(store.read_oauth_credential().unwrap().unwrap());
let snapshot = auth.resync_and_refresh_snapshot_if_needed().unwrap();
assert!(snapshot.is_none());
assert_eq!(*calls.lock().unwrap(), 0);
let ClaudeCodeAuth::OAuth(oauth) = auth else {
unreachable!();
};
assert_eq!(oauth.access_token, "cc-new");
assert_eq!(oauth.refresh_token, "cc-refresh-new");
}
#[test]
fn claude_code_keychain_rotation_during_refresh_keeps_external_credential() {
let store = Arc::new(ClaudeCodeCredentialStore::with_readers(
PathBuf::from("/tmp/unused"),
Arc::new(ScriptedKeychainReader {
reads: Mutex::new(vec![
Some(credential_json("cc-old", "cc-refresh-old", 1)),
Some(credential_json(
"cc-external",
"cc-refresh-external",
4102444800000,
)),
]),
}),
Arc::new(ReqwestClaudeTokenRefreshTransport),
));
let mut oauth = store.read_oauth_credential().unwrap().unwrap();
let snapshot = oauth.refresh_snapshot_if_needed().unwrap();
let committed = oauth
.commit_refreshed_snapshot_if_current(
&snapshot,
RefreshedClaudeToken {
access_token: "cc-stale-refresh-result".to_string(),
refresh_token: Some("cc-refresh-stale-result".to_string()),
expires_at_ms: Some(4102444800000),
},
)
.unwrap();
assert!(committed.is_none());
assert_eq!(oauth.access_token, "cc-external");
assert_eq!(oauth.refresh_token, "cc-refresh-external");
}
#[test]
fn claude_code_oauth_refresh_body_read_error_keeps_context_and_redacts() {
let error = handle_refresh_response_text(
StatusCode::OK,
Err("transport read failed near sk-secret-refresh-token".to_string()),
)
.unwrap_err()
.to_string();
assert!(
error.contains("Claude Code OAuth refresh response body read failed"),
"{error}"
);
assert!(error.contains("status 200 OK"), "{error}");
assert!(!error.contains("sk-secret-refresh-token"), "{error}");
assert!(error.contains("<redacted>"), "{error}");
}
#[test]
fn claude_code_oauth_refresh_rejects_expires_in_overflow() {
let error = parse_refresh_response(&format!(
r#"{{"access_token":"cc-access","expires_in":{}}}"#,
i64::MAX
))
.unwrap_err()
.to_string();
assert!(
error.contains("Claude Code OAuth refresh response expires_in is too large"),
"{error}"
);
}
#[test]
fn claude_code_oauth_refresh_parse_rejects_expires_in_above_i64_max() {
let error = parse_refresh_response(
r#"{"access_token":"cc-access","expires_in":9223372036854775808}"#,
)
.unwrap_err()
.to_string();
assert!(
error.contains("Claude Code OAuth refresh response expires_in is too large"),
"{error}"
);
}
#[test]
fn claude_code_oauth_refresh_body_read_rejects_oversized_body() {
let body = vec![b'a'; OAUTH_REFRESH_RESPONSE_BODY_MAX_BYTES as usize + 1];
let error = read_limited_refresh_response_body(
std::io::Cursor::new(body),
OAUTH_REFRESH_RESPONSE_BODY_MAX_BYTES,
)
.unwrap_err()
.to_string();
assert!(error.contains("response body exceeded"), "{error}");
}
#[cfg(target_os = "macos")]
#[test]
fn claude_code_keychain_command_timeout_returns_without_waiting_forever() {
let start = Instant::now();
let error = run_command_with_timeout(
proc_mod::Command::new("sh").args(["-c", "sleep 5"]),
Duration::from_millis(50),
)
.unwrap_err()
.to_string();
assert!(error.contains("timed out"), "{error}");
assert!(start.elapsed() < Duration::from_secs(2));
}
#[test]
fn claude_code_fresh_token_skips_refresh() {
let temp = tempfile::TempDir::new().unwrap();
let path = temp.path().join(".credentials.json");
fs::write(&path, r#"{"claudeAiOauth":{"accessToken":"cc-fresh","refreshToken":"cc-refresh","expiresAt":4102444800000}}"#).unwrap();
let calls = Arc::new(Mutex::new(0));
let store = ClaudeCodeCredentialStore::with_readers(
path,
Arc::new(test_support::StaticKeychainReader(None)),
Arc::new(test_support::ScriptedRefreshTransport {
calls: Arc::clone(&calls),
response: RefreshedClaudeToken {
access_token: "cc-new".to_string(),
refresh_token: None,
expires_at_ms: None,
},
}),
);
let mut auth = ClaudeCodeAuth::OAuth(store.read_oauth_credential().unwrap().unwrap());
auth.refresh_if_needed(&crate::agent::cancellation::AgentCancellation::default())
.unwrap();
assert_eq!(*calls.lock().unwrap(), 0);
}
#[test]
fn oauth_refresh_fallback_classification_is_typed_and_narrow() {
assert!(
RefreshAttemptError {
category: RefreshFailureCategory::Transport,
error: anyhow::anyhow!("transport"),
}
.fallback_allowed()
);
for status in [404, 405, 500, 599] {
assert!(
RefreshAttemptError {
category: RefreshFailureCategory::HttpStatus(status),
error: anyhow::anyhow!("status"),
}
.fallback_allowed()
);
}
for category in [
RefreshFailureCategory::HttpStatus(400),
RefreshFailureCategory::Body,
RefreshFailureCategory::Parse,
RefreshFailureCategory::Canceled,
] {
assert!(
!RefreshAttemptError {
category,
error: anyhow::anyhow!("failure"),
}
.fallback_allowed()
);
}
}
#[test]
fn missing_default_credentials_path_does_not_read_relative_tilde_path() {
let store = ClaudeCodeCredentialStore {
credentials_path: None,
keychain_reader: Arc::new(NoopKeychainReader),
refresh_transport: Arc::new(ReqwestClaudeTokenRefreshTransport),
};
assert!(store.read_oauth_credential().unwrap().is_none());
}
}