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 {
use super::*;
use tempfile::TempDir;
fn write_creds(dir: &Path, relative: &str, contents: &str) -> PathBuf {
let path = dir.join(relative);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(&path, contents).unwrap();
path
}
fn valid_credentials_json(token: &str) -> String {
format!(
r#"{{
"claudeAiOauth": {{
"accessToken": "{token}",
"refreshToken": null,
"expiresAt": null,
"scopes": ["user:inference", "user:profile"],
"subscriptionType": null
}}
}}"#
)
}
#[test]
fn parses_valid_credentials_bytes() {
let json = valid_credentials_json("test-token-xyz");
let creds = parse_credentials_bytes(
&json,
Path::new("/test"),
CredentialSource::ClaudeLegacy {
path: PathBuf::from("/test"),
},
)
.expect("parse");
assert_eq!(creds.token(), "test-token-xyz");
assert_eq!(creds.scopes().len(), 2);
assert!(matches!(
creds.source(),
CredentialSource::ClaudeLegacy { .. }
));
}
#[test]
fn rejects_null_token_as_empty_token() {
let json = r#"{ "claudeAiOauth": { "accessToken": null } }"#;
let err = parse_credentials_bytes(
json,
Path::new("/test"),
CredentialSource::ClaudeLegacy {
path: PathBuf::from("/test"),
},
)
.unwrap_err();
assert!(matches!(err, CredentialError::EmptyToken { .. }));
}
#[test]
fn rejects_absent_access_token_key_as_missing_field() {
let json = r#"{ "claudeAiOauth": { "scopes": ["x"] } }"#;
let err = parse_credentials_bytes(
json,
Path::new("/test"),
CredentialSource::ClaudeLegacy {
path: PathBuf::from("/test"),
},
)
.unwrap_err();
assert!(matches!(err, CredentialError::MissingField { .. }));
}
#[test]
fn rejects_non_string_access_token() {
let json = r#"{ "claudeAiOauth": { "accessToken": 42 } }"#;
let err = parse_credentials_bytes(
json,
Path::new("/test"),
CredentialSource::ClaudeLegacy {
path: PathBuf::from("/test"),
},
)
.unwrap_err();
assert!(matches!(err, CredentialError::ParseError { .. }));
}
#[test]
fn rejects_empty_token() {
let json = r#"{ "claudeAiOauth": { "accessToken": "" } }"#;
let err = parse_credentials_bytes(
json,
Path::new("/test"),
CredentialSource::ClaudeLegacy {
path: PathBuf::from("/test"),
},
)
.unwrap_err();
assert!(matches!(err, CredentialError::EmptyToken { .. }));
}
#[test]
fn rejects_missing_claude_ai_oauth() {
let json = r#"{ "somethingElse": {} }"#;
let err = parse_credentials_bytes(
json,
Path::new("/test"),
CredentialSource::ClaudeLegacy {
path: PathBuf::from("/test"),
},
)
.unwrap_err();
assert!(matches!(err, CredentialError::MissingField { .. }));
}
#[test]
fn rejects_invalid_json() {
let json = "{ not json at all ";
let err = parse_credentials_bytes(
json,
Path::new("/test"),
CredentialSource::ClaudeLegacy {
path: PathBuf::from("/test"),
},
)
.unwrap_err();
assert!(matches!(err, CredentialError::ParseError { .. }));
}
#[test]
fn scopes_default_to_empty_when_missing() {
let json = r#"{ "claudeAiOauth": { "accessToken": "t" } }"#;
let creds = parse_credentials_bytes(
json,
Path::new("/test"),
CredentialSource::ClaudeLegacy {
path: PathBuf::from("/test"),
},
)
.expect("parse");
assert!(creds.scopes().is_empty());
}
#[test]
fn credentials_debug_redacts_token() {
let creds = Credentials {
token: SecretString::from("super-secret-token".to_string()),
scopes: vec!["x".to_string()],
source: CredentialSource::ClaudeLegacy {
path: PathBuf::from("/etc/x"),
},
};
let debug = format!("{creds:?}");
assert!(
!debug.contains("super-secret-token"),
"debug leaks token: {debug}"
);
assert!(debug.contains("<redacted>"));
}
#[test]
fn credential_error_code_taxonomy() {
let parse_err = serde_json::from_str::<i32>("not-a-number").unwrap_err();
let all: [(CredentialError, &str); 6] = [
(CredentialError::NoCredentials, "NoCredentials"),
(
CredentialError::SubprocessFailed(io::Error::other("x")),
"SubprocessFailed",
),
(
CredentialError::IoError {
path: PathBuf::from("/x"),
cause: io::Error::other("x"),
},
"IoError",
),
(
CredentialError::ParseError {
path: PathBuf::from("/x"),
cause: parse_err,
},
"ParseError",
),
(
CredentialError::MissingField {
path: PathBuf::from("/x"),
},
"MissingField",
),
(
CredentialError::EmptyToken {
path: PathBuf::from("/x"),
},
"EmptyToken",
),
];
for (err, expected) in &all {
assert_eq!(err.code(), *expected);
}
let codes: std::collections::HashSet<&'static str> =
all.iter().map(|(e, _)| e.code()).collect();
assert_eq!(codes.len(), all.len());
}
#[test]
fn parse_error_display_does_not_leak_token_bytes() {
let leaky = r#"{ "claudeAiOauth": { "accessToken": "LEAK-ME-abcdef" "#;
let err = parse_credentials_bytes(
leaky,
Path::new("/etc/creds"),
CredentialSource::ClaudeLegacy {
path: PathBuf::from("/etc/creds"),
},
)
.unwrap_err();
assert!(matches!(err, CredentialError::ParseError { .. }));
let display = format!("{err}");
let debug = format!("{err:?}");
assert!(
!display.contains("LEAK-ME"),
"Display leaked token: {display}"
);
assert!(!debug.contains("LEAK-ME"), "Debug leaked token: {debug}");
}
#[test]
fn oversized_file_rejected_before_parse() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("big.json");
let big = "x".repeat((MAX_FILE_SIZE + 1024) as usize);
fs::write(&path, &big).unwrap();
let err = read_and_parse_file(&path, CredentialSource::ClaudeLegacy { path: path.clone() })
.unwrap_err();
match err {
CredentialError::IoError { cause, .. } => {
assert_eq!(cause.kind(), io::ErrorKind::InvalidData);
}
other => panic!("expected IoError(InvalidData), got {other:?}"),
}
}
mod cascade {
use super::*;
fn env_from(
claude: Option<&Path>,
xdg: Option<&Path>,
home: Option<&Path>,
) -> FileCascadeEnv {
FileCascadeEnv {
claude_config_dir: claude.map(Path::to_path_buf),
xdg_config_home: xdg.map(Path::to_path_buf),
home: home.map(Path::to_path_buf),
}
}
#[test]
fn env_dir_candidate_included_when_set() {
let tmp = TempDir::new().unwrap();
let env = env_from(Some(tmp.path()), None, Some(tmp.path()));
let candidates = file_cascade_candidates(&env);
assert!(matches!(candidates[0].1, CredentialSource::EnvDir { .. }));
}
#[test]
fn env_dir_absent_when_not_set() {
let tmp = TempDir::new().unwrap();
let env = env_from(None, None, Some(tmp.path()));
let candidates = file_cascade_candidates(&env);
assert!(
!matches!(candidates[0].1, CredentialSource::EnvDir { .. }),
"no CLAUDE_CONFIG_DIR should omit the EnvDir candidate"
);
}
#[test]
fn xdg_preferred_over_legacy_when_both_roots_present() {
let tmp = TempDir::new().unwrap();
let xdg = tmp.path().join("xdg");
let env = env_from(None, Some(&xdg), Some(tmp.path()));
let candidates = file_cascade_candidates(&env);
let positions: Vec<_> = candidates
.iter()
.map(|(_, s)| match s {
CredentialSource::XdgConfig { .. } => "xdg",
CredentialSource::ClaudeLegacy { .. } => "legacy",
_ => "other",
})
.collect();
assert_eq!(positions, ["xdg", "legacy"]);
}
#[test]
fn xdg_default_root_is_home_dot_config() {
let tmp = TempDir::new().unwrap();
let env = env_from(None, None, Some(tmp.path()));
let candidates = file_cascade_candidates(&env);
let xdg = candidates
.iter()
.find(|(_, s)| matches!(s, CredentialSource::XdgConfig { .. }))
.expect("xdg candidate present");
assert!(xdg.0.starts_with(tmp.path().join(".config").join("claude")));
}
#[test]
fn resolve_reads_existing_env_dir_credentials() {
let tmp = TempDir::new().unwrap();
write_creds(
tmp.path(),
".credentials.json",
&valid_credentials_json("env-dir-tok"),
);
let env = env_from(Some(tmp.path()), None, Some(tmp.path()));
let creds = try_file_cascade_with(&env).expect("resolve");
assert_eq!(creds.token(), "env-dir-tok");
assert!(matches!(creds.source(), CredentialSource::EnvDir { .. }));
}
#[test]
fn resolve_falls_through_to_xdg() {
let tmp = TempDir::new().unwrap();
let xdg = tmp.path().join("xdg");
write_creds(
&xdg,
"claude/.credentials.json",
&valid_credentials_json("xdg-tok"),
);
let env = env_from(
Some(&tmp.path().join("does-not-exist")),
Some(&xdg),
Some(tmp.path()),
);
let creds = try_file_cascade_with(&env).expect("resolve");
assert_eq!(creds.token(), "xdg-tok");
assert!(matches!(creds.source(), CredentialSource::XdgConfig { .. }));
}
#[test]
fn resolve_falls_through_to_legacy() {
let tmp = TempDir::new().unwrap();
write_creds(
tmp.path(),
".claude/.credentials.json",
&valid_credentials_json("legacy-tok"),
);
let env = env_from(None, None, Some(tmp.path()));
let creds = try_file_cascade_with(&env).expect("resolve");
assert_eq!(creds.token(), "legacy-tok");
assert!(matches!(
creds.source(),
CredentialSource::ClaudeLegacy { .. }
));
}
#[test]
fn resolve_no_files_returns_no_credentials() {
let tmp = TempDir::new().unwrap();
let env = env_from(None, None, Some(tmp.path()));
let err = try_file_cascade_with(&env).unwrap_err();
assert!(matches!(err, CredentialError::NoCredentials));
}
#[test]
fn resolve_no_home_returns_no_credentials() {
let env = env_from(None, None, None);
let err = try_file_cascade_with(&env).unwrap_err();
assert!(matches!(err, CredentialError::NoCredentials));
}
#[test]
fn xdg_path_probed_even_when_home_is_unset() {
let tmp = TempDir::new().unwrap();
let xdg = tmp.path().join("xdg");
write_creds(
&xdg,
"claude/.credentials.json",
&valid_credentials_json("xdg-no-home-tok"),
);
let env = env_from(None, Some(&xdg), None);
let creds = try_file_cascade_with(&env).expect("resolve");
assert_eq!(creds.token(), "xdg-no-home-tok");
assert!(matches!(creds.source(), CredentialSource::XdgConfig { .. }));
}
#[test]
fn candidate_list_includes_xdg_when_home_unset() {
let tmp = TempDir::new().unwrap();
let xdg = tmp.path().join("xdg");
let env = env_from(None, Some(&xdg), None);
let candidates = file_cascade_candidates(&env);
assert!(
candidates
.iter()
.any(|(_, s)| matches!(s, CredentialSource::XdgConfig { .. })),
"XDG candidate must be present with HOME unset + XDG_CONFIG_HOME set",
);
assert!(
!candidates
.iter()
.any(|(_, s)| matches!(s, CredentialSource::ClaudeLegacy { .. })),
"Legacy candidate requires HOME",
);
}
#[test]
fn xdg_wins_when_both_xdg_and_legacy_files_exist() {
let tmp = TempDir::new().unwrap();
let xdg = tmp.path().join("xdg");
write_creds(
&xdg,
"claude/.credentials.json",
&valid_credentials_json("xdg-wins"),
);
write_creds(
tmp.path(),
".claude/.credentials.json",
&valid_credentials_json("legacy-loses"),
);
let env = env_from(None, Some(&xdg), Some(tmp.path()));
let creds = try_file_cascade_with(&env).expect("resolve");
assert_eq!(creds.token(), "xdg-wins");
assert!(matches!(creds.source(), CredentialSource::XdgConfig { .. }));
}
#[test]
fn env_dir_set_but_empty_dir_falls_through_to_xdg() {
let tmp = TempDir::new().unwrap();
let env_dir = tmp.path().join("env-dir");
fs::create_dir_all(&env_dir).unwrap();
let xdg = tmp.path().join("xdg");
write_creds(
&xdg,
"claude/.credentials.json",
&valid_credentials_json("xdg-tok"),
);
let env = env_from(Some(&env_dir), Some(&xdg), Some(tmp.path()));
let creds = try_file_cascade_with(&env).expect("resolve");
assert_eq!(creds.token(), "xdg-tok");
}
#[test]
fn resolve_credentials_end_to_end_no_files() {
let tmp = TempDir::new().unwrap();
let env = env_from(None, None, Some(tmp.path()));
let err = try_file_cascade_with(&env).unwrap_err();
assert!(matches!(err, CredentialError::NoCredentials));
}
}
}