use crate::persistence::{
CrossProcessFileLock, atomic_write_with_permissions, in_process_file_lock,
};
use base64::Engine;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::{collections::BTreeMap, env, fmt, fs, io::Read, path::Path};
use super::{CustomProviderConfig, McPaths};
use anyhow::Context;
const OAUTH_REFRESH_SKEW_SECS: i64 = 300;
#[derive(Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct Auth {
#[serde(default)]
pub api_key: Option<String>,
#[serde(flatten)]
pub providers: BTreeMap<String, AuthProviderRecord>,
}
impl fmt::Debug for Auth {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Auth")
.field("api_key", &self.api_key.as_ref().map(|_| "<redacted>"))
.field("providers", &self.providers)
.finish()
}
}
#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "type")]
pub enum AuthProviderRecord {
#[serde(rename = "api_key")]
ApiKey { key: String },
#[serde(rename = "oauth")]
OAuth {
access: String,
#[serde(default)]
refresh: Option<String>,
#[serde(default)]
expires: Option<i64>,
#[serde(default, rename = "accountId")]
account_id: Option<String>,
},
}
impl fmt::Debug for AuthProviderRecord {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::ApiKey { .. } => f
.debug_struct("ApiKey")
.field("key", &"<redacted>")
.finish(),
Self::OAuth {
refresh, expires, ..
} => f
.debug_struct("OAuth")
.field("access", &"<redacted>")
.field("refresh", &refresh.as_ref().map(|_| "<redacted>"))
.field("expires", expires)
.field("account_id", &"<redacted>")
.finish(),
}
}
}
#[derive(Clone, PartialEq, Eq)]
pub enum ProviderCredential {
ApiKey {
key: String,
},
OAuth {
access: String,
account_id: Option<String>,
},
NoAuth,
}
impl fmt::Debug for ProviderCredential {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::ApiKey { .. } => f
.debug_struct("ApiKey")
.field("key", &"<redacted>")
.finish(),
Self::OAuth { account_id, .. } => f
.debug_struct("OAuth")
.field("access", &"<redacted>")
.field("account_id", &account_id.as_ref().map(|_| "<redacted>"))
.finish(),
Self::NoAuth => f.debug_struct("NoAuth").finish(),
}
}
}
impl ProviderCredential {
pub fn is_configured(&self) -> bool {
match self {
Self::ApiKey { key } => !key.is_empty(),
Self::OAuth { access, .. } => !access.is_empty(),
Self::NoAuth => true,
}
}
pub fn is_supported_for_provider(&self, provider: &str) -> bool {
matches!(
(provider, self),
(crate::providers::OPENAI_CODEX_PROVIDER, Self::OAuth { .. })
| (crate::providers::ANTHROPIC_PROVIDER, Self::ApiKey { .. })
| (crate::providers::CLAUDE_CODE_PROVIDER, Self::ApiKey { .. })
| (crate::providers::CLAUDE_CODE_PROVIDER, Self::OAuth { .. })
)
}
}
fn is_auth_ready_for_provider(provider: &str, credential: &ProviderCredential) -> bool {
credential.is_configured() && credential.is_supported_for_provider(provider)
}
#[derive(Clone, PartialEq, Eq)]
pub enum AuthState {
Ready {
provider: String,
credential: ProviderCredential,
},
Missing {
provider: String,
},
}
impl fmt::Debug for AuthState {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Ready {
provider,
credential,
} => f
.debug_struct("Ready")
.field("provider", provider)
.field("credential", credential)
.finish(),
Self::Missing { provider } => f
.debug_struct("Missing")
.field("provider", provider)
.finish(),
}
}
}
impl AuthState {
pub fn for_provider(provider: impl Into<String>, auth: Option<&ProviderCredential>) -> Self {
Self::for_provider_with_custom(provider, auth, &BTreeMap::new())
}
pub fn for_provider_with_custom(
provider: impl Into<String>,
auth: Option<&ProviderCredential>,
custom_providers: &BTreeMap<String, CustomProviderConfig>,
) -> Self {
let provider = provider.into();
match auth {
Some(credential)
if (is_auth_ready_for_provider(&provider, credential)
|| (custom_providers.contains_key(&provider)
&& matches!(
credential,
ProviderCredential::ApiKey { .. } | ProviderCredential::NoAuth
)
&& credential.is_configured())) =>
{
Self::Ready {
provider,
credential: credential.clone(),
}
}
_ => Self::Missing { provider },
}
}
pub fn is_ready(&self) -> bool {
matches!(self, Self::Ready { .. })
}
pub fn provider(&self) -> &str {
match self {
Self::Ready { provider, .. } | Self::Missing { provider } => provider,
}
}
pub fn credential(&self) -> Option<&ProviderCredential> {
match self {
Self::Ready { credential, .. } => Some(credential),
Self::Missing { .. } => None,
}
}
}
pub fn resolve_provider_credential(
provider: &str,
auth: &Auth,
cli_api_key: Option<String>,
custom_providers: &BTreeMap<String, CustomProviderConfig>,
) -> anyhow::Result<Option<ProviderCredential>> {
if provider == crate::providers::CLAUDE_CODE_PROVIDER {
return crate::providers::claude_code::auth::ClaudeCodeAuth::readiness_credential(auth);
}
if let Some(custom) = custom_providers.get(provider) {
return Ok(match &custom.api_key_env_var {
Some(env_var) => env::var(env_var)
.ok()
.filter(|key| !key.is_empty())
.map(|key| ProviderCredential::ApiKey { key }),
None => Some(ProviderCredential::NoAuth),
});
}
if provider == crate::providers::ANTHROPIC_PROVIDER {
if let Ok(key) = env::var("ANTHROPIC_API_KEY")
&& !key.is_empty()
{
return Ok(Some(ProviderCredential::ApiKey { key }));
}
if let Some(record) = auth.providers.get(provider) {
return Ok(match record {
AuthProviderRecord::ApiKey { key } => {
Some(ProviderCredential::ApiKey { key: key.clone() })
}
AuthProviderRecord::OAuth { .. } => None,
});
}
return Ok(None);
}
if provider != crate::providers::OPENAI_CODEX_PROVIDER {
if let Some(key) = cli_api_key.filter(|key| !key.is_empty()) {
return Ok(Some(ProviderCredential::ApiKey { key }));
}
if let Ok(key) = env::var("MC_API_KEY")
&& !key.is_empty()
{
return Ok(Some(ProviderCredential::ApiKey { key }));
}
}
if provider == "openai" {
return Ok(None);
}
if let Some(record) = auth.providers.get(provider) {
return Ok(match record {
AuthProviderRecord::ApiKey { key } => {
Some(ProviderCredential::ApiKey { key: key.clone() })
}
AuthProviderRecord::OAuth {
access,
refresh,
expires,
account_id,
} => {
if provider == crate::providers::OPENAI_CODEX_PROVIDER
&& oauth_requires_refresh(*expires)
&& refresh.as_ref().is_none_or(|value| value.is_empty())
{
None
} else {
Some(ProviderCredential::OAuth {
access: access.clone(),
account_id: account_id.clone(),
})
}
}
});
}
Ok(None)
}
fn oauth_requires_refresh(expires: Option<i64>) -> bool {
expires
.is_none_or(|expires| expires <= chrono::Utc::now().timestamp() + OAUTH_REFRESH_SKEW_SECS)
}
pub(crate) fn extract_chatgpt_account_id_from_jwt(access_token: &str) -> anyhow::Result<String> {
let value = jwt_payload_json(access_token)?;
standard_chatgpt_account_id_claim(&value)
.filter(|id| !id.is_empty())
.map(ToString::to_string)
.ok_or_else(|| anyhow::anyhow!("Codex access token is missing ChatGPT account id claim"))
}
pub(crate) fn extract_oauth_account_id_from_jwt(access_token: &str) -> Option<String> {
let value = jwt_payload_json(access_token).ok()?;
standard_chatgpt_account_id_claim(&value)
.or_else(|| value.get("chatgpt_account_id").and_then(Value::as_str))
.or_else(|| value.get("accountId").and_then(Value::as_str))
.filter(|id| !id.is_empty())
.map(ToString::to_string)
}
fn jwt_payload_json(access_token: &str) -> anyhow::Result<Value> {
let payload = access_token
.split('.')
.nth(1)
.ok_or_else(|| anyhow::anyhow!("Codex access token is not a JWT"))?;
let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(payload)?;
Ok(serde_json::from_slice(&decoded)?)
}
fn standard_chatgpt_account_id_claim(value: &Value) -> Option<&str> {
value
.get("https://api.openai.com/auth.chatgpt_account_id")
.and_then(Value::as_str)
.or_else(|| {
value
.get("https://api.openai.com/auth")
.and_then(|auth| auth.get("chatgpt_account_id"))
.and_then(Value::as_str)
})
}
pub fn read_auth(paths: &McPaths) -> anyhow::Result<Auth> {
let auth_lock = auth_file_lock(&paths.auth_file)?;
let _auth_guard = auth_lock
.lock()
.map_err(|_| anyhow::anyhow!("auth lock was poisoned"))?;
let _file_guard = CrossProcessFileLock::acquire(&paths.auth_file)?;
read_auth_unlocked(paths)
}
fn read_auth_unlocked(paths: &McPaths) -> anyhow::Result<Auth> {
read_auth_file(&paths.auth_file).map(|auth| auth.unwrap_or_default())
}
fn read_auth_file(path: &Path) -> anyhow::Result<Option<Auth>> {
validate_auth_file_path_before_open(path)?;
let mut options = fs::OpenOptions::new();
options.read(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.custom_flags(o_no_follow());
}
let mut file = match options.open(path) {
Ok(file) => file,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
#[cfg(unix)]
Err(error) if path_is_symlink(path) => {
let _ = error;
anyhow::bail!(
"auth.json must be a regular private file; symlinked auth files are not allowed"
);
}
Err(error) => {
return Err(error).with_context(|| format!("failed to read {}", path.display()));
}
};
validate_open_auth_file(&file)?;
let mut text = String::new();
file.read_to_string(&mut text)
.with_context(|| format!("failed to read {}", path.display()))?;
Ok(Some(serde_json::from_str(&text)?))
}
fn validate_open_auth_file(file: &fs::File) -> anyhow::Result<()> {
let metadata = file.metadata()?;
if !metadata.is_file() {
anyhow::bail!("auth.json must be a regular private file");
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if metadata.permissions().mode() & 0o077 != 0 {
anyhow::bail!("auth.json permissions must be private/owner-only (0600 or stricter)");
}
}
Ok(())
}
#[cfg(windows)]
fn validate_auth_file_path_before_open(path: &Path) -> anyhow::Result<()> {
use std::os::windows::fs::MetadataExt;
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400;
match fs::symlink_metadata(path) {
Ok(metadata) if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 => {
anyhow::bail!(
"auth.json must be a regular private file; reparse-point auth files are not allowed"
);
}
Ok(_) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(error).with_context(|| format!("failed to read {}", path.display())),
}
}
#[cfg(not(windows))]
fn validate_auth_file_path_before_open(_path: &Path) -> anyhow::Result<()> {
Ok(())
}
#[cfg(unix)]
fn path_is_symlink(path: &Path) -> bool {
fs::symlink_metadata(path)
.map(|metadata| metadata.file_type().is_symlink())
.unwrap_or(false)
}
#[cfg(all(unix, target_os = "linux"))]
fn o_no_follow() -> i32 {
0x20000
}
#[cfg(all(unix, not(target_os = "linux")))]
fn o_no_follow() -> i32 {
0x100
}
#[cfg_attr(not(test), allow(dead_code))]
pub(crate) fn write_auth(paths: &McPaths, auth: &Auth) -> anyhow::Result<()> {
fs::create_dir_all(&paths.root)?;
let auth_lock = auth_file_lock(&paths.auth_file)?;
let _auth_guard = auth_lock
.lock()
.map_err(|_| anyhow::anyhow!("auth lock was poisoned"))?;
let _file_guard = CrossProcessFileLock::acquire(&paths.auth_file)?;
write_auth_unlocked(paths, auth)
}
fn write_auth_unlocked(paths: &McPaths, auth: &Auth) -> anyhow::Result<()> {
fs::create_dir_all(&paths.root)?;
atomic_write_with_permissions(
&paths.auth_file,
serde_json::to_string_pretty(auth)?.as_bytes(),
Some(0o600),
)?;
Ok(())
}
pub(crate) fn update_auth(paths: &McPaths, mutate: impl FnOnce(&mut Auth)) -> anyhow::Result<Auth> {
fs::create_dir_all(&paths.root)?;
let auth_lock = auth_file_lock(&paths.auth_file)?;
let _auth_guard = auth_lock
.lock()
.map_err(|_| anyhow::anyhow!("auth lock was poisoned"))?;
let _file_guard = CrossProcessFileLock::acquire(&paths.auth_file)?;
let mut auth = read_auth_unlocked(paths)?;
mutate(&mut auth);
write_auth_unlocked(paths, &auth)?;
Ok(auth)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LogoutAuthRemoval {
pub provider_id: String,
pub removed: bool,
pub auth: Auth,
}
pub fn remove_provider_auth(
paths: &McPaths,
provider_id: &str,
) -> anyhow::Result<LogoutAuthRemoval> {
let auth_lock = auth_file_lock(&paths.auth_file)?;
let _auth_guard = auth_lock
.lock()
.map_err(|_| anyhow::anyhow!("auth lock was poisoned"))?;
let _file_guard = CrossProcessFileLock::acquire(&paths.auth_file)?;
let mut auth = read_auth_unlocked(paths)?;
let removed = auth.providers.remove(provider_id).is_some();
if removed {
write_auth_unlocked(paths, &auth)?;
}
Ok(LogoutAuthRemoval {
provider_id: provider_id.to_string(),
removed,
auth,
})
}
fn auth_file_lock(path: &Path) -> anyhow::Result<std::sync::Arc<std::sync::Mutex<()>>> {
in_process_file_lock(path, "auth")
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
fn set_mode(path: &std::path::Path, mode: u32) {
use std::os::unix::fs::PermissionsExt;
let mut permissions = fs::metadata(path).unwrap().permissions();
permissions.set_mode(mode);
fs::set_permissions(path, permissions).unwrap();
}
#[test]
fn concurrent_auth_updates_preserve_independent_providers() {
let temp = tempfile::TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
let left = paths.clone();
let right = paths.clone();
let left = std::thread::spawn(move || {
update_auth(&left, |auth| {
auth.providers.insert(
"provider-a".to_string(),
AuthProviderRecord::ApiKey {
key: "key-a".to_string(),
},
);
})
.unwrap();
});
let right = std::thread::spawn(move || {
update_auth(&right, |auth| {
auth.providers.insert(
"provider-b".to_string(),
AuthProviderRecord::ApiKey {
key: "key-b".to_string(),
},
);
})
.unwrap();
});
left.join().unwrap();
right.join().unwrap();
let auth = read_auth(&paths).unwrap();
assert!(auth.providers.contains_key("provider-a"));
assert!(auth.providers.contains_key("provider-b"));
}
#[cfg(unix)]
#[test]
fn read_auth_rejects_symlink_with_no_follow_open_on_unix() {
use std::os::unix::fs::symlink;
let temp = tempfile::TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
fs::create_dir_all(&paths.root).unwrap();
let target = temp.path().join("target-auth.json");
fs::write(&target, r#"{"api_key":"target-secret"}"#).unwrap();
set_mode(&target, 0o600);
symlink(&target, &paths.auth_file).unwrap();
let error = read_auth(&paths).unwrap_err().to_string();
assert!(error.contains("auth.json"), "{error}");
assert!(
error.contains("symlink") || error.contains("regular private file"),
"{error}"
);
assert!(!error.contains("target-secret"), "{error}");
}
#[cfg(windows)]
#[test]
fn read_auth_rejects_reparse_point_on_windows() {
use std::os::windows::fs::symlink_file;
let temp = tempfile::TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
fs::create_dir_all(&paths.root).unwrap();
let target = temp.path().join("target-auth.json");
fs::write(&target, r#"{"api_key":"target-secret"}"#).unwrap();
if let Err(error) = symlink_file(&target, &paths.auth_file) {
if error.kind() == std::io::ErrorKind::PermissionDenied {
return;
}
panic!("symlink_file failed: {error}");
}
let error = read_auth(&paths).unwrap_err().to_string();
assert!(
error.contains("reparse") || error.contains("regular private file"),
"{error}"
);
assert!(!error.contains("target-secret"), "{error}");
}
#[test]
fn claude_code_auth_state_accepts_oauth_and_api_key_but_not_no_auth() {
assert!(
AuthState::for_provider(
crate::providers::CLAUDE_CODE_PROVIDER,
Some(&ProviderCredential::OAuth {
access: "cc-access".to_string(),
account_id: None,
}),
)
.is_ready()
);
assert!(
AuthState::for_provider(
crate::providers::CLAUDE_CODE_PROVIDER,
Some(&ProviderCredential::ApiKey {
key: "sk-ant-api-test".to_string(),
}),
)
.is_ready()
);
assert!(
!AuthState::for_provider(
crate::providers::CLAUDE_CODE_PROVIDER,
Some(&ProviderCredential::NoAuth),
)
.is_ready()
);
}
#[test]
fn claude_code_resolve_uses_provider_keyed_api_key_fallback_only() {
let env = crate::test_support::env::env_lock();
let _saved = [
env.save("MC_API_KEY"),
env.save("OPENAI_API_KEY"),
env.save("ANTHROPIC_API_KEY"),
env.save("MC_CLAUDE_CODE_CREDENTIALS_PATH"),
];
let temp = tempfile::TempDir::new().unwrap();
env.set_var(
"MC_CLAUDE_CODE_CREDENTIALS_PATH",
temp.path().join("missing-claude-credentials.json"),
);
env.set_var("MC_API_KEY", "mc-key");
env.set_var("OPENAI_API_KEY", "openai-key");
env.set_var("ANTHROPIC_API_KEY", "anthropic-key");
let auth = Auth {
providers: BTreeMap::from([(
crate::providers::CLAUDE_CODE_PROVIDER.to_string(),
AuthProviderRecord::ApiKey {
key: "provider-key".to_string(),
},
)]),
..Auth::default()
};
let credential = resolve_provider_credential(
crate::providers::CLAUDE_CODE_PROVIDER,
&auth,
Some("cli-key".to_string()),
&BTreeMap::new(),
)
.unwrap()
.unwrap();
assert_eq!(
credential,
ProviderCredential::ApiKey {
key: "provider-key".to_string()
}
);
}
}