use super::McPaths;
use super::auth::{self, AuthProviderRecord, AuthState, CredentialReadiness, ProviderCredential};
use crate::http_body::{DEFAULT_BOUNDED_BODY_MAX_BYTES, read_bounded_response_text};
use crate::providers::OPENAI_CODEX_PROVIDER;
use anyhow::Context;
use chrono::Utc;
use serde::Deserialize;
use std::time::Duration;
pub(crate) const OPENAI_CODEX_CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
pub(crate) const OPENAI_CODEX_RELOGIN_GUIDANCE: &str =
"openai-codex OAuth credentials need re-login; run /login openai-codex";
pub(crate) const OPENAI_CODEX_REDIRECT_URI: &str = "http://localhost:1455/auth/callback";
const TOKEN_URL: &str = "https://auth.openai.com/oauth/token";
#[derive(Debug, Clone)]
pub(crate) struct NormalizedToken {
pub(crate) access: String,
pub(crate) refresh: Option<String>,
pub(crate) expires: Option<i64>,
pub(crate) account_id: String,
}
#[derive(Deserialize)]
struct TokenResponse {
access_token: Option<String>,
refresh_token: Option<String>,
expires_in: Option<i64>,
#[serde(rename = "accountId")]
account_id_camel: Option<String>,
account_id: Option<String>,
id_token: Option<String>,
}
pub(crate) fn codex_credential_from_store(paths: &McPaths) -> anyhow::Result<ProviderCredential> {
codex_credential_from_store_with_exchange(paths, refresh_codex_token)
}
pub(crate) fn codex_credential_from_store_with_exchange(
paths: &McPaths,
exchange: impl FnOnce(&str) -> anyhow::Result<NormalizedToken>,
) -> anyhow::Result<ProviderCredential> {
let stored = auth::read_auth_store(paths)?;
let expected_generation = stored.provider_generation(OPENAI_CODEX_PROVIDER);
let Some(source_record) = stored.auth().providers.get(OPENAI_CODEX_PROVIDER).cloned() else {
anyhow::bail!("missing OAuth auth for provider 'openai-codex'; run /login openai-codex")
};
let AuthProviderRecord::OAuth {
access,
refresh,
expires,
account_id,
} = &source_record
else {
anyhow::bail!("provider 'openai-codex' requires OAuth auth; run /login openai-codex")
};
let now = Utc::now().timestamp();
match auth::classify_codex_oauth_record(
access,
refresh.as_deref(),
*expires,
account_id.as_deref(),
now,
) {
CredentialReadiness::Ready => {
return Ok(ProviderCredential::OAuth {
access: access.clone(),
account_id: account_id.clone(),
});
}
CredentialReadiness::Refreshable => {}
CredentialReadiness::Missing | CredentialReadiness::Invalid => {
anyhow::bail!(OPENAI_CODEX_RELOGIN_GUIDANCE)
}
}
let refresh = refresh
.as_deref()
.filter(|value| !value.is_empty())
.ok_or_else(|| anyhow::anyhow!(OPENAI_CODEX_RELOGIN_GUIDANCE))?;
let token = match exchange(refresh) {
Ok(token) => token,
Err(error) => {
return current_ready_credential_after_failed_exchange(
paths,
expected_generation,
error,
);
}
};
complete_codex_refresh(paths, expected_generation, source_record, token)
}
pub(crate) fn force_refresh_codex_credential_from_store(
paths: &McPaths,
) -> anyhow::Result<ProviderCredential> {
force_refresh_codex_credential_from_store_with_exchange(paths, refresh_codex_token)
}
fn force_refresh_codex_credential_from_store_with_exchange(
paths: &McPaths,
exchange: impl FnOnce(&str) -> anyhow::Result<NormalizedToken>,
) -> anyhow::Result<ProviderCredential> {
let stored = auth::read_auth_store(paths)?;
let expected_generation = stored.provider_generation(OPENAI_CODEX_PROVIDER);
let Some(source_record) = stored.auth().providers.get(OPENAI_CODEX_PROVIDER).cloned() else {
anyhow::bail!(OPENAI_CODEX_RELOGIN_GUIDANCE)
};
let AuthProviderRecord::OAuth { refresh, .. } = &source_record else {
anyhow::bail!(OPENAI_CODEX_RELOGIN_GUIDANCE)
};
let refresh = refresh
.as_deref()
.filter(|value| !value.is_empty())
.ok_or_else(|| anyhow::anyhow!(OPENAI_CODEX_RELOGIN_GUIDANCE))?;
let token = match exchange(refresh) {
Ok(token) => token,
Err(error) => {
return current_ready_credential_after_failed_exchange(
paths,
expected_generation,
error,
);
}
};
complete_codex_refresh(paths, expected_generation, source_record, token)
}
fn complete_codex_refresh(
paths: &McPaths,
expected_generation: u64,
source_record: AuthProviderRecord,
token: NormalizedToken,
) -> anyhow::Result<ProviderCredential> {
let previous_refresh = match &source_record {
AuthProviderRecord::OAuth { refresh, .. } => refresh.clone(),
AuthProviderRecord::ApiKey { .. } => None,
};
let replacement = oauth_record_from_token(token.clone(), previous_refresh);
match auth::replace_provider_auth_if_matches(
paths,
OPENAI_CODEX_PROVIDER,
expected_generation,
&source_record,
replacement,
)? {
auth::ConditionalAuthUpdate::Applied => Ok(provider_credential_from_token(token)),
auth::ConditionalAuthUpdate::Current(current_store) => {
codex_credential_from_current_store(¤t_store)
}
}
}
fn current_ready_credential_after_failed_exchange(
paths: &McPaths,
expected_generation: u64,
exchange_error: anyhow::Error,
) -> anyhow::Result<ProviderCredential> {
let current_store = match auth::read_auth_store(paths) {
Ok(store) => store,
Err(_) => return Err(exchange_error),
};
if current_store.provider_generation(OPENAI_CODEX_PROVIDER) <= expected_generation {
return Err(exchange_error);
}
match codex_credential_from_current_store(¤t_store) {
Ok(credential) => Ok(credential),
Err(_) => Err(exchange_error),
}
}
fn codex_credential_from_current_store(
current_store: &auth::AuthStore,
) -> anyhow::Result<ProviderCredential> {
let Some(record) = current_store.auth().providers.get(OPENAI_CODEX_PROVIDER) else {
anyhow::bail!("missing OAuth auth for provider 'openai-codex'; run /login openai-codex")
};
let AuthProviderRecord::OAuth {
access,
refresh,
expires,
account_id,
} = record
else {
anyhow::bail!("provider 'openai-codex' requires OAuth auth; run /login openai-codex")
};
let readiness = auth::classify_codex_oauth_record(
access,
refresh.as_deref(),
*expires,
account_id.as_deref(),
Utc::now().timestamp(),
);
if readiness == CredentialReadiness::Ready {
return Ok(ProviderCredential::OAuth {
access: access.clone(),
account_id: account_id.clone(),
});
}
anyhow::bail!(
"Codex OAuth credentials changed during refresh and are not currently usable; {OPENAI_CODEX_RELOGIN_GUIDANCE}"
)
}
fn provider_credential_from_token(token: NormalizedToken) -> ProviderCredential {
ProviderCredential::OAuth {
access: token.access,
account_id: Some(token.account_id),
}
}
pub(super) fn oauth_record_from_token(
token: NormalizedToken,
previous_refresh: Option<String>,
) -> AuthProviderRecord {
AuthProviderRecord::OAuth {
access: token.access,
refresh: token.refresh.or(previous_refresh),
expires: token.expires,
account_id: Some(token.account_id),
}
}
pub(crate) fn refreshed_codex_auth_state(
paths: &McPaths,
current: &AuthState,
) -> anyhow::Result<AuthState> {
if current.provider() != OPENAI_CODEX_PROVIDER {
return Ok(current.clone());
}
let credential = codex_credential_from_store(paths)?;
Ok(AuthState::Ready {
provider: OPENAI_CODEX_PROVIDER.to_string(),
credential,
})
}
pub(crate) fn persist_codex_token(paths: &McPaths, token: NormalizedToken) -> anyhow::Result<()> {
auth::update_auth(paths, OPENAI_CODEX_PROVIDER, |auth| {
let previous_refresh = match auth.providers.get(OPENAI_CODEX_PROVIDER) {
Some(AuthProviderRecord::OAuth { refresh, .. }) => refresh.clone(),
_ => None,
};
auth.providers.insert(
OPENAI_CODEX_PROVIDER.to_string(),
oauth_record_from_token(token, previous_refresh),
);
})?;
Ok(())
}
pub(crate) fn exchange_codex_code(verifier: &str, code: &str) -> anyhow::Result<NormalizedToken> {
let body = [
("grant_type", "authorization_code"),
("client_id", OPENAI_CODEX_CLIENT_ID),
("redirect_uri", OPENAI_CODEX_REDIRECT_URI),
("code", code),
("code_verifier", verifier),
];
post_token_form(&body)
}
pub(crate) fn refresh_codex_token(refresh: &str) -> anyhow::Result<NormalizedToken> {
let body = [
("grant_type", "refresh_token"),
("client_id", OPENAI_CODEX_CLIENT_ID),
("refresh_token", refresh),
];
post_token_form(&body)
}
fn post_token_form(body: &[(&str, &str)]) -> anyhow::Result<NormalizedToken> {
let response = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(30))
.build()?
.post(TOKEN_URL)
.form(body)
.send()?;
let status = response.status();
if !status.is_success() {
anyhow::bail!("OpenAI Codex OAuth token exchange failed with status {status}")
}
let text = read_bounded_response_text(response, DEFAULT_BOUNDED_BODY_MAX_BYTES)
.with_context(|| "OpenAI Codex OAuth token response body read failed")?;
let response = serde_json::from_str::<TokenResponse>(&text)?;
normalize_token_response(response)
}
fn normalize_token_response(response: TokenResponse) -> anyhow::Result<NormalizedToken> {
let access = response
.access_token
.filter(|value| !value.is_empty())
.ok_or_else(|| anyhow::anyhow!("OAuth token response missing access token"))?;
let account_id = response
.account_id_camel
.or(response.account_id)
.or_else(|| auth::extract_oauth_account_id_from_jwt(&access))
.or_else(|| {
response
.id_token
.as_deref()
.and_then(auth::extract_oauth_account_id_from_jwt)
})
.filter(|value| !value.is_empty())
.ok_or_else(|| anyhow::anyhow!("OAuth token response missing ChatGPT account id"))?;
let expires = response
.expires_in
.map(|seconds| {
Utc::now()
.timestamp()
.checked_add(seconds)
.ok_or_else(|| anyhow::anyhow!("OAuth token response expires_in is too large"))
})
.transpose()?;
Ok(NormalizedToken {
access,
refresh: response.refresh_token.filter(|value| !value.is_empty()),
expires,
account_id,
})
}
#[cfg(test)]
mod tests {
use super::*;
use base64::Engine;
use tempfile::TempDir;
fn fake_jwt(payload_json: &str) -> String {
let header = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(r#"{"alg":"none"}"#);
let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(payload_json);
format!("{header}.{payload}.")
}
#[test]
fn token_response_extracts_nested_pi_chatgpt_account_id_from_access_token() {
let token = normalize_token_response(TokenResponse {
access_token: Some(fake_jwt(
r#"{"https://api.openai.com/auth":{"chatgpt_account_id":"acct_pi"}}"#,
)),
refresh_token: None,
expires_in: None,
account_id_camel: None,
account_id: None,
id_token: None,
})
.unwrap();
assert_eq!(token.account_id, "acct_pi");
}
#[test]
fn token_response_rejects_expiry_overflow() {
let error = normalize_token_response(TokenResponse {
access_token: Some(fake_jwt(
r#"{"https://api.openai.com/auth":{"chatgpt_account_id":"acct_pi"}}"#,
)),
refresh_token: None,
expires_in: Some(i64::MAX),
account_id_camel: None,
account_id: None,
id_token: None,
})
.unwrap_err()
.to_string();
assert!(
error.contains("OAuth token response expires_in is too large"),
"{error}"
);
}
#[test]
fn auth_persist_preserves_unrelated_records() {
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
let mut stored_auth = crate::config::Auth::default();
stored_auth.providers.insert(
"openai".to_string(),
AuthProviderRecord::ApiKey {
key: "secret".into(),
},
);
crate::config::write_auth(&paths, &stored_auth).unwrap();
persist_codex_token(
&paths,
NormalizedToken {
access: "access".into(),
refresh: Some("refresh".into()),
expires: Some(9),
account_id: "acct".into(),
},
)
.unwrap();
let stored_auth = crate::config::read_auth(&paths).unwrap();
assert!(matches!(
stored_auth.providers.get("openai"),
Some(AuthProviderRecord::ApiKey { .. })
));
assert!(matches!(
stored_auth.providers.get(OPENAI_CODEX_PROVIDER),
Some(AuthProviderRecord::OAuth { .. })
));
}
#[test]
fn refresh_survives_unrelated_provider_update_and_returns_refreshed_credential() {
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
let mut stored_auth = crate::config::Auth::default();
stored_auth.providers.insert(
OPENAI_CODEX_PROVIDER.to_string(),
AuthProviderRecord::OAuth {
access: "expired-access".into(),
refresh: Some("stored-refresh".into()),
expires: Some(1),
account_id: Some("stored-account".into()),
},
);
crate::config::write_auth(&paths, &stored_auth).unwrap();
let exchange_started = std::sync::Arc::new(std::sync::Barrier::new(2));
let release_exchange = std::sync::Arc::new(std::sync::Barrier::new(2));
let refresh_paths = paths.clone();
let started_for_exchange = std::sync::Arc::clone(&exchange_started);
let release_for_exchange = std::sync::Arc::clone(&release_exchange);
let refresh_thread = std::thread::spawn(move || {
codex_credential_from_store_with_exchange(&refresh_paths, move |refresh| {
assert_eq!(refresh, "stored-refresh");
started_for_exchange.wait();
release_for_exchange.wait();
Ok(NormalizedToken {
access: "refreshed-access".into(),
refresh: Some("refreshed-refresh".into()),
expires: Some(4102444800),
account_id: "refreshed-account".into(),
})
})
});
exchange_started.wait();
super::super::auth::update_auth(&paths, crate::providers::ANTHROPIC_PROVIDER, |auth| {
auth.providers.insert(
crate::providers::ANTHROPIC_PROVIDER.to_string(),
AuthProviderRecord::ApiKey {
key: "anthropic-key".into(),
},
);
})
.unwrap();
release_exchange.wait();
assert_eq!(
refresh_thread.join().unwrap().unwrap(),
ProviderCredential::OAuth {
access: "refreshed-access".into(),
account_id: Some("refreshed-account".into()),
}
);
let final_auth = crate::config::read_auth(&paths).unwrap();
assert!(matches!(
final_auth.providers.get(crate::providers::ANTHROPIC_PROVIDER),
Some(AuthProviderRecord::ApiKey { key }) if key == "anthropic-key"
));
assert!(matches!(
final_auth.providers.get(OPENAI_CODEX_PROVIDER),
Some(AuthProviderRecord::OAuth {
access,
refresh,
account_id,
..
}) if access == "refreshed-access"
&& refresh.as_deref() == Some("refreshed-refresh")
&& account_id.as_deref() == Some("refreshed-account")
));
let final_store = super::super::auth::read_auth_store(&paths).unwrap();
assert_eq!(final_store.provider_generation(OPENAI_CODEX_PROVIDER), 1);
assert_eq!(
final_store.provider_generation(crate::providers::ANTHROPIC_PROVIDER),
1
);
}
#[test]
fn persist_token_preserves_previous_refresh_when_response_omits_replacement() {
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
let mut stored_auth = crate::config::Auth::default();
stored_auth.providers.insert(
OPENAI_CODEX_PROVIDER.to_string(),
AuthProviderRecord::OAuth {
access: "old-access".into(),
refresh: Some("old-refresh".into()),
expires: Some(1),
account_id: Some("acct".into()),
},
);
crate::config::write_auth(&paths, &stored_auth).unwrap();
persist_codex_token(
&paths,
NormalizedToken {
access: "new-access".into(),
refresh: None,
expires: Some(2),
account_id: "acct".into(),
},
)
.unwrap();
let stored_auth = crate::config::read_auth(&paths).unwrap();
let Some(AuthProviderRecord::OAuth {
access, refresh, ..
}) = stored_auth.providers.get(OPENAI_CODEX_PROVIDER)
else {
panic!("missing codex oauth record")
};
assert_eq!(access, "new-access");
assert_eq!(refresh.as_deref(), Some("old-refresh"));
}
#[test]
fn force_refresh_codex_credential_bypasses_future_expiry_and_persists_returned_identity() {
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
let mut stored_auth = crate::config::Auth::default();
stored_auth.providers.insert(
OPENAI_CODEX_PROVIDER.to_string(),
AuthProviderRecord::OAuth {
access: "old-access".into(),
refresh: Some("old-refresh".into()),
expires: Some(Utc::now().timestamp() + 3600),
account_id: Some("old-acct".into()),
},
);
crate::config::write_auth(&paths, &stored_auth).unwrap();
let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let calls_for_exchange = std::sync::Arc::clone(&calls);
let credential =
force_refresh_codex_credential_from_store_with_exchange(&paths, |refresh| {
calls_for_exchange.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
assert_eq!(refresh, "old-refresh");
Ok(NormalizedToken {
access: "new-access".into(),
refresh: None,
expires: Some(123),
account_id: "new-acct".into(),
})
})
.unwrap();
assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
assert_eq!(
credential,
ProviderCredential::OAuth {
access: "new-access".into(),
account_id: Some("new-acct".into())
}
);
let stored_auth = crate::config::read_auth(&paths).unwrap();
let Some(AuthProviderRecord::OAuth {
access,
refresh,
expires,
account_id,
}) = stored_auth.providers.get(OPENAI_CODEX_PROVIDER)
else {
panic!("missing codex oauth record")
};
assert_eq!(access, "new-access");
assert_eq!(refresh.as_deref(), Some("old-refresh"));
assert_eq!(*expires, Some(123));
assert_eq!(account_id.as_deref(), Some("new-acct"));
}
#[test]
fn codex_credential_from_store_refreshes_empty_access_and_persists_returned_token() {
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
let mut stored_auth = crate::config::Auth::default();
stored_auth.providers.insert(
OPENAI_CODEX_PROVIDER.to_string(),
AuthProviderRecord::OAuth {
access: String::new(),
refresh: Some("stored-refresh".into()),
expires: Some(Utc::now().timestamp() + 3600),
account_id: Some("old-acct".into()),
},
);
crate::config::write_auth(&paths, &stored_auth).unwrap();
let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let calls_for_exchange = std::sync::Arc::clone(&calls);
let credential = codex_credential_from_store_with_exchange(&paths, |refresh| {
calls_for_exchange.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
assert_eq!(refresh, "stored-refresh");
Ok(NormalizedToken {
access: "new-access".into(),
refresh: None,
expires: Some(4102444800),
account_id: "new-acct".into(),
})
})
.unwrap();
assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
assert_eq!(
credential,
ProviderCredential::OAuth {
access: "new-access".into(),
account_id: Some("new-acct".into()),
}
);
assert!(AuthState::for_provider(OPENAI_CODEX_PROVIDER, Some(&credential)).is_ready());
let stored_auth = crate::config::read_auth(&paths).unwrap();
let Some(AuthProviderRecord::OAuth {
access,
refresh,
expires,
account_id,
}) = stored_auth.providers.get(OPENAI_CODEX_PROVIDER)
else {
panic!("missing codex oauth record")
};
assert_eq!(access, "new-access");
assert_eq!(refresh.as_deref(), Some("stored-refresh"));
assert_eq!(*expires, Some(4102444800));
assert_eq!(account_id.as_deref(), Some("new-acct"));
}
#[test]
fn codex_credential_from_store_refreshes_expired_access_and_persists_returned_token() {
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
let mut stored_auth = crate::config::Auth::default();
stored_auth.providers.insert(
OPENAI_CODEX_PROVIDER.to_string(),
AuthProviderRecord::OAuth {
access: "expired-access".into(),
refresh: Some("stored-refresh".into()),
expires: Some(1),
account_id: Some("old-acct".into()),
},
);
crate::config::write_auth(&paths, &stored_auth).unwrap();
let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let calls_for_exchange = std::sync::Arc::clone(&calls);
let credential = codex_credential_from_store_with_exchange(&paths, |refresh| {
calls_for_exchange.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
assert_eq!(refresh, "stored-refresh");
Ok(NormalizedToken {
access: "new-access".into(),
refresh: None,
expires: Some(4102444800),
account_id: "new-acct".into(),
})
})
.unwrap();
assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
assert_eq!(
credential,
ProviderCredential::OAuth {
access: "new-access".into(),
account_id: Some("new-acct".into()),
}
);
assert!(AuthState::for_provider(OPENAI_CODEX_PROVIDER, Some(&credential)).is_ready());
let stored_auth = crate::config::read_auth(&paths).unwrap();
let Some(AuthProviderRecord::OAuth {
access,
refresh,
expires,
account_id,
}) = stored_auth.providers.get(OPENAI_CODEX_PROVIDER)
else {
panic!("missing codex oauth record")
};
assert_eq!(access, "new-access");
assert_eq!(refresh.as_deref(), Some("stored-refresh"));
assert_eq!(*expires, Some(4102444800));
assert_eq!(account_id.as_deref(), Some("new-acct"));
}
#[test]
fn codex_credential_from_store_refreshes_missing_account_with_refresh_token() {
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
let mut stored_auth = crate::config::Auth::default();
stored_auth.providers.insert(
OPENAI_CODEX_PROVIDER.to_string(),
AuthProviderRecord::OAuth {
access: "current-access".into(),
refresh: Some("stored-refresh".into()),
expires: Some(Utc::now().timestamp() + 3600),
account_id: None,
},
);
crate::config::write_auth(&paths, &stored_auth).unwrap();
let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let calls_for_exchange = std::sync::Arc::clone(&calls);
let credential = codex_credential_from_store_with_exchange(&paths, |refresh| {
calls_for_exchange.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
assert_eq!(refresh, "stored-refresh");
Ok(NormalizedToken {
access: "refreshed-access".into(),
refresh: None,
expires: Some(4102444800),
account_id: "refreshed-account".into(),
})
})
.unwrap();
assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
assert_eq!(
credential,
ProviderCredential::OAuth {
access: "refreshed-access".into(),
account_id: Some("refreshed-account".into()),
}
);
let stored_auth = crate::config::read_auth(&paths).unwrap();
let Some(AuthProviderRecord::OAuth {
access,
refresh,
account_id,
..
}) = stored_auth.providers.get(OPENAI_CODEX_PROVIDER)
else {
panic!("missing codex oauth record")
};
assert_eq!(access, "refreshed-access");
assert_eq!(refresh.as_deref(), Some("stored-refresh"));
assert_eq!(account_id.as_deref(), Some("refreshed-account"));
}
#[test]
fn refresh_does_not_overwrite_login_that_commits_during_exchange() {
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
let source_record = AuthProviderRecord::OAuth {
access: "source-access".into(),
refresh: Some("source-refresh".into()),
expires: Some(1),
account_id: Some("source-account".into()),
};
let mut stored_auth = crate::config::Auth::default();
stored_auth
.providers
.insert(OPENAI_CODEX_PROVIDER.to_string(), source_record);
crate::config::write_auth(&paths, &stored_auth).unwrap();
let exchange_started = std::sync::Arc::new(std::sync::Barrier::new(2));
let release_exchange = std::sync::Arc::new(std::sync::Barrier::new(2));
let refresh_paths = paths.clone();
let started_for_exchange = std::sync::Arc::clone(&exchange_started);
let release_for_exchange = std::sync::Arc::clone(&release_exchange);
let refresh_thread = std::thread::spawn(move || {
codex_credential_from_store_with_exchange(&refresh_paths, move |refresh| {
assert_eq!(refresh, "source-refresh");
started_for_exchange.wait();
release_for_exchange.wait();
Ok(NormalizedToken {
access: "stale-access".into(),
refresh: Some("stale-refresh".into()),
expires: Some(4102444800),
account_id: "stale-account".into(),
})
})
});
exchange_started.wait();
let login_record = AuthProviderRecord::OAuth {
access: "login-access".into(),
refresh: Some("login-refresh".into()),
expires: Some(4102444800),
account_id: Some("login-account".into()),
};
persist_codex_token(
&paths,
NormalizedToken {
access: "login-access".into(),
refresh: Some("login-refresh".into()),
expires: Some(4102444800),
account_id: "login-account".into(),
},
)
.unwrap();
release_exchange.wait();
let returned = refresh_thread.join().unwrap().unwrap();
assert_eq!(
returned,
ProviderCredential::OAuth {
access: "login-access".into(),
account_id: Some("login-account".into()),
}
);
let final_auth = crate::config::read_auth(&paths).unwrap();
assert_eq!(
final_auth.providers.get(OPENAI_CODEX_PROVIDER),
Some(&login_record)
);
}
#[test]
fn refresh_does_not_overwrite_exact_value_aba_login_after_logout() {
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
let source_record = AuthProviderRecord::OAuth {
access: "source-access".into(),
refresh: Some("source-refresh".into()),
expires: Some(1),
account_id: Some("source-account".into()),
};
let mut stored_auth = crate::config::Auth::default();
stored_auth
.providers
.insert(OPENAI_CODEX_PROVIDER.to_string(), source_record.clone());
crate::config::write_auth(&paths, &stored_auth).unwrap();
let exchange_started = std::sync::Arc::new(std::sync::Barrier::new(2));
let release_exchange = std::sync::Arc::new(std::sync::Barrier::new(2));
let refresh_paths = paths.clone();
let started_for_exchange = std::sync::Arc::clone(&exchange_started);
let release_for_exchange = std::sync::Arc::clone(&release_exchange);
let refresh_thread = std::thread::spawn(move || {
codex_credential_from_store_with_exchange(&refresh_paths, move |refresh| {
assert_eq!(refresh, "source-refresh");
started_for_exchange.wait();
release_for_exchange.wait();
Ok(NormalizedToken {
access: "stale-access".into(),
refresh: Some("stale-refresh".into()),
expires: Some(4102444800),
account_id: "stale-account".into(),
})
})
});
exchange_started.wait();
let removal = crate::config::remove_provider_auth(&paths, OPENAI_CODEX_PROVIDER).unwrap();
assert!(removal.removed);
persist_codex_token(
&paths,
NormalizedToken {
access: "source-access".into(),
refresh: Some("source-refresh".into()),
expires: Some(1),
account_id: "source-account".into(),
},
)
.unwrap();
release_exchange.wait();
let error = refresh_thread.join().unwrap().unwrap_err().to_string();
assert!(error.contains("not currently usable"), "{error}");
assert!(error.contains(OPENAI_CODEX_RELOGIN_GUIDANCE), "{error}");
assert!(!error.contains("stale-access"), "{error}");
let final_auth = crate::config::read_auth(&paths).unwrap();
let final_store = super::super::auth::read_auth_store(&paths).unwrap();
assert_eq!(final_store.revision(), 3);
assert_eq!(
final_auth.providers.get(OPENAI_CODEX_PROVIDER),
Some(&source_record)
);
}
#[test]
fn refresh_does_not_resurrect_logout_that_commits_during_exchange() {
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
let mut stored_auth = crate::config::Auth::default();
stored_auth.providers.insert(
OPENAI_CODEX_PROVIDER.to_string(),
AuthProviderRecord::OAuth {
access: "source-access".into(),
refresh: Some("source-refresh".into()),
expires: Some(1),
account_id: Some("source-account".into()),
},
);
crate::config::write_auth(&paths, &stored_auth).unwrap();
let exchange_started = std::sync::Arc::new(std::sync::Barrier::new(2));
let release_exchange = std::sync::Arc::new(std::sync::Barrier::new(2));
let refresh_paths = paths.clone();
let started_for_exchange = std::sync::Arc::clone(&exchange_started);
let release_for_exchange = std::sync::Arc::clone(&release_exchange);
let refresh_thread = std::thread::spawn(move || {
codex_credential_from_store_with_exchange(&refresh_paths, move |refresh| {
assert_eq!(refresh, "source-refresh");
started_for_exchange.wait();
release_for_exchange.wait();
Ok(NormalizedToken {
access: "stale-access".into(),
refresh: Some("stale-refresh".into()),
expires: Some(4102444800),
account_id: "stale-account".into(),
})
})
});
exchange_started.wait();
let removal = crate::config::remove_provider_auth(&paths, OPENAI_CODEX_PROVIDER).unwrap();
assert!(removal.removed);
release_exchange.wait();
let error = refresh_thread.join().unwrap().unwrap_err().to_string();
assert!(
error.contains("missing OAuth auth for provider 'openai-codex'"),
"{error}"
);
assert!(!error.contains("stale-access"), "{error}");
let final_auth = crate::config::read_auth(&paths).unwrap();
assert!(!final_auth.providers.contains_key(OPENAI_CODEX_PROVIDER));
}
#[test]
fn concurrent_refreshes_preserve_first_rotated_token_and_return_current_credential() {
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
let mut stored_auth = crate::config::Auth::default();
stored_auth.providers.insert(
OPENAI_CODEX_PROVIDER.to_string(),
AuthProviderRecord::OAuth {
access: "source-access".into(),
refresh: Some("source-refresh".into()),
expires: Some(1),
account_id: Some("source-account".into()),
},
);
crate::config::write_auth(&paths, &stored_auth).unwrap();
let exchanges_ready = std::sync::Arc::new(std::sync::Barrier::new(3));
let release_exchanges = std::sync::Arc::new(std::sync::Barrier::new(3));
let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let (first_finished_tx, first_finished_rx) = std::sync::mpsc::channel();
let first_paths = paths.clone();
let first_ready = std::sync::Arc::clone(&exchanges_ready);
let first_release = std::sync::Arc::clone(&release_exchanges);
let first_calls = std::sync::Arc::clone(&calls);
let first_finished = std::thread::spawn(move || {
let result = codex_credential_from_store_with_exchange(&first_paths, move |refresh| {
assert_eq!(refresh, "source-refresh");
first_calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
first_ready.wait();
first_release.wait();
Ok(NormalizedToken {
access: "first-access".into(),
refresh: Some("first-refresh".into()),
expires: Some(4102444800),
account_id: "first-account".into(),
})
});
first_finished_tx.send(()).unwrap();
result
});
let second_paths = paths.clone();
let second_ready = std::sync::Arc::clone(&exchanges_ready);
let second_release = std::sync::Arc::clone(&release_exchanges);
let second_calls = std::sync::Arc::clone(&calls);
let second_finished = std::thread::spawn(move || {
codex_credential_from_store_with_exchange(&second_paths, move |refresh| {
assert_eq!(refresh, "source-refresh");
second_calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
second_ready.wait();
second_release.wait();
first_finished_rx
.recv_timeout(std::time::Duration::from_secs(5))
.expect("first refresh did not commit");
Ok(NormalizedToken {
access: "second-access".into(),
refresh: Some("second-refresh".into()),
expires: Some(4102444800),
account_id: "second-account".into(),
})
})
});
exchanges_ready.wait();
release_exchanges.wait();
let first_returned = first_finished.join().unwrap().unwrap();
let second_returned = second_finished.join().unwrap().unwrap();
assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 2);
assert_eq!(
first_returned,
ProviderCredential::OAuth {
access: "first-access".into(),
account_id: Some("first-account".into()),
}
);
assert_eq!(
second_returned,
ProviderCredential::OAuth {
access: "first-access".into(),
account_id: Some("first-account".into()),
}
);
let final_auth = crate::config::read_auth(&paths).unwrap();
assert_eq!(
final_auth.providers.get(OPENAI_CODEX_PROVIDER),
Some(&AuthProviderRecord::OAuth {
access: "first-access".into(),
refresh: Some("first-refresh".into()),
expires: Some(4102444800),
account_id: Some("first-account".into()),
})
);
}
#[test]
fn failed_competing_refresh_returns_newer_winner_credential() {
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
let mut stored_auth = crate::config::Auth::default();
stored_auth.providers.insert(
OPENAI_CODEX_PROVIDER.to_string(),
AuthProviderRecord::OAuth {
access: "source-access".into(),
refresh: Some("source-refresh".into()),
expires: Some(1),
account_id: Some("source-account".into()),
},
);
crate::config::write_auth(&paths, &stored_auth).unwrap();
let exchanges_started = std::sync::Arc::new(std::sync::Barrier::new(2));
let (winner_done_tx, winner_done_rx) = std::sync::mpsc::channel();
let winner_paths = paths.clone();
let winner_barrier = std::sync::Arc::clone(&exchanges_started);
let winner = std::thread::spawn(move || {
let result = codex_credential_from_store_with_exchange(&winner_paths, |refresh| {
assert_eq!(refresh, "source-refresh");
winner_barrier.wait();
Ok(NormalizedToken {
access: "winner-access".into(),
refresh: Some("winner-refresh".into()),
expires: Some(4102444800),
account_id: "winner-account".into(),
})
});
winner_done_tx.send(()).unwrap();
result
});
let loser_paths = paths.clone();
let loser_barrier = std::sync::Arc::clone(&exchanges_started);
let loser = std::thread::spawn(move || {
codex_credential_from_store_with_exchange(&loser_paths, |refresh| {
assert_eq!(refresh, "source-refresh");
loser_barrier.wait();
winner_done_rx
.recv_timeout(std::time::Duration::from_secs(5))
.expect("winner refresh did not commit");
Err(anyhow::anyhow!("losing exchange failed"))
})
});
let winner_credential = winner.join().unwrap().unwrap();
let loser_credential = loser.join().unwrap().unwrap();
let expected = ProviderCredential::OAuth {
access: "winner-access".into(),
account_id: Some("winner-account".into()),
};
assert_eq!(winner_credential, expected);
assert_eq!(loser_credential, expected);
assert_eq!(
super::super::auth::read_auth_store(&paths)
.unwrap()
.provider_generation(OPENAI_CODEX_PROVIDER),
1
);
}
#[test]
fn codex_credential_from_store_rejects_invalid_records_before_exchange() {
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
let cases = [
(
String::new(),
None,
Some(1_i64),
Some("account".to_string()),
),
(
"expired-access".to_string(),
None,
Some(1_i64),
Some("account".to_string()),
),
];
for (access, refresh, expires, account_id) in cases {
let mut stored_auth = crate::config::Auth::default();
stored_auth.providers.insert(
OPENAI_CODEX_PROVIDER.to_string(),
AuthProviderRecord::OAuth {
access,
refresh,
expires,
account_id,
},
);
crate::config::write_auth(&paths, &stored_auth).unwrap();
let expected_auth = crate::config::read_auth(&paths).unwrap();
let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let calls_for_exchange = std::sync::Arc::clone(&calls);
let error = codex_credential_from_store_with_exchange(&paths, move |_| {
calls_for_exchange.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
unreachable!("invalid Codex OAuth records must not be exchanged")
})
.unwrap_err()
.to_string();
assert!(error.contains(OPENAI_CODEX_RELOGIN_GUIDANCE), "{error}");
assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
assert_eq!(crate::config::read_auth(&paths).unwrap(), expected_auth);
}
}
}