use std::path::{Path, PathBuf};
use crate::core::trusty_tools_config::CRATE_NAME;
pub const TOKEN_FILE: &str = "claude-code-oauth.token";
pub const OAUTH_TOKEN_ENV_VAR: &str = "CLAUDE_CODE_OAUTH_TOKEN";
pub fn token_file_path_at(base: &Path) -> PathBuf {
trusty_common::crate_config::crate_config_dir_at(base, CRATE_NAME).join(TOKEN_FILE)
}
pub fn token_file_path() -> Option<PathBuf> {
trusty_common::crate_config::crate_config_dir(CRATE_NAME).map(|dir| dir.join(TOKEN_FILE))
}
#[cfg(unix)]
fn secure_write(path: &Path, data: &[u8]) -> std::io::Result<()> {
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let mut f = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(path)?;
f.write_all(data)
}
#[cfg(not(unix))]
fn secure_write(path: &Path, data: &[u8]) -> std::io::Result<()> {
std::fs::write(path, data)
}
pub fn set_token_at(path: &Path, token: &str) -> anyhow::Result<()> {
let trimmed = token.trim();
if trimmed.is_empty() {
anyhow::bail!("token must not be empty");
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
secure_write(path, trimmed.as_bytes())
.map_err(|e| anyhow::anyhow!("failed to write token file {}: {e}", path.display()))
}
pub fn set_token(token: &str) -> anyhow::Result<PathBuf> {
let path =
token_file_path().ok_or_else(|| anyhow::anyhow!("could not resolve home directory"))?;
set_token_at(&path, token)?;
Ok(path)
}
pub fn clear_token_at(path: &Path) -> anyhow::Result<()> {
match std::fs::remove_file(path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(anyhow::anyhow!(
"failed to remove token file {}: {e}",
path.display()
)),
}
}
pub fn clear_token() -> anyhow::Result<PathBuf> {
let path =
token_file_path().ok_or_else(|| anyhow::anyhow!("could not resolve home directory"))?;
clear_token_at(&path)?;
Ok(path)
}
pub fn load_token_at(path: &Path) -> Option<String> {
let raw = std::fs::read_to_string(path).ok()?;
let trimmed = raw.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
pub fn load_token() -> Option<String> {
token_file_path().and_then(|p| load_token_at(&p))
}
pub fn resolve_oauth_token() -> Option<String> {
if let Ok(v) = std::env::var(OAUTH_TOKEN_ENV_VAR) {
let trimmed = v.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_string());
}
}
load_token()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TokenStatus {
pub stored_token_present: bool,
pub token_path: Option<PathBuf>,
pub env_var_set: bool,
pub anthropic_api_key_set: bool,
pub credentials_file_present: bool,
pub credentials_path: Option<PathBuf>,
}
fn build_status(
stored_token_present: bool,
token_path: Option<PathBuf>,
env_var_set: bool,
anthropic_api_key_set: bool,
credentials_file_present: bool,
credentials_path: Option<PathBuf>,
) -> TokenStatus {
TokenStatus {
stored_token_present,
token_path,
env_var_set,
anthropic_api_key_set,
credentials_file_present,
credentials_path,
}
}
pub fn compute_status() -> TokenStatus {
let token_path = token_file_path();
let stored_token_present = token_path.as_deref().and_then(load_token_at).is_some();
let env_var_set = std::env::var(OAUTH_TOKEN_ENV_VAR).is_ok_and(|v| !v.trim().is_empty());
let anthropic_api_key_set =
std::env::var("ANTHROPIC_API_KEY").is_ok_and(|v| !v.trim().is_empty());
let credentials_path = dirs::home_dir().map(|h| h.join(".claude").join(".credentials.json"));
let credentials_file_present = credentials_path.as_deref().is_some_and(Path::is_file);
build_status(
stored_token_present,
token_path,
env_var_set,
anthropic_api_key_set,
credentials_file_present,
credentials_path,
)
}
#[cfg(test)]
mod tests {
use super::*;
struct HomeGuard {
prev: Option<String>,
_tmp: tempfile::TempDir,
}
impl HomeGuard {
fn set() -> Self {
let tmp = tempfile::tempdir().expect("tempdir");
let prev = std::env::var("HOME").ok();
unsafe { std::env::set_var("HOME", tmp.path()) };
Self { prev, _tmp: tmp }
}
}
impl Drop for HomeGuard {
fn drop(&mut self) {
match self.prev {
Some(ref p) => unsafe { std::env::set_var("HOME", p) },
None => unsafe { std::env::remove_var("HOME") },
}
}
}
#[test]
fn token_file_path_layout() {
let p = token_file_path_at(Path::new("/home/bob"));
assert_eq!(
p,
PathBuf::from("/home/bob/.trusty-tools/trusty-mpm/claude-code-oauth.token")
);
}
#[test]
fn set_then_load_token_round_trips() {
let tmp = tempfile::tempdir().unwrap();
let path = token_file_path_at(tmp.path());
set_token_at(&path, " sk-ant-oat01-fake-token ").unwrap();
let loaded = load_token_at(&path).expect("token present");
assert_eq!(loaded, "sk-ant-oat01-fake-token", "must be trimmed");
}
#[test]
fn set_token_rejects_empty() {
let tmp = tempfile::tempdir().unwrap();
let path = token_file_path_at(tmp.path());
assert!(set_token_at(&path, " ").is_err());
assert!(!path.exists(), "no file must be written on rejection");
}
#[cfg(unix)]
#[test]
fn token_file_is_mode_0600() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let path = token_file_path_at(tmp.path());
set_token_at(&path, "sk-ant-oat01-fake-token").unwrap();
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "token file must be 0600, got {mode:o}");
}
#[test]
fn clear_token_removes_file() {
let tmp = tempfile::tempdir().unwrap();
let path = token_file_path_at(tmp.path());
set_token_at(&path, "sk-ant-oat01-fake-token").unwrap();
assert!(path.exists());
clear_token_at(&path).unwrap();
assert!(!path.exists());
}
#[test]
fn clear_token_is_noop_when_absent() {
let tmp = tempfile::tempdir().unwrap();
let path = token_file_path_at(tmp.path());
assert!(!path.exists());
assert!(clear_token_at(&path).is_ok(), "must not error when absent");
}
#[test]
fn load_token_none_when_absent() {
let tmp = tempfile::tempdir().unwrap();
let path = token_file_path_at(tmp.path());
assert_eq!(load_token_at(&path), None);
}
#[test]
fn load_token_none_when_blank() {
let tmp = tempfile::tempdir().unwrap();
let path = token_file_path_at(tmp.path());
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, " \n").unwrap();
assert_eq!(load_token_at(&path), None);
}
#[test]
fn compute_status_never_carries_the_token_value() {
let status = build_status(
true,
Some(PathBuf::from(
"/home/bob/.trusty-tools/trusty-mpm/claude-code-oauth.token",
)),
true,
false,
true,
Some(PathBuf::from("/home/bob/.claude/.credentials.json")),
);
let rendered = format!("{status:?}");
assert!(
!rendered.contains("sk-ant-oat01-fake-token"),
"status debug output must never contain a token value: {rendered}"
);
}
#[serial_test::serial]
#[test]
fn resolve_oauth_token_prefers_env_over_stored_file() {
let _home = HomeGuard::set();
unsafe { std::env::remove_var(OAUTH_TOKEN_ENV_VAR) };
set_token("stored-token").expect("store under redirected HOME");
assert_eq!(
resolve_oauth_token().as_deref(),
Some("stored-token"),
"with no env var set, the stored file must be used"
);
unsafe { std::env::set_var(OAUTH_TOKEN_ENV_VAR, "env-token") };
let resolved = resolve_oauth_token();
unsafe { std::env::remove_var(OAUTH_TOKEN_ENV_VAR) };
assert_eq!(
resolved.as_deref(),
Some("env-token"),
"an already-set process env var must win over the stored file"
);
}
#[serial_test::serial]
#[test]
fn resolve_oauth_token_falls_back_to_stored_file() {
let _home = HomeGuard::set();
unsafe { std::env::remove_var(OAUTH_TOKEN_ENV_VAR) };
assert_eq!(
resolve_oauth_token(),
None,
"no env var, no stored file → None"
);
set_token("stored-only-token").expect("store under redirected HOME");
assert_eq!(resolve_oauth_token().as_deref(), Some("stored-only-token"));
}
#[serial_test::serial]
#[test]
fn resolve_oauth_token_none_when_neither_present() {
let _home = HomeGuard::set();
unsafe { std::env::remove_var(OAUTH_TOKEN_ENV_VAR) };
assert_eq!(resolve_oauth_token(), None);
}
}