use std::fmt;
use std::fs;
use std::io::{self, Read};
use std::path::{Path, PathBuf};
use secrecy::{ExposeSecret, SecretString};
const MAX_FILE_SIZE: u64 = 1_000_000;
#[cfg(target_os = "macos")]
const KEYCHAIN_SERVICE: &str = "Claude Code-credentials";
#[derive(Clone)]
pub struct Credentials {
token: SecretString,
scopes: Vec<String>,
source: CredentialSource,
}
impl Credentials {
#[must_use]
pub fn token(&self) -> &str {
self.token.expose_secret()
}
#[must_use]
pub fn scopes(&self) -> &[String] {
&self.scopes
}
#[must_use]
pub fn source(&self) -> &CredentialSource {
&self.source
}
}
impl fmt::Debug for Credentials {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Credentials")
.field("token", &"<redacted>")
.field("scopes", &self.scopes)
.field("source", &self.source)
.finish()
}
}
#[cfg(test)]
impl Credentials {
pub(crate) fn for_testing(token: impl Into<String>) -> Self {
let token: String = token.into();
debug_assert!(
!token.is_empty(),
"Credentials::for_testing requires a non-empty token",
);
Self {
token: SecretString::from(token),
scopes: Vec::new(),
source: CredentialSource::ClaudeLegacy {
path: PathBuf::from("/test"),
},
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum CredentialSource {
MacosKeychainPrimary,
MacosKeychainMultiAccount {
service: String,
mdat: Option<String>,
},
EnvDir { path: PathBuf },
XdgConfig { path: PathBuf },
ClaudeLegacy { path: PathBuf },
}
#[non_exhaustive]
pub enum CredentialError {
NoCredentials,
SubprocessFailed(io::Error),
IoError { path: PathBuf, cause: io::Error },
ParseError {
path: PathBuf,
cause: serde_json::Error,
},
MissingField { path: PathBuf },
EmptyToken { path: PathBuf },
}
impl CredentialError {
#[must_use]
pub fn code(&self) -> &'static str {
match self {
Self::NoCredentials => "NoCredentials",
Self::SubprocessFailed(_) => "SubprocessFailed",
Self::IoError { .. } => "IoError",
Self::ParseError { .. } => "ParseError",
Self::MissingField { .. } => "MissingField",
Self::EmptyToken { .. } => "EmptyToken",
}
}
}
impl Clone for CredentialError {
fn clone(&self) -> Self {
match self {
Self::NoCredentials => Self::NoCredentials,
Self::SubprocessFailed(e) => {
Self::SubprocessFailed(io::Error::new(e.kind(), e.to_string()))
}
Self::IoError { path, cause } => Self::IoError {
path: path.clone(),
cause: io::Error::new(cause.kind(), cause.to_string()),
},
Self::ParseError { path, cause } => Self::ParseError {
path: path.clone(),
cause: serde_json::Error::io(io::Error::other(cause.to_string())),
},
Self::MissingField { path } => Self::MissingField { path: path.clone() },
Self::EmptyToken { path } => Self::EmptyToken { path: path.clone() },
}
}
}
impl fmt::Debug for CredentialError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NoCredentials => f.write_str("NoCredentials"),
Self::SubprocessFailed(e) => {
f.debug_tuple("SubprocessFailed").field(&e.kind()).finish()
}
Self::IoError { path, cause } => f
.debug_struct("IoError")
.field("path", path)
.field("cause_kind", &cause.kind())
.finish(),
Self::ParseError { path, cause } => f
.debug_struct("ParseError")
.field("path", path)
.field("line", &cause.line())
.field("column", &cause.column())
.finish(),
Self::MissingField { path } => {
f.debug_struct("MissingField").field("path", path).finish()
}
Self::EmptyToken { path } => f.debug_struct("EmptyToken").field("path", path).finish(),
}
}
}
impl fmt::Display for CredentialError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NoCredentials => f.write_str("no OAuth credentials found"),
Self::SubprocessFailed(e) => {
write!(f, "security subprocess failed ({kind})", kind = e.kind())
}
Self::IoError { path, cause } => write!(
f,
"failed to read credentials file {}: {kind}",
path.display(),
kind = cause.kind()
),
Self::ParseError { path, cause } => write!(
f,
"credentials file {} failed to parse at line {}, column {}",
path.display(),
cause.line(),
cause.column()
),
Self::MissingField { path } => write!(
f,
"credentials file {} missing claudeAiOauth.accessToken",
path.display()
),
Self::EmptyToken { path } => write!(
f,
"credentials file {} has empty accessToken",
path.display()
),
}
}
}
impl std::error::Error for CredentialError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::SubprocessFailed(e) => Some(e),
Self::IoError { cause, .. } => Some(cause),
Self::ParseError { cause, .. } => Some(cause),
_ => None,
}
}
}
#[derive(serde::Deserialize)]
struct CredentialsFile {
#[serde(rename = "claudeAiOauth")]
claude_ai_oauth: Option<ClaudeAiOauth>,
}
#[derive(serde::Deserialize)]
struct ClaudeAiOauth {
#[serde(
default,
rename = "accessToken",
deserialize_with = "deserialize_explicit"
)]
access_token: Option<Option<String>>,
#[serde(default)]
scopes: Vec<String>,
}
fn deserialize_explicit<'de, D>(de: D) -> Result<Option<Option<String>>, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::Deserialize;
Option::<String>::deserialize(de).map(Some)
}
pub fn resolve_credentials() -> Result<Credentials, CredentialError> {
resolve_credentials_with(&FileCascadeEnv::from_process_env())
}
pub fn resolve_credentials_with(env: &FileCascadeEnv) -> Result<Credentials, CredentialError> {
#[cfg_attr(not(target_os = "macos"), allow(unused_mut))]
let mut first_subprocess_err: Option<CredentialError> = None;
#[cfg(target_os = "macos")]
{
match macos::try_keychain_primary() {
Ok(Some(creds)) => return Ok(creds),
Ok(None) => {}
Err(e) => first_subprocess_err = Some(e),
}
match macos::try_keychain_multi_account() {
Ok(Some(creds)) => return Ok(creds),
Ok(None) => {}
Err(e) => {
if first_subprocess_err.is_none() {
first_subprocess_err = Some(e);
}
}
}
}
match try_file_cascade_with(env) {
Ok(creds) => Ok(creds),
Err(CredentialError::NoCredentials) => {
Err(first_subprocess_err.unwrap_or(CredentialError::NoCredentials))
}
Err(e) => Err(e),
}
}
#[derive(Debug, Clone, Default)]
#[non_exhaustive]
pub struct FileCascadeEnv {
pub claude_config_dir: Option<PathBuf>,
pub xdg_config_home: Option<PathBuf>,
pub home: Option<PathBuf>,
}
impl FileCascadeEnv {
#[must_use]
pub fn new(
claude_config_dir: Option<std::ffi::OsString>,
xdg_config_home: Option<std::ffi::OsString>,
home: Option<std::ffi::OsString>,
) -> Self {
fn nonempty(v: Option<std::ffi::OsString>) -> Option<PathBuf> {
v.filter(|s| !s.is_empty()).map(PathBuf::from)
}
Self {
claude_config_dir: nonempty(claude_config_dir),
xdg_config_home: nonempty(xdg_config_home),
home: nonempty(home),
}
}
#[must_use]
pub fn from_process_env() -> Self {
Self::new(
std::env::var_os("CLAUDE_CONFIG_DIR"),
std::env::var_os("XDG_CONFIG_HOME"),
std::env::var_os("HOME"),
)
}
}
fn file_cascade_candidates(env: &FileCascadeEnv) -> Vec<(PathBuf, CredentialSource)> {
let mut out = Vec::with_capacity(3);
if let Some(dir) = &env.claude_config_dir {
let path = dir.join(".credentials.json");
out.push((path.clone(), CredentialSource::EnvDir { path }));
}
let xdg_root = env
.xdg_config_home
.clone()
.or_else(|| env.home.as_ref().map(|h| h.join(".config")));
if let Some(xdg_root) = xdg_root {
let xdg_path = xdg_root.join("claude").join(".credentials.json");
out.push((
xdg_path.clone(),
CredentialSource::XdgConfig { path: xdg_path },
));
}
if let Some(home) = &env.home {
let legacy_path = home.join(".claude").join(".credentials.json");
out.push((
legacy_path.clone(),
CredentialSource::ClaudeLegacy { path: legacy_path },
));
}
out
}
fn try_file_cascade_with(env: &FileCascadeEnv) -> Result<Credentials, CredentialError> {
for (path, source) in file_cascade_candidates(env) {
match fs::metadata(&path) {
Ok(_) => return read_and_parse_file(&path, source),
Err(e)
if e.kind() == io::ErrorKind::NotFound
|| e.kind() == io::ErrorKind::PermissionDenied =>
{
continue
}
Err(cause) => return Err(CredentialError::IoError { path, cause }),
}
}
Err(CredentialError::NoCredentials)
}
fn read_and_parse_file(
path: &Path,
source: CredentialSource,
) -> Result<Credentials, CredentialError> {
let file = fs::File::open(path).map_err(|cause| CredentialError::IoError {
path: path.to_path_buf(),
cause,
})?;
let mut buf = String::new();
file.take(MAX_FILE_SIZE + 1)
.read_to_string(&mut buf)
.map_err(|cause| CredentialError::IoError {
path: path.to_path_buf(),
cause,
})?;
if buf.len() as u64 > MAX_FILE_SIZE {
return Err(CredentialError::IoError {
path: path.to_path_buf(),
cause: io::Error::new(
io::ErrorKind::InvalidData,
format!("credentials file exceeds {MAX_FILE_SIZE} byte limit"),
),
});
}
parse_credentials_bytes(&buf, path, source)
}
fn parse_credentials_bytes(
bytes: &str,
path: &Path,
source: CredentialSource,
) -> Result<Credentials, CredentialError> {
let file: CredentialsFile =
serde_json::from_str(bytes).map_err(|cause| CredentialError::ParseError {
path: path.to_path_buf(),
cause,
})?;
let oauth = file
.claude_ai_oauth
.ok_or_else(|| CredentialError::MissingField {
path: path.to_path_buf(),
})?;
match oauth.access_token {
None => Err(CredentialError::MissingField {
path: path.to_path_buf(),
}),
Some(None) => Err(CredentialError::EmptyToken {
path: path.to_path_buf(),
}),
Some(Some(s)) if s.is_empty() => Err(CredentialError::EmptyToken {
path: path.to_path_buf(),
}),
Some(Some(s)) => Ok(Credentials {
token: SecretString::from(s),
scopes: oauth.scopes,
source,
}),
}
}
#[cfg(target_os = "macos")]
mod macos {
use super::*;
use std::io::Read;
use std::os::unix::process::ExitStatusExt;
use std::process::{Command, ExitStatus, Stdio};
use std::time::{Duration, Instant};
const SECURITY_TIMEOUT: Duration = Duration::from_secs(2);
const POLL_INTERVAL: Duration = Duration::from_millis(50);
const KILL_GRACE: Duration = Duration::from_millis(500);
const MAX_STDERR_IN_ERROR: usize = 512;
const ERR_SEC_ITEM_NOT_FOUND: i32 = 44;
pub(super) fn try_keychain_primary() -> Result<Option<Credentials>, CredentialError> {
let user = std::env::var("USER").unwrap_or_default();
let mut args: Vec<&str> = vec!["find-generic-password"];
if !user.is_empty() {
args.extend(["-a", &user]);
}
args.extend(["-w", "-s", KEYCHAIN_SERVICE]);
let run = run_security(&args).map_err(CredentialError::SubprocessFailed)?;
match classify_security_exit(&run) {
SecurityResult::ItemNotFound => return Ok(None),
SecurityResult::Failed(e) => return Err(CredentialError::SubprocessFailed(e)),
SecurityResult::Success => {}
}
let stdout = String::from_utf8_lossy(&run.stdout);
let stdout = stdout.trim();
if stdout.is_empty() {
return Ok(None);
}
match parse_credentials_bytes(
stdout,
Path::new("keychain:Claude Code-credentials"),
CredentialSource::MacosKeychainPrimary,
) {
Ok(creds) => Ok(Some(creds)),
Err(CredentialError::MissingField { .. } | CredentialError::EmptyToken { .. }) => {
Ok(None)
}
Err(e) => Err(e),
}
}
pub(super) fn try_keychain_multi_account() -> Result<Option<Credentials>, CredentialError> {
let dump = run_security(&["dump-keychain"]).map_err(CredentialError::SubprocessFailed)?;
match classify_security_exit(&dump) {
SecurityResult::ItemNotFound => return Ok(None),
SecurityResult::Failed(e) => return Err(CredentialError::SubprocessFailed(e)),
SecurityResult::Success => {}
}
let dump_text = String::from_utf8_lossy(&dump.stdout);
let mut candidates = parse_dump_for_services(&dump_text);
candidates.sort_by(|a, b| match (&a.mdat, &b.mdat) {
(Some(am), Some(bm)) => bm.cmp(am),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => std::cmp::Ordering::Equal,
});
let mut first_err: Option<io::Error> = None;
for candidate in candidates {
let run = match run_security(&["find-generic-password", "-w", "-s", &candidate.service])
{
Ok(r) => r,
Err(e) => {
if first_err.is_none() {
first_err = Some(e);
}
continue;
}
};
match classify_security_exit(&run) {
SecurityResult::ItemNotFound => continue,
SecurityResult::Failed(e) => {
if first_err.is_none() {
first_err = Some(e);
}
continue;
}
SecurityResult::Success => {}
}
let stdout = String::from_utf8_lossy(&run.stdout);
let stdout = stdout.trim();
if stdout.is_empty() {
continue;
}
match parse_credentials_bytes(
stdout,
Path::new("keychain"),
CredentialSource::MacosKeychainMultiAccount {
service: candidate.service.clone(),
mdat: candidate.mdat.clone(),
},
) {
Ok(creds) => return Ok(Some(creds)),
Err(CredentialError::MissingField { .. } | CredentialError::EmptyToken { .. }) => {
continue
}
Err(e) => return Err(e),
}
}
match first_err {
Some(e) => Err(CredentialError::SubprocessFailed(e)),
None => Ok(None),
}
}
pub(super) struct SecurityRun {
pub status: ExitStatus,
pub stdout: Vec<u8>,
pub stderr: Vec<u8>,
}
pub(super) enum SecurityResult {
Success,
ItemNotFound,
Failed(io::Error),
}
pub(super) fn run_security(args: &[&str]) -> io::Result<SecurityRun> {
let mut child = Command::new("security")
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
let stdout = child.stdout.take().expect("stdout piped");
let stderr = child.stderr.take().expect("stderr piped");
let stdout_handle = std::thread::spawn(move || drain(stdout));
let stderr_handle = std::thread::spawn(move || drain(stderr));
let deadline = Instant::now() + SECURITY_TIMEOUT;
let status = loop {
match child.try_wait()? {
Some(status) => break status,
None => {
if Instant::now() >= deadline {
let _ = child.kill();
let grace_deadline = Instant::now() + KILL_GRACE;
while Instant::now() < grace_deadline {
if let Ok(Some(_)) = child.try_wait() {
break;
}
std::thread::sleep(POLL_INTERVAL);
}
drop(stdout_handle);
drop(stderr_handle);
return Err(io::Error::new(
io::ErrorKind::TimedOut,
format!("security timed out after {}s", SECURITY_TIMEOUT.as_secs()),
));
}
std::thread::sleep(POLL_INTERVAL);
}
}
};
let stdout = stdout_handle
.join()
.map_err(|_| io::Error::other("security stdout reader thread panicked"))?;
let stderr = stderr_handle
.join()
.map_err(|_| io::Error::other("security stderr reader thread panicked"))?;
Ok(SecurityRun {
status,
stdout,
stderr,
})
}
fn drain<R: Read>(mut reader: R) -> Vec<u8> {
let mut buf = Vec::new();
let _ = reader.read_to_end(&mut buf);
buf
}
pub(super) fn classify_security_exit(run: &SecurityRun) -> SecurityResult {
if run.status.success() {
return SecurityResult::Success;
}
if run.status.code() == Some(ERR_SEC_ITEM_NOT_FOUND) {
return SecurityResult::ItemNotFound;
}
let stderr = String::from_utf8_lossy(&run.stderr);
let stderr = truncate_for_error(stderr.trim());
let msg = match (run.status.code(), run.status.signal()) {
(Some(code), _) if stderr.is_empty() => {
format!("security exited with status {code}")
}
(Some(code), _) => format!("security exited with status {code}: {stderr}"),
(None, Some(sig)) if stderr.is_empty() => {
format!("security terminated by signal {sig}")
}
(None, Some(sig)) => format!("security terminated by signal {sig}: {stderr}"),
(None, None) if stderr.is_empty() => String::from("security terminated abnormally"),
(None, None) => format!("security terminated abnormally: {stderr}"),
};
SecurityResult::Failed(io::Error::other(msg))
}
fn truncate_for_error(s: &str) -> String {
if s.len() <= MAX_STDERR_IN_ERROR {
return s.to_string();
}
let mut end = MAX_STDERR_IN_ERROR;
while end > 0 && !s.is_char_boundary(end) {
end -= 1;
}
format!("{}... (truncated)", &s[..end])
}
struct Candidate {
service: String,
mdat: Option<String>,
}
fn parse_dump_for_services(dump: &str) -> Vec<Candidate> {
let mut out = Vec::new();
let mut current_svce: Option<String> = None;
let mut current_mdat: Option<String> = None;
for line in dump.lines() {
let line = line.trim();
if line.starts_with("keychain:") {
if let Some(svce) = current_svce.take() {
if svce.starts_with(KEYCHAIN_SERVICE) {
out.push(Candidate {
service: svce,
mdat: current_mdat.take(),
});
} else {
current_mdat = None;
}
}
continue;
}
if let Some(val) = extract_quoted_after(line, "\"svce\"") {
current_svce = Some(val);
} else if let Some(val) = extract_quoted_after(line, "\"mdat\"") {
current_mdat = Some(val);
}
}
if let Some(svce) = current_svce {
if svce.starts_with(KEYCHAIN_SERVICE) {
out.push(Candidate {
service: svce,
mdat: current_mdat,
});
}
}
out
}
fn extract_quoted_after(line: &str, key: &str) -> Option<String> {
let after_key = line.strip_prefix(key)?;
let first_quote = after_key.find('"')?;
let rest = &after_key[first_quote + 1..];
let close_quote = rest.find('"')?;
Some(rest[..close_quote].to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use std::os::unix::process::ExitStatusExt;
fn run_with(raw_status: i32, stderr: &[u8]) -> SecurityRun {
SecurityRun {
status: ExitStatus::from_raw(raw_status),
stdout: Vec::new(),
stderr: stderr.to_vec(),
}
}
#[test]
fn classify_success_on_zero_exit() {
let run = run_with(0, b"");
assert!(matches!(
classify_security_exit(&run),
SecurityResult::Success
));
}
#[test]
fn classify_item_not_found_on_exit_44() {
let run = run_with(ERR_SEC_ITEM_NOT_FOUND << 8, b"");
assert!(matches!(
classify_security_exit(&run),
SecurityResult::ItemNotFound,
));
}
#[test]
fn classify_other_non_zero_exit_is_failed_with_stderr() {
let run = run_with(25 << 8, b"keychain locked");
let SecurityResult::Failed(e) = classify_security_exit(&run) else {
panic!("expected Failed variant for non-zero exit with stderr");
};
let msg = e.to_string();
assert!(msg.contains("status 25"), "msg={msg}");
assert!(msg.contains(": keychain locked"), "msg={msg}");
}
#[test]
fn classify_signal_termination_includes_signal_number() {
let run = run_with(9, b"");
let SecurityResult::Failed(e) = classify_security_exit(&run) else {
panic!("expected Failed variant for signal termination");
};
let msg = e.to_string();
assert!(msg.contains("terminated by signal 9"), "msg={msg}",);
}
#[test]
fn classify_signal_termination_includes_stderr() {
let run = run_with(11, b"segfault diag");
let SecurityResult::Failed(e) = classify_security_exit(&run) else {
panic!("expected Failed variant");
};
let msg = e.to_string();
assert!(msg.contains("terminated by signal 11"), "msg={msg}");
assert!(msg.contains(": segfault diag"), "msg={msg}");
}
#[test]
fn classify_failed_truncates_long_stderr() {
let long_stderr = "x".repeat(MAX_STDERR_IN_ERROR * 2);
let run = run_with(25 << 8, long_stderr.as_bytes());
let SecurityResult::Failed(e) = classify_security_exit(&run) else {
panic!("expected Failed variant");
};
let msg = e.to_string();
assert!(msg.contains("(truncated)"), "msg={msg}");
assert!(
msg.len() < long_stderr.len(),
"expected truncation, got msg.len()={}",
msg.len()
);
}
#[test]
fn parses_dump_with_single_matching_service() {
let dump = r#"keychain: "/Users/alice/Library/Keychains/login.keychain-db"
"svce"<blob>="Claude Code-credentials"
"acct"<blob>="alice"
"mdat"<timedate>=0x30303030 "20260418105500Z"
"#;
let candidates = parse_dump_for_services(dump);
assert_eq!(candidates.len(), 1);
assert_eq!(candidates[0].service, "Claude Code-credentials");
assert_eq!(candidates[0].mdat.as_deref(), Some("20260418105500Z"));
}
#[test]
fn skips_non_matching_services() {
let dump = r#"keychain: "/path/to/login.keychain"
"svce"<blob>="some.other.app"
"mdat"<timedate>=0x00 "20260101000000Z"
keychain: "/path/to/login.keychain"
"svce"<blob>="Claude Code-credentials-acct2"
"mdat"<timedate>=0x00 "20260420000000Z"
"#;
let candidates = parse_dump_for_services(dump);
assert_eq!(candidates.len(), 1);
assert_eq!(candidates[0].service, "Claude Code-credentials-acct2");
}
}
}
#[cfg(test)]
mod tests;