[Auth] Choose which auth storage to use based on config (#5792)

This PR is a follow-up to #5591. It allows users to choose which auth
storage mode they want by using the new
`cli_auth_credentials_store_mode` config.
This commit is contained in:
Celia Chen
2025-10-27 19:41:49 -07:00
committed by GitHub
parent 66a4b89822
commit 4a42c4e142
30 changed files with 361 additions and 80 deletions

View File

@@ -323,7 +323,11 @@ impl CodexMessageProcessor {
} }
} }
match login_with_api_key(&self.config.codex_home, &params.api_key) { match login_with_api_key(
&self.config.codex_home,
&params.api_key,
self.config.cli_auth_credentials_store_mode,
) {
Ok(()) => { Ok(()) => {
self.auth_manager.reload(); self.auth_manager.reload();
self.outgoing self.outgoing
@@ -367,6 +371,7 @@ impl CodexMessageProcessor {
config.codex_home.clone(), config.codex_home.clone(),
CLIENT_ID.to_string(), CLIENT_ID.to_string(),
config.forced_chatgpt_workspace_id.clone(), config.forced_chatgpt_workspace_id.clone(),
config.cli_auth_credentials_store_mode,
) )
}; };

View File

@@ -37,7 +37,11 @@ impl MessageProcessor {
feedback: CodexFeedback, feedback: CodexFeedback,
) -> Self { ) -> Self {
let outgoing = Arc::new(outgoing); let outgoing = Arc::new(outgoing);
let auth_manager = AuthManager::shared(config.codex_home.clone(), false); let auth_manager = AuthManager::shared(
config.codex_home.clone(),
false,
config.cli_auth_credentials_store_mode,
);
let conversation_manager = Arc::new(ConversationManager::new( let conversation_manager = Arc::new(ConversationManager::new(
auth_manager.clone(), auth_manager.clone(),
SessionSource::VSCode, SessionSource::VSCode,

View File

@@ -6,6 +6,7 @@ use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD; use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use chrono::DateTime; use chrono::DateTime;
use chrono::Utc; use chrono::Utc;
use codex_core::auth::AuthCredentialsStoreMode;
use codex_core::auth::AuthDotJson; use codex_core::auth::AuthDotJson;
use codex_core::auth::save_auth; use codex_core::auth::save_auth;
use codex_core::token_data::TokenData; use codex_core::token_data::TokenData;
@@ -108,7 +109,11 @@ pub fn encode_id_token(claims: &ChatGptIdTokenClaims) -> Result<String> {
Ok(format!("{header_b64}.{payload_b64}.{signature_b64}")) Ok(format!("{header_b64}.{payload_b64}.{signature_b64}"))
} }
pub fn write_chatgpt_auth(codex_home: &Path, fixture: ChatGptAuthFixture) -> Result<()> { pub fn write_chatgpt_auth(
codex_home: &Path,
fixture: ChatGptAuthFixture,
cli_auth_credentials_store_mode: AuthCredentialsStoreMode,
) -> Result<()> {
let id_token_raw = encode_id_token(&fixture.claims)?; let id_token_raw = encode_id_token(&fixture.claims)?;
let id_token = parse_id_token(&id_token_raw).context("parse id token")?; let id_token = parse_id_token(&id_token_raw).context("parse id token")?;
let tokens = TokenData { let tokens = TokenData {
@@ -126,5 +131,5 @@ pub fn write_chatgpt_auth(codex_home: &Path, fixture: ChatGptAuthFixture) -> Res
last_refresh, last_refresh,
}; };
save_auth(codex_home, &auth).context("write auth.json") save_auth(codex_home, &auth, cli_auth_credentials_store_mode).context("write auth.json")
} }

View File

@@ -12,6 +12,7 @@ use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::LoginChatGptResponse; use codex_app_server_protocol::LoginChatGptResponse;
use codex_app_server_protocol::LogoutChatGptResponse; use codex_app_server_protocol::LogoutChatGptResponse;
use codex_app_server_protocol::RequestId; use codex_app_server_protocol::RequestId;
use codex_core::auth::AuthCredentialsStoreMode;
use codex_login::login_with_api_key; use codex_login::login_with_api_key;
use serial_test::serial; use serial_test::serial;
use tempfile::TempDir; use tempfile::TempDir;
@@ -45,7 +46,12 @@ stream_max_retries = 0
async fn logout_chatgpt_removes_auth() { async fn logout_chatgpt_removes_auth() {
let codex_home = TempDir::new().unwrap_or_else(|e| panic!("create tempdir: {e}")); let codex_home = TempDir::new().unwrap_or_else(|e| panic!("create tempdir: {e}"));
create_config_toml(codex_home.path()).expect("write config.toml"); create_config_toml(codex_home.path()).expect("write config.toml");
login_with_api_key(codex_home.path(), "sk-test-key").expect("seed api key"); login_with_api_key(
codex_home.path(),
"sk-test-key",
AuthCredentialsStoreMode::File,
)
.expect("seed api key");
assert!(codex_home.path().join("auth.json").exists()); assert!(codex_home.path().join("auth.json").exists());
let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]) let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)])

View File

@@ -9,6 +9,7 @@ use codex_app_server_protocol::JSONRPCError;
use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::LoginApiKeyParams; use codex_app_server_protocol::LoginApiKeyParams;
use codex_app_server_protocol::RequestId; use codex_app_server_protocol::RequestId;
use codex_core::auth::AuthCredentialsStoreMode;
use codex_protocol::protocol::RateLimitSnapshot; use codex_protocol::protocol::RateLimitSnapshot;
use codex_protocol::protocol::RateLimitWindow; use codex_protocol::protocol::RateLimitWindow;
use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;
@@ -106,6 +107,7 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> {
ChatGptAuthFixture::new("chatgpt-token") ChatGptAuthFixture::new("chatgpt-token")
.account_id("account-123") .account_id("account-123")
.plan_type("pro"), .plan_type("pro"),
AuthCredentialsStoreMode::File,
) )
.context("write chatgpt auth")?; .context("write chatgpt auth")?;

View File

@@ -7,6 +7,7 @@ use app_test_support::write_chatgpt_auth;
use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::RequestId; use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::UserInfoResponse; use codex_app_server_protocol::UserInfoResponse;
use codex_core::auth::AuthCredentialsStoreMode;
use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;
use tempfile::TempDir; use tempfile::TempDir;
use tokio::time::timeout; use tokio::time::timeout;
@@ -22,6 +23,7 @@ async fn user_info_returns_email_from_auth_json() {
ChatGptAuthFixture::new("access") ChatGptAuthFixture::new("access")
.refresh_token("refresh") .refresh_token("refresh")
.email("user@example.com"), .email("user@example.com"),
AuthCredentialsStoreMode::File,
) )
.expect("write chatgpt auth"); .expect("write chatgpt auth");

View File

@@ -32,7 +32,8 @@ pub async fn run_apply_command(
) )
.await?; .await?;
init_chatgpt_token_from_auth(&config.codex_home).await?; init_chatgpt_token_from_auth(&config.codex_home, config.cli_auth_credentials_store_mode)
.await?;
let task_response = get_task(&config, apply_cli.task_id).await?; let task_response = get_task(&config, apply_cli.task_id).await?;
apply_diff_from_task(task_response, cwd).await apply_diff_from_task(task_response, cwd).await

View File

