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;
const MAX_AUTH_FILE_BYTES: usize = 1024 * 1024;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum CredentialReadiness {
Missing,
Ready,
Refreshable,
Invalid,
}
impl CredentialReadiness {
pub(crate) fn is_ready(self) -> bool {
matches!(self, Self::Ready | Self::Refreshable)
}
}
pub(crate) fn classify_codex_oauth_record(
access: &str,
refresh: Option<&str>,
expires: Option<i64>,
account_id: Option<&str>,
now: i64,
) -> CredentialReadiness {
let has_refresh_token = refresh.is_some_and(|value| !value.is_empty());
if !access.is_empty()
&& account_id.is_some_and(|id| !id.is_empty())
&& !codex_oauth_requires_refresh(expires, now)
{
return CredentialReadiness::Ready;
}
if has_refresh_token {
CredentialReadiness::Refreshable
} else {
CredentialReadiness::Invalid
}
}
pub(crate) fn classify_provider_auth_record(
provider: &str,
record: Option<&AuthProviderRecord>,
now: i64,
) -> CredentialReadiness {
let Some(record) = record else {
return CredentialReadiness::Missing;
};
match (provider, record) {
(
crate::providers::OPENAI_CODEX_PROVIDER,
AuthProviderRecord::OAuth {
access,
refresh,
expires,
account_id,
},
) => classify_codex_oauth_record(
access,
refresh.as_deref(),
*expires,
account_id.as_deref(),
now,
),
(crate::providers::ANTHROPIC_PROVIDER, AuthProviderRecord::ApiKey { key })
if !key.is_empty() =>
{
CredentialReadiness::Ready
}
_ => CredentialReadiness::Invalid,
}
}
pub(crate) fn custom_provider_auth_readiness(custom: &CustomProviderConfig) -> CredentialReadiness {
match custom.api_key_env_var.as_deref() {
Some(env_var)
if env::var(env_var)
.ok()
.is_some_and(|value| !value.is_empty()) =>
{
CredentialReadiness::Ready
}
Some(_) => CredentialReadiness::Missing,
None => CredentialReadiness::Ready,
}
}
pub(crate) fn current_provider_auth_readiness(
provider: &str,
auth: &Auth,
custom_providers: &BTreeMap<String, CustomProviderConfig>,
) -> CredentialReadiness {
if let Some(custom) = custom_providers.get(provider) {
return custom_provider_auth_readiness(custom);
}
if provider == crate::providers::ANTHROPIC_PROVIDER
&& env::var("ANTHROPIC_API_KEY")
.ok()
.is_some_and(|value| !value.is_empty())
{
return CredentialReadiness::Ready;
}
if provider != crate::providers::OPENAI_CODEX_PROVIDER
&& provider != crate::providers::ANTHROPIC_PROVIDER
&& env::var("MC_API_KEY")
.ok()
.is_some_and(|value| !value.is_empty())
{
return CredentialReadiness::Ready;
}
if provider == "openai" {
return CredentialReadiness::Missing;
}
classify_provider_auth_record(
provider,
auth.providers.get(provider),
chrono::Utc::now().timestamp(),
)
}
pub(crate) fn codex_oauth_requires_refresh(expires: Option<i64>, now: i64) -> bool {
expires.is_none_or(|expires| expires <= now + OAUTH_REFRESH_SKEW_SECS)
}
#[derive(Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub(crate) struct Auth {
#[serde(default)]
pub(crate) api_key: Option<String>,
#[serde(flatten)]
pub(crate) 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, Default, PartialEq, Eq)]
pub(crate) struct AuthStore {
auth: Auth,
revision: u64,
provider_generations: BTreeMap<String, u64>,
}
#[derive(Serialize, Deserialize)]
struct AuthStoreWire {
#[serde(default)]
api_key: Option<String>,
#[serde(default)]
revision: u64,
#[serde(default)]
provider_generations: BTreeMap<String, u64>,
#[serde(flatten)]
providers: BTreeMap<String, AuthProviderRecord>,
}
impl AuthStore {
fn from_wire(wire: AuthStoreWire) -> Self {
Self {
auth: Auth {
api_key: wire.api_key,
providers: wire.providers,
},
revision: wire.revision,
provider_generations: wire.provider_generations,
}
}
fn to_wire(&self) -> AuthStoreWire {
AuthStoreWire {
api_key: self.auth.api_key.clone(),
revision: self.revision,
provider_generations: self.provider_generations.clone(),
providers: self.auth.providers.clone(),
}
}
pub(crate) fn auth(&self) -> &Auth {
&self.auth
}
#[cfg(test)]
pub(crate) fn revision(&self) -> u64 {
self.revision
}
pub(crate) fn provider_generation(&self, provider_id: &str) -> u64 {
self.provider_generations
.get(provider_id)
.copied()
.unwrap_or_default()
}
}
#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "type")]
pub(crate) 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(crate) 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(crate) fn readiness_for_provider(
&self,
provider: &str,
custom_providers: &BTreeMap<String, CustomProviderConfig>,
) -> CredentialReadiness {
match (provider, self) {
(crate::providers::OPENAI_CODEX_PROVIDER, Self::OAuth { access, account_id })
if !access.is_empty() && account_id.as_ref().is_some_and(|id| !id.is_empty()) =>
{
CredentialReadiness::Ready
}
(crate::providers::ANTHROPIC_PROVIDER, Self::ApiKey { key }) if !key.is_empty() => {
CredentialReadiness::Ready
}
(provider, Self::ApiKey { key })
if custom_providers.contains_key(provider) && !key.is_empty() =>
{
CredentialReadiness::Ready
}
(provider, Self::NoAuth) if custom_providers.contains_key(provider) => {
CredentialReadiness::Ready
}
_ => CredentialReadiness::Invalid,
}
}
}
#[derive(Clone, PartialEq, Eq)]
pub(crate) struct ResolvedAuth {
pub(crate) readiness: CredentialReadiness,
pub(crate) credential: Option<ProviderCredential>,
}
#[derive(Clone, PartialEq, Eq)]
pub(crate) 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(crate) fn for_provider(
provider: impl Into<String>,
auth: Option<&ProviderCredential>,
) -> Self {
Self::for_provider_with_custom(provider, auth, &BTreeMap::new())
}
pub(crate) 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 credential
.readiness_for_provider(&provider, custom_providers)
.is_ready() =>
{
Self::Ready {
provider,
credential: credential.clone(),
}
}
_ => Self::Missing { provider },
}
}
pub(crate) fn is_ready(&self) -> bool {
matches!(self, Self::Ready { .. })
}
pub(crate) fn provider(&self) -> &str {
match self {
Self::Ready { provider, .. } | Self::Missing { provider } => provider,
}
}
pub(crate) fn credential(&self) -> Option<&ProviderCredential> {
match self {
Self::Ready { credential, .. } => Some(credential),
Self::Missing { .. } => None,
}
}
}
pub(crate) fn resolve_provider_credential(
provider: &str,
auth: &Auth,
cli_api_key: Option<String>,
custom_providers: &BTreeMap<String, CustomProviderConfig>,
) -> anyhow::Result<Option<ProviderCredential>> {
let resolved =
resolve_provider_credential_with_readiness(provider, auth, cli_api_key, custom_providers)?;
if resolved.readiness == CredentialReadiness::Ready {
Ok(resolved.credential)
} else {
Ok(None)
}
}
pub(crate) fn resolve_provider_credential_with_readiness(
provider: &str,
auth: &Auth,
cli_api_key: Option<String>,
custom_providers: &BTreeMap<String, CustomProviderConfig>,
) -> anyhow::Result<ResolvedAuth> {
if let Some(custom) = custom_providers.get(provider) {
let readiness = custom_provider_auth_readiness(custom);
let credential = match readiness {
CredentialReadiness::Ready => 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),
},
CredentialReadiness::Missing
| CredentialReadiness::Invalid
| CredentialReadiness::Refreshable => None,
};
return Ok(ResolvedAuth {
readiness,
credential,
});
}
if provider == crate::providers::ANTHROPIC_PROVIDER
&& let Ok(key) = env::var("ANTHROPIC_API_KEY")
&& !key.is_empty()
{
return Ok(ResolvedAuth {
readiness: CredentialReadiness::Ready,
credential: Some(ProviderCredential::ApiKey { key }),
});
}
if provider != crate::providers::OPENAI_CODEX_PROVIDER
&& provider != crate::providers::ANTHROPIC_PROVIDER
{
if let Some(key) = cli_api_key.filter(|key| !key.is_empty()) {
return Ok(ResolvedAuth {
readiness: CredentialReadiness::Ready,
credential: Some(ProviderCredential::ApiKey { key }),
});
}
if let Ok(key) = env::var("MC_API_KEY")
&& !key.is_empty()
{
return Ok(ResolvedAuth {
readiness: CredentialReadiness::Ready,
credential: Some(ProviderCredential::ApiKey { key }),
});
}
}
if provider == "openai" {
return Ok(ResolvedAuth {
readiness: CredentialReadiness::Missing,
credential: None,
});
}
let Some(record) = auth.providers.get(provider) else {
return Ok(ResolvedAuth {
readiness: CredentialReadiness::Missing,
credential: None,
});
};
let readiness =
classify_provider_auth_record(provider, Some(record), chrono::Utc::now().timestamp());
let credential = match (readiness, record) {
(CredentialReadiness::Ready, AuthProviderRecord::ApiKey { key }) => {
Some(ProviderCredential::ApiKey { key: key.clone() })
}
(
CredentialReadiness::Ready,
AuthProviderRecord::OAuth {
access, account_id, ..
},
) => Some(ProviderCredential::OAuth {
access: access.clone(),
account_id: account_id.clone(),
}),
_ => None,
};
Ok(ResolvedAuth {
readiness,
credential,
})
}
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(crate) fn read_auth(paths: &McPaths) -> anyhow::Result<Auth> {
Ok(read_auth_store(paths)?.auth().clone())
}
pub(crate) fn read_auth_store(paths: &McPaths) -> anyhow::Result<AuthStore> {
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_store_unlocked(paths)
}
fn read_auth_store_unlocked(paths: &McPaths) -> anyhow::Result<AuthStore> {
read_auth_file(&paths.auth_file).map(|store| store.unwrap_or_default())
}
fn read_auth_file(path: &Path) -> anyhow::Result<Option<AuthStore>> {
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 file_bytes = file
.metadata()
.with_context(|| format!("failed to inspect {}", path.display()))?
.len();
if file_bytes > MAX_AUTH_FILE_BYTES as u64 {
anyhow::bail!(
"auth file exceeded {MAX_AUTH_FILE_BYTES} byte limit: {}",
path.display()
);
}
let mut bytes = Vec::new();
file.by_ref()
.take((MAX_AUTH_FILE_BYTES + 1) as u64)
.read_to_end(&mut bytes)
.with_context(|| format!("failed to read {}", path.display()))?;
if bytes.len() > MAX_AUTH_FILE_BYTES {
anyhow::bail!(
"auth file exceeded {MAX_AUTH_FILE_BYTES} byte limit: {}",
path.display()
);
}
Ok(Some(AuthStore::from_wire(serde_json::from_slice(&bytes)?)))
}
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(test)]
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)?;
let current = read_auth_store_unlocked(paths)?;
let mut next_store = AuthStore {
auth: auth.clone(),
revision: current.revision,
provider_generations: current.provider_generations,
};
increment_store_revision(&mut next_store)?;
write_auth_contents_unlocked(paths, &next_store)
}
fn write_auth_contents_unlocked(paths: &McPaths, store: &AuthStore) -> anyhow::Result<()> {
fs::create_dir_all(&paths.root)?;
atomic_write_with_permissions(
&paths.auth_file,
serde_json::to_string_pretty(&store.to_wire())?.as_bytes(),
Some(0o600),
)?;
Ok(())
}
fn increment_store_revision(store: &mut AuthStore) -> anyhow::Result<()> {
store.revision = store
.revision
.checked_add(1)
.ok_or_else(|| anyhow::anyhow!("auth revision overflow"))?;
Ok(())
}
fn increment_provider_generation(store: &mut AuthStore, provider_id: &str) -> anyhow::Result<()> {
let generation = store
.provider_generations
.entry(provider_id.to_string())
.or_default();
*generation = generation
.checked_add(1)
.ok_or_else(|| anyhow::anyhow!("auth generation overflow for provider '{provider_id}'"))?;
Ok(())
}
fn write_auth_mutation_unlocked(
paths: &McPaths,
store: &mut AuthStore,
provider_id: &str,
) -> anyhow::Result<()> {
increment_provider_generation(store, provider_id)?;
increment_store_revision(store)?;
write_auth_contents_unlocked(paths, store)
}
pub(crate) fn update_auth(
paths: &McPaths,
provider_id: &str,
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 store = read_auth_store_unlocked(paths)?;
let current_revision = store.revision;
let current_generation = store.provider_generation(provider_id);
mutate(&mut store.auth);
store.revision = current_revision;
store
.provider_generations
.insert(provider_id.to_string(), current_generation);
write_auth_mutation_unlocked(paths, &mut store, provider_id)?;
Ok(store.auth.clone())
}
pub(crate) enum ConditionalAuthUpdate {
Applied,
Current(AuthStore),
}
pub(crate) fn cancel_codex_login_before_commit(
paths: &McPaths,
cancel: &std::sync::atomic::AtomicBool,
) -> anyhow::Result<()> {
let auth_lock = auth_file_lock(&paths.auth_file)?;
let _guard = auth_lock
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
cancel.store(true, std::sync::atomic::Ordering::Relaxed);
Ok(())
}
pub(crate) fn persist_codex_login_if_current(
paths: &McPaths,
expected_generation: u64,
token: super::NormalizedToken,
cancel: &std::sync::atomic::AtomicBool,
) -> anyhow::Result<bool> {
fs::create_dir_all(&paths.root)?;
let auth_lock = auth_file_lock(&paths.auth_file)?;
let _guard = auth_lock
.lock()
.map_err(|_| anyhow::anyhow!("auth lock poisoned"))?;
let _file_guard = CrossProcessFileLock::acquire(&paths.auth_file)?;
let mut store = read_auth_store_unlocked(paths)?;
let provider = crate::providers::OPENAI_CODEX_PROVIDER;
if cancel.load(std::sync::atomic::Ordering::Relaxed)
|| store.provider_generation(provider) != expected_generation
{
return Ok(false);
}
store.auth.providers.insert(
provider.into(),
super::codex_auth::oauth_record_from_token(token, None),
);
write_auth_mutation_unlocked(paths, &mut store, provider)?;
Ok(true)
}
pub(crate) fn replace_provider_auth_if_matches(
paths: &McPaths,
provider_id: &str,
expected_generation: u64,
expected: &AuthProviderRecord,
replacement: AuthProviderRecord,
) -> anyhow::Result<ConditionalAuthUpdate> {
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 store = read_auth_store_unlocked(paths)?;
if store.provider_generation(provider_id) != expected_generation
|| store.auth.providers.get(provider_id) != Some(expected)
{
return Ok(ConditionalAuthUpdate::Current(store));
}
store
.auth
.providers
.insert(provider_id.to_string(), replacement);
write_auth_mutation_unlocked(paths, &mut store, provider_id)?;
Ok(ConditionalAuthUpdate::Applied)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct LogoutAuthRemoval {
pub(crate) provider_id: String,
pub(crate) removed: bool,
pub(crate) auth: Auth,
}
pub(crate) 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 store = read_auth_store_unlocked(paths)?;
let removed = store.auth.providers.remove(provider_id).is_some();
write_auth_mutation_unlocked(paths, &mut store, provider_id)?;
Ok(LogoutAuthRemoval {
provider_id: provider_id.to_string(),
removed,
auth: store.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 login_cancellation_waits_for_commit_lock_without_writing_credentials() {
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
};
use std::time::Duration;
let temp = tempfile::TempDir::new().unwrap();
let paths =
McPaths::from_root_and_project_dir(temp.path().join("state"), temp.path().to_owned());
paths.ensure_runtime_dirs().unwrap();
let lock = auth_file_lock(&paths.auth_file).unwrap();
let commit_guard = lock.lock().unwrap();
let canceled = Arc::new(AtomicBool::new(false));
let worker_flag = Arc::clone(&canceled);
let worker_paths = paths.clone();
let (entered, started) = std::sync::mpsc::sync_channel(1);
let (finished, done) = std::sync::mpsc::sync_channel(1);
let worker = std::thread::spawn(move || {
entered.send(()).unwrap();
cancel_codex_login_before_commit(&worker_paths, &worker_flag).unwrap();
finished.send(()).unwrap();
});
started.recv_timeout(Duration::from_secs(1)).unwrap();
let before_commit_release = done.recv_timeout(Duration::from_millis(25));
let flag_before_commit_release = canceled.load(Ordering::Relaxed);
drop(commit_guard);
worker.join().unwrap();
assert!(matches!(
before_commit_release,
Err(std::sync::mpsc::RecvTimeoutError::Timeout)
));
assert!(!flag_before_commit_release);
assert!(canceled.load(Ordering::Relaxed));
assert!(!paths.auth_file.exists());
}
#[test]
fn fresh_codex_login_never_inherits_previous_account_refresh_token() {
let provider = crate::providers::OPENAI_CODEX_PROVIDER;
for refresh in [None, Some("new-refresh".to_string())] {
let temp = tempfile::TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
update_auth(&paths, provider, |auth| {
auth.providers.insert(
provider.into(),
AuthProviderRecord::OAuth {
access: "old-access".into(),
refresh: Some("old-refresh".into()),
expires: Some(1),
account_id: Some("old-account".into()),
},
);
})
.unwrap();
let generation = read_auth_store(&paths)
.unwrap()
.provider_generation(provider);
let token = super::super::NormalizedToken {
access: "new-access".into(),
refresh: refresh.clone(),
expires: Some(chrono::Utc::now().timestamp() + 3600),
account_id: "new-account".into(),
};
let expires = token.expires;
assert!(
persist_codex_login_if_current(
&paths,
generation,
token,
&std::sync::atomic::AtomicBool::new(false),
)
.unwrap()
);
assert_eq!(
read_auth(&paths).unwrap().providers.get(provider),
Some(&AuthProviderRecord::OAuth {
access: "new-access".into(),
refresh,
expires,
account_id: Some("new-account".into()),
})
);
}
}
#[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, "provider-a", |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, "provider-b", |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"));
}
#[test]
fn update_auth_waits_for_cross_process_file_lock() {
let temp = tempfile::TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
let held_lock = CrossProcessFileLock::acquire(&paths.auth_file).expect("hold lock");
let (started_tx, started_rx) = std::sync::mpsc::sync_channel(0);
let (mutated_tx, mutated_rx) = std::sync::mpsc::channel();
let update_paths = paths.clone();
let updater = std::thread::spawn(move || {
started_tx.send(()).unwrap();
update_auth(&update_paths, "provider", |auth| {
mutated_tx.send(()).unwrap();
auth.providers.insert(
"provider".to_string(),
AuthProviderRecord::ApiKey {
key: "key".to_string(),
},
);
})
});
started_rx.recv().unwrap();
assert!(
mutated_rx
.recv_timeout(std::time::Duration::from_millis(100))
.is_err(),
"update ran while cross-process lock was held"
);
drop(held_lock);
updater.join().unwrap().unwrap();
assert!(
read_auth(&paths)
.unwrap()
.providers
.contains_key("provider")
);
}
#[test]
fn auth_mutations_advance_store_revision() {
let temp = tempfile::TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
update_auth(&paths, "provider-a", |auth| {
auth.providers.insert(
"provider-a".to_string(),
AuthProviderRecord::ApiKey {
key: "key-a".to_string(),
},
);
})
.unwrap();
let first = read_auth_store(&paths).unwrap();
assert_eq!(first.revision(), 1);
assert_eq!(first.provider_generation("provider-a"), 1);
assert_eq!(first.provider_generation("provider-b"), 0);
update_auth(&paths, "provider-b", |auth| {
auth.providers.insert(
"provider-b".to_string(),
AuthProviderRecord::ApiKey {
key: "key-b".to_string(),
},
);
})
.unwrap();
let second = read_auth_store(&paths).unwrap();
assert_eq!(second.revision(), 2);
assert_eq!(second.provider_generation("provider-a"), 1);
assert_eq!(second.provider_generation("provider-b"), 1);
let removal = remove_provider_auth(&paths, "provider-a").unwrap();
assert!(removal.removed);
let after_removal = read_auth_store(&paths).unwrap();
assert_eq!(after_removal.revision(), 3);
assert_eq!(after_removal.provider_generation("provider-a"), 2);
assert_eq!(after_removal.provider_generation("provider-b"), 1);
assert_eq!(read_auth_store(&paths).unwrap().revision(), 3);
}
#[test]
fn provider_generation_advances_for_exact_same_value_updates() {
let temp = tempfile::TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
let record = AuthProviderRecord::ApiKey {
key: "same-key".to_string(),
};
update_auth(&paths, "provider", |auth| {
auth.providers
.insert("provider".to_string(), record.clone());
})
.unwrap();
assert_eq!(
read_auth_store(&paths)
.unwrap()
.provider_generation("provider"),
1
);
update_auth(&paths, "provider", |auth| {
auth.providers
.insert("provider".to_string(), record.clone());
})
.unwrap();
let second = read_auth_store(&paths).unwrap();
assert_eq!(second.provider_generation("provider"), 2);
assert_eq!(second.auth().providers.get("provider"), Some(&record));
}
#[test]
fn legacy_auth_without_revision_defaults_and_mutation_starts_at_one() {
let temp = tempfile::TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
fs::create_dir_all(&paths.root).unwrap();
fs::write(
&paths.auth_file,
r#"{"provider-a":{"type":"api_key","key":"secret"}}"#,
)
.unwrap();
#[cfg(unix)]
set_mode(&paths.auth_file, 0o600);
let initial = read_auth_store(&paths).unwrap();
assert_eq!(initial.revision(), 0);
assert_eq!(initial.provider_generation("provider-a"), 0);
update_auth(&paths, "provider-b", |auth| {
auth.providers.insert(
"provider-b".to_string(),
AuthProviderRecord::ApiKey {
key: "key-b".to_string(),
},
);
})
.unwrap();
let updated = read_auth_store(&paths).unwrap();
assert_eq!(updated.revision(), 1);
assert_eq!(updated.provider_generation("provider-a"), 0);
assert_eq!(updated.provider_generation("provider-b"), 1);
let raw: Value =
serde_json::from_str(&fs::read_to_string(&paths.auth_file).unwrap()).unwrap();
assert_eq!(raw["revision"], 1);
assert_eq!(raw["provider_generations"]["provider-b"], 1);
}
#[test]
fn read_auth_rejects_oversized_private_file() {
let temp = tempfile::TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
fs::create_dir_all(&paths.root).unwrap();
fs::write(&paths.auth_file, vec![b' '; MAX_AUTH_FILE_BYTES + 1]).unwrap();
#[cfg(unix)]
set_mode(&paths.auth_file, 0o600);
let error = read_auth(&paths).unwrap_err().to_string();
assert!(error.contains("auth file exceeded"), "{error}");
assert!(
error.contains(&paths.auth_file.display().to_string()),
"{error}"
);
}
#[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}");
}
}