@@ -13,7 +13,8 @@ pub(crate) async fn chatgpt_get_request<T: DeserializeOwned>(
path: String, path: String,
) -> anyhow::Result<T> { ) -> anyhow::Result<T> {
let chatgpt_base_url = &config.chatgpt_base_url; let chatgpt_base_url = &config.chatgpt_base_url;
init_chatgpt_token_from_auth(&config.codex_home).await?; init_chatgpt_token_from_auth(&config.codex_home, config.cli_auth_credentials_store_mode)
.await?;
// Make direct HTTP request to ChatGPT backend API with the token // Make direct HTTP request to ChatGPT backend API with the token
let client = create_client(); let client = create_client();

View File

@@ -3,6 +3,7 @@ use std::path::Path;
use std::sync::LazyLock; use std::sync::LazyLock;
use std::sync::RwLock; use std::sync::RwLock;
use codex_core::auth::AuthCredentialsStoreMode;
use codex_core::token_data::TokenData; use codex_core::token_data::TokenData;
static CHATGPT_TOKEN: LazyLock<RwLock<Option<TokenData>>> = LazyLock::new(|| RwLock::new(None)); static CHATGPT_TOKEN: LazyLock<RwLock<Option<TokenData>>> = LazyLock::new(|| RwLock::new(None));
@@ -18,8 +19,11 @@ pub fn set_chatgpt_token_data(value: TokenData) {
} }
/// Initialize the ChatGPT token from auth.json file /// Initialize the ChatGPT token from auth.json file
pub async fn init_chatgpt_token_from_auth(codex_home: &Path) -> std::io::Result<()> { pub async fn init_chatgpt_token_from_auth(
let auth = CodexAuth::from_auth_storage(codex_home)?; codex_home: &Path,
auth_credentials_store_mode: AuthCredentialsStoreMode,
) -> std::io::Result<()> {
let auth = CodexAuth::from_auth_storage(codex_home, auth_credentials_store_mode)?;
if let Some(auth) = auth { if let Some(auth) = auth {
let token_data = auth.get_token_data().await?; let token_data = auth.get_token_data().await?;
set_chatgpt_token_data(token_data); set_chatgpt_token_data(token_data);

View File

@@ -1,6 +1,7 @@
use codex_app_server_protocol::AuthMode; use codex_app_server_protocol::AuthMode;
use codex_common::CliConfigOverrides; use codex_common::CliConfigOverrides;
use codex_core::CodexAuth; use codex_core::CodexAuth;
use codex_core::auth::AuthCredentialsStoreMode;
use codex_core::auth::CLIENT_ID; use codex_core::auth::CLIENT_ID;
use codex_core::auth::login_with_api_key; use codex_core::auth::login_with_api_key;
use codex_core::auth::logout; use codex_core::auth::logout;
@@ -17,11 +18,13 @@ use std::path::PathBuf;
pub async fn login_with_chatgpt( pub async fn login_with_chatgpt(
codex_home: PathBuf, codex_home: PathBuf,
forced_chatgpt_workspace_id: Option<String>, forced_chatgpt_workspace_id: Option<String>,
cli_auth_credentials_store_mode: AuthCredentialsStoreMode,
) -> std::io::Result<()> { ) -> std::io::Result<()> {
let opts = ServerOptions::new( let opts = ServerOptions::new(
codex_home, codex_home,
CLIENT_ID.to_string(), CLIENT_ID.to_string(),
forced_chatgpt_workspace_id, forced_chatgpt_workspace_id,
cli_auth_credentials_store_mode,
); );
let server = run_login_server(opts)?; let server = run_login_server(opts)?;
@@ -43,7 +46,13 @@ pub async fn run_login_with_chatgpt(cli_config_overrides: CliConfigOverrides) ->
let forced_chatgpt_workspace_id = config.forced_chatgpt_workspace_id.clone(); let forced_chatgpt_workspace_id = config.forced_chatgpt_workspace_id.clone();
match login_with_chatgpt(config.codex_home, forced_chatgpt_workspace_id).await { match login_with_chatgpt(
config.codex_home,
forced_chatgpt_workspace_id,
config.cli_auth_credentials_store_mode,
)
.await
{
Ok(_) => { Ok(_) => {
eprintln!("Successfully logged in"); eprintln!("Successfully logged in");
std::process::exit(0); std::process::exit(0);
@@ -66,7 +75,11 @@ pub async fn run_login_with_api_key(
std::process::exit(1); std::process::exit(1);
} }
match login_with_api_key(&config.codex_home, &api_key) { match login_with_api_key(
&config.codex_home,
&api_key,
config.cli_auth_credentials_store_mode,
) {
Ok(_) => { Ok(_) => {
eprintln!("Successfully logged in"); eprintln!("Successfully logged in");
std::process::exit(0); std::process::exit(0);
@@ -121,6 +134,7 @@ pub async fn run_login_with_device_code(
config.codex_home, config.codex_home,
client_id.unwrap_or(CLIENT_ID.to_string()), client_id.unwrap_or(CLIENT_ID.to_string()),
forced_chatgpt_workspace_id, forced_chatgpt_workspace_id,
config.cli_auth_credentials_store_mode,
); );
if let Some(iss) = issuer_base_url { if let Some(iss) = issuer_base_url {
opts.issuer = iss; opts.issuer = iss;
@@ -140,7 +154,7 @@ pub async fn run_login_with_device_code(
pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! { pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! {
let config = load_config_or_exit(cli_config_overrides).await; let config = load_config_or_exit(cli_config_overrides).await;
match CodexAuth::from_auth_storage(&config.codex_home) { match CodexAuth::from_auth_storage(&config.codex_home, config.cli_auth_credentials_store_mode) {
Ok(Some(auth)) => match auth.mode { Ok(Some(auth)) => match auth.mode {
AuthMode::ApiKey => match auth.get_token().await { AuthMode::ApiKey => match auth.get_token().await {
Ok(api_key) => { Ok(api_key) => {
@@ -171,7 +185,7 @@ pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! {
pub async fn run_logout(cli_config_overrides: CliConfigOverrides) -> ! { pub async fn run_logout(cli_config_overrides: CliConfigOverrides) -> ! {
let config = load_config_or_exit(cli_config_overrides).await; let config = load_config_or_exit(cli_config_overrides).await;
match logout(&config.codex_home) { match logout(&config.codex_home, config.cli_auth_credentials_store_mode) {
Ok(true) => { Ok(true) => {
eprintln!("Successfully logged out"); eprintln!("Successfully logged out");
std::process::exit(0); std::process::exit(0);

View File

@@ -58,7 +58,16 @@ async fn init_backend(user_agent_suffix: &str) -> anyhow::Result<BackendContext>
let auth = match codex_core::config::find_codex_home() let auth = match codex_core::config::find_codex_home()
.ok() .ok()
.map(|home| codex_login::AuthManager::new(home, false)) .map(|home| {
let store_mode = codex_core::config::Config::load_from_base_config_with_overrides(
codex_core::config::ConfigToml::default(),
codex_core::config::ConfigOverrides::default(),
home.clone(),
)
.map(|cfg| cfg.cli_auth_credentials_store_mode)
.unwrap_or_default();
codex_login::AuthManager::new(home, false, store_mode)
})
.and_then(|am| am.auth()) .and_then(|am| am.auth())
{ {
Some(auth) => auth, Some(auth) => auth,

View File

@@ -70,7 +70,14 @@ pub async fn build_chatgpt_headers() -> HeaderMap {
HeaderValue::from_str(&ua).unwrap_or(HeaderValue::from_static("codex-cli")), HeaderValue::from_str(&ua).unwrap_or(HeaderValue::from_static("codex-cli")),
); );
if let Ok(home) = codex_core::config::find_codex_home() { if let Ok(home) = codex_core::config::find_codex_home() {
let am = codex_login::AuthManager::new(home, false); let store_mode = codex_core::config::Config::load_from_base_config_with_overrides(
codex_core::config::ConfigToml::default(),
codex_core::config::ConfigOverrides::default(),
home.clone(),
)
.map(|cfg| cfg.cli_auth_credentials_store_mode)
.unwrap_or_default();
let am = codex_login::AuthManager::new(home, false, store_mode);
if let Some(auth) = am.auth() if let Some(auth) = am.auth()
&& let Ok(tok) = auth.get_token().await && let Ok(tok) = auth.get_token().await
&& !tok.is_empty() && !tok.is_empty()

View File

@@ -39,7 +39,12 @@ eventsource-stream = { workspace = true }
futures = { workspace = true } futures = { workspace = true }
http = { workspace = true } http = { workspace = true }
indexmap = { workspace = true } indexmap = { workspace = true }
keyring = { workspace = true } keyring = { workspace = true, features = [
"apple-native",
"crypto-rust",
"linux-native-async-persistent",
"windows-native",
] }
libc = { workspace = true } libc = { workspace = true }
mcp-types = { workspace = true } mcp-types = { workspace = true }
os_info = { workspace = true } os_info = { workspace = true }

View File

@@ -79,8 +79,11 @@ impl CodexAuth {
} }
/// Loads the available auth information from auth storage. /// Loads the available auth information from auth storage.
pub fn from_auth_storage(codex_home: &Path) -> std::io::Result<Option<CodexAuth>> { pub fn from_auth_storage(
load_auth(codex_home, false) codex_home: &Path,
auth_credentials_store_mode: AuthCredentialsStoreMode,
) -> std::io::Result<Option<CodexAuth>> {
load_auth(codex_home, false, auth_credentials_store_mode)
} }
pub async fn get_token_data(&self) -> Result<TokenData, std::io::Error> { pub async fn get_token_data(&self) -> Result<TokenData, std::io::Error> {
@@ -217,36 +220,55 @@ pub fn read_codex_api_key_from_env() -> Option<String> {
/// Delete the auth.json file inside `codex_home` if it exists. Returns `Ok(true)` /// Delete the auth.json file inside `codex_home` if it exists. Returns `Ok(true)`
/// if a file was removed, `Ok(false)` if no auth file was present. /// if a file was removed, `Ok(false)` if no auth file was present.
pub fn logout(codex_home: &Path) -> std::io::Result<bool> { pub fn logout(
let storage = create_auth_storage(codex_home.to_path_buf(), AuthCredentialsStoreMode::File); codex_home: &Path,
auth_credentials_store_mode: AuthCredentialsStoreMode,
) -> std::io::Result<bool> {
let storage = create_auth_storage(codex_home.to_path_buf(), auth_credentials_store_mode);
storage.delete() storage.delete()
} }
/// Writes an `auth.json` that contains only the API key. /// Writes an `auth.json` that contains only the API key.
pub fn login_with_api_key(codex_home: &Path, api_key: &str) -> std::io::Result<()> { pub fn login_with_api_key(
codex_home: &Path,
api_key: &str,
auth_credentials_store_mode: AuthCredentialsStoreMode,
) -> std::io::Result<()> {
let auth_dot_json = AuthDotJson { let auth_dot_json = AuthDotJson {
openai_api_key: Some(api_key.to_string()), openai_api_key: Some(api_key.to_string()),
tokens: None, tokens: None,
last_refresh: None, last_refresh: None,
}; };
save_auth(codex_home, &auth_dot_json) save_auth(codex_home, &auth_dot_json, auth_credentials_store_mode)
} }
/// Persist the provided auth payload using the specified backend. /// Persist the provided auth payload using the specified backend.
pub fn save_auth(codex_home: &Path, auth: &AuthDotJson) -> std::io::Result<()> { pub fn save_auth(
let storage = create_auth_storage(codex_home.to_path_buf(), AuthCredentialsStoreMode::File); codex_home: &Path,
auth: &AuthDotJson,
auth_credentials_store_mode: AuthCredentialsStoreMode,
) -> std::io::Result<()> {
let storage = create_auth_storage(codex_home.to_path_buf(), auth_credentials_store_mode);
storage.save(auth) storage.save(auth)
} }
/// Load CLI auth data using the configured credential store backend. /// Load CLI auth data using the configured credential store backend.
/// Returns `None` when no credentials are stored. /// Returns `None` when no credentials are stored.
pub fn load_auth_dot_json(codex_home: &Path) -> std::io::Result<Option<AuthDotJson>> { pub fn load_auth_dot_json(
let storage = create_auth_storage(codex_home.to_path_buf(), AuthCredentialsStoreMode::File); codex_home: &Path,
auth_credentials_store_mode: AuthCredentialsStoreMode,
) -> std::io::Result<Option<AuthDotJson>> {
let storage = create_auth_storage(codex_home.to_path_buf(), auth_credentials_store_mode);
storage.load() storage.load()
} }
pub async fn enforce_login_restrictions(config: &Config) -> std::io::Result<()> { pub async fn enforce_login_restrictions(config: &Config) -> std::io::Result<()> {
let Some(auth) = load_auth(&config.codex_home, true)? else { let Some(auth) = load_auth(
&config.codex_home,
true,
config.cli_auth_credentials_store_mode,
)?
else {
return Ok(()); return Ok(());
}; };
@@ -265,7 +287,11 @@ pub async fn enforce_login_restrictions(config: &Config) -> std::io::Result<()>
}; };
if let Some(message) = method_violation { if let Some(message) = method_violation {
return logout_with_message(&config.codex_home, message); return logout_with_message(
&config.codex_home,
message,
config.cli_auth_credentials_store_mode,
);
} }
} }
@@ -282,6 +308,7 @@ pub async fn enforce_login_restrictions(config: &Config) -> std::io::Result<()>
format!( format!(
"Failed to load ChatGPT credentials while enforcing workspace restrictions: {err}. Logging out." "Failed to load ChatGPT credentials while enforcing workspace restrictions: {err}. Logging out."
), ),
config.cli_auth_credentials_store_mode,
); );
} }
}; };
@@ -297,15 +324,23 @@ pub async fn enforce_login_restrictions(config: &Config) -> std::io::Result<()>
"Login is restricted to workspace {expected_account_id}, but current credentials lack a workspace identifier. Logging out." "Login is restricted to workspace {expected_account_id}, but current credentials lack a workspace identifier. Logging out."
), ),
}; };
return logout_with_message(&config.codex_home, message); return logout_with_message(
&config.codex_home,
message,
config.cli_auth_credentials_store_mode,
);
} }
} }
Ok(()) Ok(())
} }
fn logout_with_message(codex_home: &Path, message: String) -> std::io::Result<()> { fn logout_with_message(
match logout(codex_home) { codex_home: &Path,
message: String,
auth_credentials_store_mode: AuthCredentialsStoreMode,
) -> std::io::Result<()> {
match logout(codex_home, auth_credentials_store_mode) {
Ok(_) => Err(std::io::Error::other(message)), Ok(_) => Err(std::io::Error::other(message)),
Err(err) => Err(std::io::Error::other(format!( Err(err) => Err(std::io::Error::other(format!(
"{message}. Failed to remove auth.json: {err}" "{message}. Failed to remove auth.json: {err}"
@@ -316,6 +351,7 @@ fn logout_with_message(codex_home: &Path, message: String) -> std::io::Result<()
fn load_auth( fn load_auth(
codex_home: &Path, codex_home: &Path,
enable_codex_api_key_env: bool, enable_codex_api_key_env: bool,
auth_credentials_store_mode: AuthCredentialsStoreMode,
) -> std::io::Result<Option<CodexAuth>> { ) -> std::io::Result<Option<CodexAuth>> {
if enable_codex_api_key_env && let Some(api_key) = read_codex_api_key_from_env() { if enable_codex_api_key_env && let Some(api_key) = read_codex_api_key_from_env() {
let client = crate::default_client::create_client(); let client = crate::default_client::create_client();
@@ -325,7 +361,7 @@ fn load_auth(
))); )));
} }
let storage = create_auth_storage(codex_home.to_path_buf(), AuthCredentialsStoreMode::File); let storage = create_auth_storage(codex_home.to_path_buf(), auth_credentials_store_mode);
let client = crate::default_client::create_client(); let client = crate::default_client::create_client();
let auth_dot_json = match storage.load()? { let auth_dot_json = match storage.load()? {
@@ -512,7 +548,8 @@ mod tests {
) )
.unwrap(); .unwrap();
super::login_with_api_key(dir.path(), "sk-new").expect("login_with_api_key should succeed"); super::login_with_api_key(dir.path(), "sk-new", AuthCredentialsStoreMode::File)
.expect("login_with_api_key should succeed");
let storage = FileAuthStorage::new(dir.path().to_path_buf()); let storage = FileAuthStorage::new(dir.path().to_path_buf());
let auth = storage let auth = storage
@@ -525,7 +562,8 @@ mod tests {
#[test] #[test]
fn missing_auth_json_returns_none() { fn missing_auth_json_returns_none() {
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
let auth = CodexAuth::from_auth_storage(dir.path()).expect("call should succeed"); let auth = CodexAuth::from_auth_storage(dir.path(), AuthCredentialsStoreMode::File)
.expect("call should succeed");
assert_eq!(auth, None); assert_eq!(auth, None);
} }
@@ -549,7 +587,9 @@ mod tests {
auth_dot_json, auth_dot_json,
storage: _, storage: _,
.. ..
} = super::load_auth(codex_home.path(), false).unwrap().unwrap(); } = super::load_auth(codex_home.path(), false, AuthCredentialsStoreMode::File)
.unwrap()
.unwrap();
assert_eq!(None, api_key); assert_eq!(None, api_key);
assert_eq!(AuthMode::ChatGPT, mode); assert_eq!(AuthMode::ChatGPT, mode);
@@ -590,7 +630,9 @@ mod tests {
) )
.unwrap(); .unwrap();
let auth = super::load_auth(dir.path(), false).unwrap().unwrap(); let auth = super::load_auth(dir.path(), false, AuthCredentialsStoreMode::File)
.unwrap()
.unwrap();
assert_eq!(auth.mode, AuthMode::ApiKey); assert_eq!(auth.mode, AuthMode::ApiKey);
assert_eq!(auth.api_key, Some("sk-test-key".to_string())); assert_eq!(auth.api_key, Some("sk-test-key".to_string()));
@@ -605,10 +647,10 @@ mod tests {
tokens: None, tokens: None,
last_refresh: None, last_refresh: None,
}; };
super::save_auth(dir.path(), &auth_dot_json)?; super::save_auth(dir.path(), &auth_dot_json, AuthCredentialsStoreMode::File)?;
let auth_file = get_auth_file(dir.path()); let auth_file = get_auth_file(dir.path());
assert!(auth_file.exists()); assert!(auth_file.exists());
assert!(logout(dir.path())?); assert!(logout(dir.path(), AuthCredentialsStoreMode::File)?);
assert!(!auth_file.exists()); assert!(!auth_file.exists());
Ok(()) Ok(())
} }
@@ -717,7 +759,8 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn enforce_login_restrictions_logs_out_for_method_mismatch() { async fn enforce_login_restrictions_logs_out_for_method_mismatch() {
let codex_home = tempdir().unwrap(); let codex_home = tempdir().unwrap();
login_with_api_key(codex_home.path(), "sk-test").expect("seed api key"); login_with_api_key(codex_home.path(), "sk-test", AuthCredentialsStoreMode::File)
.expect("seed api key");
let config = build_config(codex_home.path(), Some(ForcedLoginMethod::Chatgpt), None); let config = build_config(codex_home.path(), Some(ForcedLoginMethod::Chatgpt), None);
@@ -786,7 +829,8 @@ mod tests {
async fn enforce_login_restrictions_allows_api_key_if_login_method_not_set_but_forced_chatgpt_workspace_id_is_set() async fn enforce_login_restrictions_allows_api_key_if_login_method_not_set_but_forced_chatgpt_workspace_id_is_set()
{ {
let codex_home = tempdir().unwrap(); let codex_home = tempdir().unwrap();
login_with_api_key(codex_home.path(), "sk-test").expect("seed api key"); login_with_api_key(codex_home.path(), "sk-test", AuthCredentialsStoreMode::File)
.expect("seed api key");
let config = build_config(codex_home.path(), None, Some("org_mine".to_string())); let config = build_config(codex_home.path(), None, Some("org_mine".to_string()));
@@ -830,6 +874,7 @@ pub struct AuthManager {
codex_home: PathBuf, codex_home: PathBuf,
inner: RwLock<CachedAuth>, inner: RwLock<CachedAuth>,
enable_codex_api_key_env: bool, enable_codex_api_key_env: bool,
auth_credentials_store_mode: AuthCredentialsStoreMode,
} }
impl AuthManager { impl AuthManager {
@@ -837,14 +882,23 @@ impl AuthManager {
/// preferred auth method. Errors loading auth are swallowed; `auth()` will /// preferred auth method. Errors loading auth are swallowed; `auth()` will
/// simply return `None` in that case so callers can treat it as an /// simply return `None` in that case so callers can treat it as an
/// unauthenticated state. /// unauthenticated state.
pub fn new(codex_home: PathBuf, enable_codex_api_key_env: bool) -> Self { pub fn new(
let auth = load_auth(&codex_home, enable_codex_api_key_env) codex_home: PathBuf,
.ok() enable_codex_api_key_env: bool,
.flatten(); auth_credentials_store_mode: AuthCredentialsStoreMode,
) -> Self {
let auth = load_auth(
&codex_home,
enable_codex_api_key_env,
auth_credentials_store_mode,
)
.ok()
.flatten();
Self { Self {
codex_home, codex_home,
inner: RwLock::new(CachedAuth { auth }), inner: RwLock::new(CachedAuth { auth }),
enable_codex_api_key_env, enable_codex_api_key_env,
auth_credentials_store_mode,
} }
} }
@@ -855,6 +909,7 @@ impl AuthManager {
codex_home: PathBuf::new(), codex_home: PathBuf::new(),
inner: RwLock::new(cached), inner: RwLock::new(cached),
enable_codex_api_key_env: false, enable_codex_api_key_env: false,
auth_credentials_store_mode: AuthCredentialsStoreMode::File,
}) })
} }
@@ -866,9 +921,13 @@ impl AuthManager {
/// Force a reload of the auth information from auth.json. Returns /// Force a reload of the auth information from auth.json. Returns
/// whether the auth value changed. /// whether the auth value changed.
pub fn reload(&self) -> bool { pub fn reload(&self) -> bool {
let new_auth = load_auth(&self.codex_home, self.enable_codex_api_key_env) let new_auth = load_auth(
.ok() &self.codex_home,
.flatten(); self.enable_codex_api_key_env,
self.auth_credentials_store_mode,
)
.ok()
.flatten();
if let Ok(mut guard) = self.inner.write() { if let Ok(mut guard) = self.inner.write() {
let changed = !AuthManager::auths_equal(&guard.auth, &new_auth); let changed = !AuthManager::auths_equal(&guard.auth, &new_auth);
guard.auth = new_auth; guard.auth = new_auth;
@@ -887,8 +946,16 @@ impl AuthManager {
} }
/// Convenience constructor returning an `Arc` wrapper. /// Convenience constructor returning an `Arc` wrapper.
pub fn shared(codex_home: PathBuf, enable_codex_api_key_env: bool) -> Arc<Self> { pub fn shared(
Arc::new(Self::new(codex_home, enable_codex_api_key_env)) codex_home: PathBuf,
enable_codex_api_key_env: bool,
auth_credentials_store_mode: AuthCredentialsStoreMode,
) -> Arc<Self> {
Arc::new(Self::new(
codex_home,
enable_codex_api_key_env,
auth_credentials_store_mode,
))
} }
/// Attempt to refresh the current auth token (if any). On success, reload /// Attempt to refresh the current auth token (if any). On success, reload
@@ -916,7 +983,7 @@ impl AuthManager {
/// reloads the inmemory auth cache so callers immediately observe the /// reloads the inmemory auth cache so callers immediately observe the
/// unauthenticated state. /// unauthenticated state.
pub fn logout(&self) -> std::io::Result<bool> { pub fn logout(&self) -> std::io::Result<bool> {
let removed = super::auth::logout(&self.codex_home)?; let removed = super::auth::logout(&self.codex_home, self.auth_credentials_store_mode)?;
// Always reload to clear any cached auth (even if file absent). // Always reload to clear any cached auth (even if file absent).
self.reload(); self.reload();
Ok(removed) Ok(removed)

View File

@@ -2525,7 +2525,11 @@ mod tests {
let config = Arc::new(config); let config = Arc::new(config);
let conversation_id = ConversationId::default(); let conversation_id = ConversationId::default();
let otel_event_manager = otel_event_manager(conversation_id, config.as_ref()); let otel_event_manager = otel_event_manager(conversation_id, config.as_ref());
let auth_manager = AuthManager::shared(config.cwd.clone(), false); let auth_manager = AuthManager::shared(
config.cwd.clone(),
false,
config.cli_auth_credentials_store_mode,
);
let session_configuration = SessionConfiguration { let session_configuration = SessionConfiguration {
provider: config.model_provider.clone(), provider: config.model_provider.clone(),
@@ -2594,7 +2598,11 @@ mod tests {
let config = Arc::new(config); let config = Arc::new(config);
let conversation_id = ConversationId::default(); let conversation_id = ConversationId::default();
let otel_event_manager = otel_event_manager(conversation_id, config.as_ref()); let otel_event_manager = otel_event_manager(conversation_id, config.as_ref());
let auth_manager = AuthManager::shared(config.cwd.clone(), false); let auth_manager = AuthManager::shared(
config.cwd.clone(),
false,
config.cli_auth_credentials_store_mode,
);
let session_configuration = SessionConfiguration { let session_configuration = SessionConfiguration {
provider: config.model_provider.clone(), provider: config.model_provider.clone(),

View File

@@ -1,3 +1,4 @@
use crate::auth::AuthCredentialsStoreMode;
use crate::config_loader::LoadedConfigLayers; use crate::config_loader::LoadedConfigLayers;
pub use crate::config_loader::load_config_as_toml; pub use crate::config_loader::load_config_as_toml;
use crate::config_loader::load_config_layers_with_overrides; use crate::config_loader::load_config_layers_with_overrides;
@@ -160,6 +161,12 @@ pub struct Config {
/// resolved against this path. /// resolved against this path.
pub cwd: PathBuf, pub cwd: PathBuf,
/// Preferred store for CLI auth credentials.
/// file (default): Use a file in the Codex home directory.
/// keyring: Use an OS-specific keyring service.
/// auto: Use the OS-specific keyring service if available, otherwise use a file.
pub cli_auth_credentials_store_mode: AuthCredentialsStoreMode,
/// Definition for MCP servers that Codex can reach out to for tool calls. /// Definition for MCP servers that Codex can reach out to for tool calls.
pub mcp_servers: HashMap<String, McpServerConfig>, pub mcp_servers: HashMap<String, McpServerConfig>,
@@ -873,6 +880,13 @@ pub struct ConfigToml {
#[serde(default)] #[serde(default)]
pub forced_login_method: Option<ForcedLoginMethod>, pub forced_login_method: Option<ForcedLoginMethod>,
/// Preferred backend for storing CLI auth credentials.
/// file (default): Use a file in the Codex home directory.
/// keyring: Use an OS-specific keyring service.
/// auto: Use the keyring if available, otherwise use a file.
#[serde(default)]
pub cli_auth_credentials_store: Option<AuthCredentialsStoreMode>,
/// Definition for MCP servers that Codex can reach out to for tool calls. /// Definition for MCP servers that Codex can reach out to for tool calls.
#[serde(default)] #[serde(default)]
pub mcp_servers: HashMap<String, McpServerConfig>, pub mcp_servers: HashMap<String, McpServerConfig>,
@@ -1381,6 +1395,9 @@ impl Config {
notify: cfg.notify, notify: cfg.notify,
user_instructions, user_instructions,
base_instructions, base_instructions,
// The config.toml omits "_mode" because it's a config file. However, "_mode"
// is important in code to differentiate the mode from the store implementation.
cli_auth_credentials_store_mode: cfg.cli_auth_credentials_store.unwrap_or_default(),
mcp_servers: cfg.mcp_servers, mcp_servers: cfg.mcp_servers,
// The config.toml omits "_mode" because it's a config file. However, "_mode" // The config.toml omits "_mode" because it's a config file. However, "_mode"
// is important in code to differentiate the mode from the store implementation. // is important in code to differentiate the mode from the store implementation.
@@ -1803,6 +1820,47 @@ trust_level = "trusted"
Ok(()) Ok(())
} }
#[test]
fn config_defaults_to_file_cli_auth_store_mode() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cfg = ConfigToml::default();
let config = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
codex_home.path().to_path_buf(),
)?;
assert_eq!(
config.cli_auth_credentials_store_mode,
AuthCredentialsStoreMode::File,
);
Ok(())
}
#[test]
fn config_honors_explicit_keyring_auth_store_mode() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cfg = ConfigToml {
cli_auth_credentials_store: Some(AuthCredentialsStoreMode::Keyring),
..Default::default()
};
let config = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
codex_home.path().to_path_buf(),
)?;
assert_eq!(
config.cli_auth_credentials_store_mode,
AuthCredentialsStoreMode::Keyring,
);
Ok(())
}
#[test] #[test]
fn config_defaults_to_auto_oauth_store_mode() -> std::io::Result<()> { fn config_defaults_to_auto_oauth_store_mode() -> std::io::Result<()> {
let codex_home = TempDir::new()?; let codex_home = TempDir::new()?;
@@ -3025,6 +3083,7 @@ model_verbosity = "high"
user_instructions: None, user_instructions: None,
notify: None, notify: None,
cwd: fixture.cwd(), cwd: fixture.cwd(),
cli_auth_credentials_store_mode: Default::default(),
mcp_servers: HashMap::new(), mcp_servers: HashMap::new(),
mcp_oauth_credentials_store_mode: Default::default(), mcp_oauth_credentials_store_mode: Default::default(),
model_providers: fixture.model_provider_map.clone(), model_providers: fixture.model_provider_map.clone(),
@@ -3095,6 +3154,7 @@ model_verbosity = "high"
user_instructions: None, user_instructions: None,
notify: None, notify: None,
cwd: fixture.cwd(), cwd: fixture.cwd(),
cli_auth_credentials_store_mode: Default::default(),
mcp_servers: HashMap::new(), mcp_servers: HashMap::new(),
mcp_oauth_credentials_store_mode: Default::default(), mcp_oauth_credentials_store_mode: Default::default(),
model_providers: fixture.model_provider_map.clone(), model_providers: fixture.model_provider_map.clone(),
@@ -3180,6 +3240,7 @@ model_verbosity = "high"
user_instructions: None, user_instructions: None,
notify: None, notify: None,
cwd: fixture.cwd(), cwd: fixture.cwd(),
cli_auth_credentials_store_mode: Default::default(),
mcp_servers: HashMap::new(), mcp_servers: HashMap::new(),
mcp_oauth_credentials_store_mode: Default::default(), mcp_oauth_credentials_store_mode: Default::default(),
model_providers: fixture.model_provider_map.clone(), model_providers: fixture.model_provider_map.clone(),
@@ -3251,6 +3312,7 @@ model_verbosity = "high"
user_instructions: None, user_instructions: None,
notify: None, notify: None,
cwd: fixture.cwd(), cwd: fixture.cwd(),
cli_auth_credentials_store_mode: Default::default(),
mcp_servers: HashMap::new(), mcp_servers: HashMap::new(),
mcp_oauth_credentials_store_mode: Default::default(), mcp_oauth_credentials_store_mode: Default::default(),
model_providers: fixture.model_provider_map.clone(), model_providers: fixture.model_provider_map.clone(),

View File

@@ -12,6 +12,7 @@ use codex_core::Prompt;
use codex_core::ResponseEvent; use codex_core::ResponseEvent;
use codex_core::ResponseItem; use codex_core::ResponseItem;
use codex_core::WireApi; use codex_core::WireApi;
use codex_core::auth::AuthCredentialsStoreMode;
use codex_core::built_in_model_providers; use codex_core::built_in_model_providers;
use codex_core::error::CodexErr; use codex_core::error::CodexErr;
use codex_core::model_family::find_family_for_model; use codex_core::model_family::find_family_for_model;
@@ -525,11 +526,12 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() {
let mut config = load_default_config_for_test(&codex_home); let mut config = load_default_config_for_test(&codex_home);
config.model_provider = model_provider; config.model_provider = model_provider;
let auth_manager = match CodexAuth::from_auth_storage(codex_home.path()) { let auth_manager =
Ok(Some(auth)) => codex_core::AuthManager::from_auth_for_testing(auth), match CodexAuth::from_auth_storage(codex_home.path(), AuthCredentialsStoreMode::File) {
Ok(None) => panic!("No CodexAuth found in codex_home"), Ok(Some(auth)) => codex_core::AuthManager::from_auth_for_testing(auth),
Err(e) => panic!("Failed to load CodexAuth: {e}"), Ok(None) => panic!("No CodexAuth found in codex_home"),
}; Err(e) => panic!("Failed to load CodexAuth: {e}"),
};
let conversation_manager = ConversationManager::new(auth_manager, SessionSource::Exec); let conversation_manager = ConversationManager::new(auth_manager, SessionSource::Exec);
let NewConversation { let NewConversation {
conversation: codex, conversation: codex,

View File

@@ -249,7 +249,11 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
std::process::exit(1); std::process::exit(1);
} }
let auth_manager = AuthManager::shared(config.codex_home.clone(), true); let auth_manager = AuthManager::shared(
config.codex_home.clone(),
true,
config.cli_auth_credentials_store_mode,
);
let conversation_manager = ConversationManager::new(auth_manager.clone(), SessionSource::Exec); let conversation_manager = ConversationManager::new(auth_manager.clone(), SessionSource::Exec);
// Handle resume subcommand by resolving a rollout path and using explicit resume API. // Handle resume subcommand by resolving a rollout path and using explicit resume API.

View File

@@ -7,5 +7,10 @@ version = { workspace = true }
workspace = true workspace = true
[dependencies] [dependencies]
keyring = { workspace = true } keyring = { workspace = true, features = [
"apple-native",
"crypto-rust",
"linux-native-async-persistent",
"windows-native",
] }
tracing = { workspace = true } tracing = { workspace = true }

View File

@@ -199,6 +199,7 @@ pub async fn run_device_code_login(opts: ServerOptions) -> std::io::Result<()> {
tokens.id_token, tokens.id_token,
tokens.access_token, tokens.access_token,
tokens.refresh_token, tokens.refresh_token,
opts.cli_auth_credentials_store_mode,
) )
.await .await
} }

View File

@@ -14,6 +14,7 @@ use crate::pkce::PkceCodes;
use crate::pkce::generate_pkce; use crate::pkce::generate_pkce;
use base64::Engine; use base64::Engine;
use chrono::Utc; use chrono::Utc;
use codex_core::auth::AuthCredentialsStoreMode;
use codex_core::auth::AuthDotJson; use codex_core::auth::AuthDotJson;
use codex_core::auth::save_auth; use codex_core::auth::save_auth;
use codex_core::default_client::originator; use codex_core::default_client::originator;
@@ -39,6 +40,7 @@ pub struct ServerOptions {
pub open_browser: bool, pub open_browser: bool,
pub force_state: Option<String>, pub force_state: Option<String>,
pub forced_chatgpt_workspace_id: Option<String>, pub forced_chatgpt_workspace_id: Option<String>,
pub cli_auth_credentials_store_mode: AuthCredentialsStoreMode,
} }
impl ServerOptions { impl ServerOptions {
@@ -46,6 +48,7 @@ impl ServerOptions {
codex_home: PathBuf, codex_home: PathBuf,
client_id: String, client_id: String,
forced_chatgpt_workspace_id: Option<String>, forced_chatgpt_workspace_id: Option<String>,
cli_auth_credentials_store_mode: AuthCredentialsStoreMode,
) -> Self { ) -> Self {
Self { Self {
codex_home, codex_home,
@@ -55,6 +58,7 @@ impl ServerOptions {
open_browser: true, open_browser: true,
force_state: None, force_state: None,
forced_chatgpt_workspace_id, forced_chatgpt_workspace_id,
cli_auth_credentials_store_mode,
} }
} }
} }
@@ -270,6 +274,7 @@ async fn process_request(
tokens.id_token.clone(), tokens.id_token.clone(),
tokens.access_token.clone(), tokens.access_token.clone(),
tokens.refresh_token.clone(), tokens.refresh_token.clone(),
opts.cli_auth_credentials_store_mode,
) )
.await .await
{ {
@@ -536,6 +541,7 @@ pub(crate) async fn persist_tokens_async(
id_token: String, id_token: String,
access_token: String, access_token: String,
refresh_token: String, refresh_token: String,
auth_credentials_store_mode: AuthCredentialsStoreMode,
) -> io::Result<()> { ) -> io::Result<()> {
// Reuse existing synchronous logic but run it off the async runtime. // Reuse existing synchronous logic but run it off the async runtime.
let codex_home = codex_home.to_path_buf(); let codex_home = codex_home.to_path_buf();
@@ -557,7 +563,7 @@ pub(crate) async fn persist_tokens_async(
tokens: Some(tokens), tokens: Some(tokens),
last_refresh: Some(Utc::now()), last_refresh: Some(Utc::now()),
}; };
save_auth(&codex_home, &auth) save_auth(&codex_home, &auth, auth_credentials_store_mode)
}) })
.await .await
.map_err(|e| io::Error::other(format!("persist task failed: {e}")))? .map_err(|e| io::Error::other(format!("persist task failed: {e}")))?

View File

@@ -3,6 +3,7 @@
use anyhow::Context; use anyhow::Context;
use base64::Engine; use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD; use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use codex_core::auth::AuthCredentialsStoreMode;
use codex_core::auth::load_auth_dot_json; use codex_core::auth::load_auth_dot_json;
use codex_login::ServerOptions; use codex_login::ServerOptions;
use codex_login::run_device_code_login; use codex_login::run_device_code_login;
@@ -96,11 +97,16 @@ async fn mock_oauth_token_single(server: &MockServer, jwt: String) {
.await; .await;
} }
fn server_opts(codex_home: &tempfile::TempDir, issuer: String) -> ServerOptions { fn server_opts(
codex_home: &tempfile::TempDir,
issuer: String,
cli_auth_credentials_store_mode: AuthCredentialsStoreMode,
) -> ServerOptions {
let mut opts = ServerOptions::new( let mut opts = ServerOptions::new(
codex_home.path().to_path_buf(), codex_home.path().to_path_buf(),
"client-id".to_string(), "client-id".to_string(),
None, None,
cli_auth_credentials_store_mode,
); );
opts.issuer = issuer; opts.issuer = issuer;
opts.open_browser = false; opts.open_browser = false;
@@ -127,13 +133,13 @@ async fn device_code_login_integration_succeeds() -> anyhow::Result<()> {
mock_oauth_token_single(&mock_server, jwt.clone()).await; mock_oauth_token_single(&mock_server, jwt.clone()).await;
let issuer = mock_server.uri(); let issuer = mock_server.uri();
let opts = server_opts(&codex_home, issuer); let opts = server_opts(&codex_home, issuer, AuthCredentialsStoreMode::File);
run_device_code_login(opts) run_device_code_login(opts)
.await .await
.expect("device code login integration should succeed"); .expect("device code login integration should succeed");
let auth = load_auth_dot_json(codex_home.path()) let auth = load_auth_dot_json(codex_home.path(), AuthCredentialsStoreMode::File)
.context("auth.json should load after login succeeds")? .context("auth.json should load after login succeeds")?
.context("auth.json written")?; .context("auth.json written")?;
// assert_eq!(auth.openai_api_key.as_deref(), Some("api-key-321")); // assert_eq!(auth.openai_api_key.as_deref(), Some("api-key-321"));
@@ -166,7 +172,7 @@ async fn device_code_login_rejects_workspace_mismatch() -> anyhow::Result<()> {
mock_oauth_token_single(&mock_server, jwt).await; mock_oauth_token_single(&mock_server, jwt).await;
let issuer = mock_server.uri(); let issuer = mock_server.uri();
let mut opts = server_opts(&codex_home, issuer); let mut opts = server_opts(&codex_home, issuer, AuthCredentialsStoreMode::File);
opts.forced_chatgpt_workspace_id = Some("org-required".to_string()); opts.forced_chatgpt_workspace_id = Some("org-required".to_string());
let err = run_device_code_login(opts) let err = run_device_code_login(opts)
@@ -174,8 +180,8 @@ async fn device_code_login_rejects_workspace_mismatch() -> anyhow::Result<()> {
.expect_err("device code login should fail when workspace mismatches"); .expect_err("device code login should fail when workspace mismatches");
assert_eq!(err.kind(), std::io::ErrorKind::PermissionDenied); assert_eq!(err.kind(), std::io::ErrorKind::PermissionDenied);
let auth = let auth = load_auth_dot_json(codex_home.path(), AuthCredentialsStoreMode::File)
load_auth_dot_json(codex_home.path()).context("auth.json should load after login fails")?; .context("auth.json should load after login fails")?;
assert!( assert!(
auth.is_none(), auth.is_none(),
"auth.json should not be created when workspace validation fails" "auth.json should not be created when workspace validation fails"
@@ -194,7 +200,7 @@ async fn device_code_login_integration_handles_usercode_http_failure() -> anyhow
let issuer = mock_server.uri(); let issuer = mock_server.uri();
let opts = server_opts(&codex_home, issuer); let opts = server_opts(&codex_home, issuer, AuthCredentialsStoreMode::File);
let err = run_device_code_login(opts) let err = run_device_code_login(opts)
.await .await
@@ -205,8 +211,8 @@ async fn device_code_login_integration_handles_usercode_http_failure() -> anyhow
"unexpected error: {err:?}" "unexpected error: {err:?}"
); );
let auth = let auth = load_auth_dot_json(codex_home.path(), AuthCredentialsStoreMode::File)
load_auth_dot_json(codex_home.path()).context("auth.json should load after login fails")?; .context("auth.json should load after login fails")?;
assert!( assert!(
auth.is_none(), auth.is_none(),
"auth.json should not be created when login fails" "auth.json should not be created when login fails"
@@ -237,6 +243,7 @@ async fn device_code_login_integration_persists_without_api_key_on_exchange_fail
codex_home.path().to_path_buf(), codex_home.path().to_path_buf(),
"client-id".to_string(), "client-id".to_string(),
None, None,
AuthCredentialsStoreMode::File,
); );
opts.issuer = issuer; opts.issuer = issuer;
opts.open_browser = false; opts.open_browser = false;
@@ -245,7 +252,7 @@ async fn device_code_login_integration_persists_without_api_key_on_exchange_fail
.await .await
.expect("device login should succeed without API key exchange"); .expect("device login should succeed without API key exchange");
let auth = load_auth_dot_json(codex_home.path()) let auth = load_auth_dot_json(codex_home.path(), AuthCredentialsStoreMode::File)
.context("auth.json should load after login succeeds")? .context("auth.json should load after login succeeds")?
.context("auth.json written")?; .context("auth.json written")?;
assert!(auth.openai_api_key.is_none()); assert!(auth.openai_api_key.is_none());
@@ -286,6 +293,7 @@ async fn device_code_login_integration_handles_error_payload() -> anyhow::Result
codex_home.path().to_path_buf(), codex_home.path().to_path_buf(),
"client-id".to_string(), "client-id".to_string(),
None, None,
AuthCredentialsStoreMode::File,
); );
opts.issuer = issuer; opts.issuer = issuer;
opts.open_browser = false; opts.open_browser = false;
@@ -300,8 +308,8 @@ async fn device_code_login_integration_handles_error_payload() -> anyhow::Result
"Expected an authorization_declined / 400 / 404 error, got {err:?}" "Expected an authorization_declined / 400 / 404 error, got {err:?}"
); );
let auth = let auth = load_auth_dot_json(codex_home.path(), AuthCredentialsStoreMode::File)
load_auth_dot_json(codex_home.path()).context("auth.json should load after login fails")?; .context("auth.json should load after login fails")?;
assert!( assert!(
auth.is_none(), auth.is_none(),
"auth.json should not be created when device auth fails" "auth.json should not be created when device auth fails"

View File

@@ -7,6 +7,7 @@ use std::time::Duration;
use anyhow::Result; use anyhow::Result;
use base64::Engine; use base64::Engine;
use codex_core::auth::AuthCredentialsStoreMode;
use codex_login::ServerOptions; use codex_login::ServerOptions;
use codex_login::run_login_server; use codex_login::run_login_server;
use core_test_support::skip_if_no_network; use core_test_support::skip_if_no_network;
@@ -110,6 +111,7 @@ async fn end_to_end_login_flow_persists_auth_json() -> Result<()> {
let opts = ServerOptions { let opts = ServerOptions {
codex_home: server_home, codex_home: server_home,
cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File,
client_id: codex_login::CLIENT_ID.to_string(), client_id: codex_login::CLIENT_ID.to_string(),
issuer, issuer,
port: 0, port: 0,
@@ -170,6 +172,7 @@ async fn creates_missing_codex_home_dir() -> Result<()> {
let server_home = codex_home.clone(); let server_home = codex_home.clone();
let opts = ServerOptions { let opts = ServerOptions {
codex_home: server_home, codex_home: server_home,
cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File,
client_id: codex_login::CLIENT_ID.to_string(), client_id: codex_login::CLIENT_ID.to_string(),
issuer, issuer,
port: 0, port: 0,
@@ -208,6 +211,7 @@ async fn forced_chatgpt_workspace_id_mismatch_blocks_login() -> Result<()> {
let opts = ServerOptions { let opts = ServerOptions {
codex_home: codex_home.clone(), codex_home: codex_home.clone(),
cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File,
client_id: codex_login::CLIENT_ID.to_string(), client_id: codex_login::CLIENT_ID.to_string(),
issuer, issuer,
port: 0, port: 0,
@@ -263,6 +267,7 @@ async fn cancels_previous_login_server_when_port_is_in_use() -> Result<()> {
let first_opts = ServerOptions { let first_opts = ServerOptions {
codex_home: first_codex_home, codex_home: first_codex_home,
cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File,
client_id: codex_login::CLIENT_ID.to_string(), client_id: codex_login::CLIENT_ID.to_string(),
issuer: issuer.clone(), issuer: issuer.clone(),
port: 0, port: 0,
@@ -282,6 +287,7 @@ async fn cancels_previous_login_server_when_port_is_in_use() -> Result<()> {
let second_opts = ServerOptions { let second_opts = ServerOptions {
codex_home: second_codex_home, codex_home: second_codex_home,
cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File,
client_id: codex_login::CLIENT_ID.to_string(), client_id: codex_login::CLIENT_ID.to_string(),
issuer, issuer,
port: login_port, port: login_port,

View File

@@ -53,7 +53,11 @@ impl MessageProcessor {
config: Arc<Config>, config: Arc<Config>,
) -> Self { ) -> Self {
let outgoing = Arc::new(outgoing); let outgoing = Arc::new(outgoing);
let auth_manager = AuthManager::shared(config.codex_home.clone(), false); let auth_manager = AuthManager::shared(
config.codex_home.clone(),
false,
config.cli_auth_credentials_store_mode,
);
let conversation_manager = let conversation_manager =
Arc::new(ConversationManager::new(auth_manager, SessionSource::Mcp)); Arc::new(ConversationManager::new(auth_manager, SessionSource::Mcp));
Self { Self {

View File

@@ -1219,7 +1219,10 @@ impl ChatWidget {
self.app_event_tx.send(AppEvent::ExitRequest); self.app_event_tx.send(AppEvent::ExitRequest);
} }
SlashCommand::Logout => { SlashCommand::Logout => {
if let Err(e) = codex_core::auth::logout(&self.config.codex_home) { if let Err(e) = codex_core::auth::logout(
&self.config.codex_home,
self.config.cli_auth_credentials_store_mode,
) {
tracing::error!("failed to logout: {e}"); tracing::error!("failed to logout: {e}");
} }
self.app_event_tx.send(AppEvent::ExitRequest); self.app_event_tx.send(AppEvent::ExitRequest);

View File

@@ -315,7 +315,11 @@ async fn run_ratatui_app(
// Initialize high-fidelity session event logging if enabled. // Initialize high-fidelity session event logging if enabled.
session_log::maybe_init(&initial_config); session_log::maybe_init(&initial_config);
let auth_manager = AuthManager::shared(initial_config.codex_home.clone(), false); let auth_manager = AuthManager::shared(
initial_config.codex_home.clone(),
false,
initial_config.cli_auth_credentials_store_mode,
);
let login_status = get_login_status(&initial_config); let login_status = get_login_status(&initial_config);
let should_show_trust_screen = should_show_trust_screen(&initial_config); let should_show_trust_screen = should_show_trust_screen(&initial_config);
let should_show_windows_wsl_screen = let should_show_windows_wsl_screen =
@@ -476,7 +480,7 @@ fn get_login_status(config: &Config) -> LoginStatus {
// Reading the OpenAI API key is an async operation because it may need // Reading the OpenAI API key is an async operation because it may need
// to refresh the token. Block on it. // to refresh the token. Block on it.
let codex_home = config.codex_home.clone(); let codex_home = config.codex_home.clone();
match CodexAuth::from_auth_storage(&codex_home) { match CodexAuth::from_auth_storage(&codex_home, config.cli_auth_credentials_store_mode) {
Ok(Some(auth)) => LoginStatus::AuthMode(auth.mode), Ok(Some(auth)) => LoginStatus::AuthMode(auth.mode),
Ok(None) => LoginStatus::NotAuthenticated, Ok(None) => LoginStatus::NotAuthenticated,
Err(err) => { Err(err) => {

View File

@@ -1,6 +1,7 @@
#![allow(clippy::unwrap_used)] #![allow(clippy::unwrap_used)]
use codex_core::AuthManager; use codex_core::AuthManager;
use codex_core::auth::AuthCredentialsStoreMode;
use codex_core::auth::CLIENT_ID; use codex_core::auth::CLIENT_ID;
use codex_core::auth::login_with_api_key; use codex_core::auth::login_with_api_key;
use codex_core::auth::read_openai_api_key_from_env; use codex_core::auth::read_openai_api_key_from_env;
@@ -148,6 +149,7 @@ pub(crate) struct AuthModeWidget {
pub error: Option<String>, pub error: Option<String>,
pub sign_in_state: Arc<RwLock<SignInState>>, pub sign_in_state: Arc<RwLock<SignInState>>,
pub codex_home: PathBuf, pub codex_home: PathBuf,
pub cli_auth_credentials_store_mode: AuthCredentialsStoreMode,
pub login_status: LoginStatus, pub login_status: LoginStatus,
pub auth_manager: Arc<AuthManager>, pub auth_manager: Arc<AuthManager>,
pub forced_chatgpt_workspace_id: Option<String>, pub forced_chatgpt_workspace_id: Option<String>,
@@ -512,7 +514,11 @@ impl AuthModeWidget {
self.disallow_api_login(); self.disallow_api_login();
return; return;
} }
match login_with_api_key(&self.codex_home, &api_key) { match login_with_api_key(
&self.codex_home,
&api_key,
self.cli_auth_credentials_store_mode,
) {
Ok(()) => { Ok(()) => {
self.error = None; self.error = None;
self.login_status = LoginStatus::AuthMode(AuthMode::ApiKey); self.login_status = LoginStatus::AuthMode(AuthMode::ApiKey);
@@ -553,6 +559,7 @@ impl AuthModeWidget {
self.codex_home.clone(), self.codex_home.clone(),
CLIENT_ID.to_string(), CLIENT_ID.to_string(),
self.forced_chatgpt_workspace_id.clone(), self.forced_chatgpt_workspace_id.clone(),
self.cli_auth_credentials_store_mode,
); );
match run_login_server(opts) { match run_login_server(opts) {
Ok(child) => { Ok(child) => {
@@ -640,6 +647,8 @@ mod tests {
use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;
use tempfile::TempDir; use tempfile::TempDir;
use codex_core::auth::AuthCredentialsStoreMode;
fn widget_forced_chatgpt() -> (AuthModeWidget, TempDir) { fn widget_forced_chatgpt() -> (AuthModeWidget, TempDir) {
let codex_home = TempDir::new().unwrap(); let codex_home = TempDir::new().unwrap();
let codex_home_path = codex_home.path().to_path_buf(); let codex_home_path = codex_home.path().to_path_buf();
@@ -649,8 +658,13 @@ mod tests {
error: None, error: None,
sign_in_state: Arc::new(RwLock::new(SignInState::PickMode)), sign_in_state: Arc::new(RwLock::new(SignInState::PickMode)),
codex_home: codex_home_path.clone(), codex_home: codex_home_path.clone(),
cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File,
login_status: LoginStatus::NotAuthenticated, login_status: LoginStatus::NotAuthenticated,
auth_manager: AuthManager::shared(codex_home_path, false), auth_manager: AuthManager::shared(
codex_home_path,
false,
AuthCredentialsStoreMode::File,
),
forced_chatgpt_workspace_id: None, forced_chatgpt_workspace_id: None,
forced_login_method: Some(ForcedLoginMethod::Chatgpt), forced_login_method: Some(ForcedLoginMethod::Chatgpt),
}; };

View File

@@ -87,6 +87,7 @@ impl OnboardingScreen {
let forced_chatgpt_workspace_id = config.forced_chatgpt_workspace_id.clone(); let forced_chatgpt_workspace_id = config.forced_chatgpt_workspace_id.clone();
let forced_login_method = config.forced_login_method; let forced_login_method = config.forced_login_method;
let codex_home = config.codex_home; let codex_home = config.codex_home;
let cli_auth_credentials_store_mode = config.cli_auth_credentials_store_mode;
let mut steps: Vec<Step> = Vec::new(); let mut steps: Vec<Step> = Vec::new();
if show_windows_wsl_screen { if show_windows_wsl_screen {
steps.push(Step::Windows(WindowsSetupWidget::new(codex_home.clone()))); steps.push(Step::Windows(WindowsSetupWidget::new(codex_home.clone())));
@@ -106,6 +107,7 @@ impl OnboardingScreen {
error: None, error: None,
sign_in_state: Arc::new(RwLock::new(SignInState::PickMode)), sign_in_state: Arc::new(RwLock::new(SignInState::PickMode)),
codex_home: codex_home.clone(), codex_home: codex_home.clone(),
cli_auth_credentials_store_mode,
login_status, login_status,
auth_manager, auth_manager,
forced_chatgpt_workspace_id, forced_chatgpt_workspace_id,

View File

@@ -83,7 +83,8 @@ pub(crate) fn compose_agents_summary(config: &Config) -> String {
} }
pub(crate) fn compose_account_display(config: &Config) -> Option<StatusAccountDisplay> { pub(crate) fn compose_account_display(config: &Config) -> Option<StatusAccountDisplay> {
let auth = load_auth_dot_json(&config.codex_home).ok()??; let auth =
load_auth_dot_json(&config.codex_home, config.cli_auth_credentials_store_mode).ok()??;
if let Some(tokens) = auth.tokens.as_ref() { if let Some(tokens) = auth.tokens.as_ref() {
let info = &tokens.id_token; let info = &tokens.id_token;

View File

@@ -836,7 +836,9 @@ notifications = [ "agent-turn-complete", "approval-requested" ]
> [!NOTE] > `tui.notifications` is builtin and limited to the TUI session. For programmatic or crossenvironment notifications—or to integrate with OSspecific notifiers—use the toplevel `notify` option to run an external program that receives event JSON. The two settings are independent and can be used together. > [!NOTE] > `tui.notifications` is builtin and limited to the TUI session. For programmatic or crossenvironment notifications—or to integrate with OSspecific notifiers—use the toplevel `notify` option to run an external program that receives event JSON. The two settings are independent and can be used together.
## Forcing a login method ## Authentication and authorization
### Forcing a login method
To force users on a given machine to use a specific login method or workspace, use a combination of [managed configurations](https://developers.openai.com/codex/security#managed-configuration) as well as either or both of the following fields: To force users on a given machine to use a specific login method or workspace, use a combination of [managed configurations](https://developers.openai.com/codex/security#managed-configuration) as well as either or both of the following fields:
@@ -852,6 +854,22 @@ If the active credentials don't match the config, the user will be logged out an
If `forced_chatgpt_workspace_id` is set but `forced_login_method` is not set, API key login will still work. If `forced_chatgpt_workspace_id` is set but `forced_login_method` is not set, API key login will still work.
### Control where login credentials are stored
```toml
cli_auth_credentials_store = "keyring"
```
Valid values:
- `file` (default) Store credentials in `auth.json` under `$CODEX_HOME`.
- `keyring` Store credentials in the operating system keyring via the [`keyring` crate](https://crates.io/crates/keyring); the CLI reports an error if secure storage is unavailable. Backends by OS:
- macOS: macOS Keychain
- Windows: Windows Credential Manager
- Linux: DBusbased Secret Service, the kernel keyutils, or a combination
- FreeBSD/OpenBSD: DBusbased Secret Service
- `auto` Save credentials to the operating system keyring when available; otherwise, fall back to `auth.json` under `$CODEX_HOME`.
## Config reference ## Config reference
| Key | Type / Values | Notes | | Key | Type / Values | Notes |
@@ -910,4 +928,5 @@ If `forced_chatgpt_workspace_id` is set but `forced_login_method` is not set, AP
| `tools.web_search` | boolean | Enable web search tool (alias: `web_search_request`) (default: false). | | `tools.web_search` | boolean | Enable web search tool (alias: `web_search_request`) (default: false). |
| `forced_login_method` | `chatgpt` \| `api` | Only allow Codex to be used with ChatGPT or API keys. | | `forced_login_method` | `chatgpt` \| `api` | Only allow Codex to be used with ChatGPT or API keys. |
| `forced_chatgpt_workspace_id` | string (uuid) | Only allow Codex to be used with the specified ChatGPT workspace. | | `forced_chatgpt_workspace_id` | string (uuid) | Only allow Codex to be used with the specified ChatGPT workspace. |
| `cli_auth_credentials_store` | `file` \| `keyring` \| `auto` | Where to store CLI login credentials (default: `file`). |
| `tools.view_image` | boolean | Enable the `view_image` tool so Codex can attach local image files from the workspace (default: false). | | `tools.view_image` | boolean | Enable the `view_image` tool so Codex can attach local image files from the workspace (default: false). |