From 03e2796ca4f6b19e8bb8e6138bec4c9733d4ea6d Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Tue, 2 Sep 2025 18:36:19 -0700 Subject: [PATCH] Move CodexAuth and AuthManager to the core crate (#3074) Fix a long standing layering issue. --- codex-rs/Cargo.lock | 4 +- codex-rs/chatgpt/Cargo.toml | 2 +- codex-rs/chatgpt/src/chatgpt_token.rs | 6 +- codex-rs/cli/src/login.rs | 12 +- codex-rs/cli/src/proto.rs | 2 +- codex-rs/core/Cargo.toml | 1 - codex-rs/core/src/auth.rs | 766 ++++++++++++++++++ codex-rs/core/src/client.rs | 4 +- codex-rs/core/src/codex.rs | 2 +- codex-rs/core/src/config.rs | 2 +- codex-rs/core/src/conversation_manager.rs | 6 +- codex-rs/core/src/lib.rs | 5 + codex-rs/core/src/model_provider_info.rs | 4 +- codex-rs/{login => core}/src/token_data.rs | 38 +- codex-rs/core/tests/suite/client.rs | 8 +- codex-rs/core/tests/suite/compact.rs | 2 +- .../core/tests/suite/fork_conversation.rs | 2 +- codex-rs/core/tests/suite/prompt_caching.rs | 2 +- .../suite/stream_error_allows_next_turn.rs | 2 +- .../core/tests/suite/stream_no_completed.rs | 2 +- codex-rs/exec/src/lib.rs | 2 +- codex-rs/login/Cargo.toml | 1 + codex-rs/login/src/auth_manager.rs | 129 --- codex-rs/login/src/lib.rs | 706 +--------------- codex-rs/login/src/server.rs | 16 +- .../mcp-server/src/codex_message_processor.rs | 4 +- codex-rs/mcp-server/src/message_processor.rs | 2 +- codex-rs/mcp-server/tests/suite/auth.rs | 2 +- codex-rs/mcp-server/tests/suite/login.rs | 2 +- codex-rs/tui/src/app.rs | 2 +- codex-rs/tui/src/bottom_pane/chat_composer.rs | 2 +- codex-rs/tui/src/chatwidget.rs | 2 +- codex-rs/tui/src/chatwidget/tests.rs | 2 +- codex-rs/tui/src/history_cell.rs | 4 +- codex-rs/tui/src/lib.rs | 6 +- codex-rs/tui/src/onboarding/auth.rs | 6 +- .../tui/src/onboarding/onboarding_screen.rs | 4 +- 37 files changed, 879 insertions(+), 885 deletions(-) create mode 100644 codex-rs/core/src/auth.rs rename codex-rs/{login => core}/src/token_data.rs (82%) delete mode 100644 codex-rs/login/src/auth_manager.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 157c09a2..0e13bf6e 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -665,7 +665,7 @@ dependencies = [ "clap", "codex-common", "codex-core", - "codex-login", + "codex-protocol", "reqwest", "serde", "serde_json", @@ -718,7 +718,6 @@ dependencies = [ "bytes", "chrono", "codex-apply-patch", - "codex-login", "codex-mcp-client", "codex-protocol", "core_test_support", @@ -847,6 +846,7 @@ version = "0.0.0" dependencies = [ "base64 0.22.1", "chrono", + "codex-core", "codex-protocol", "pretty_assertions", "rand 0.8.5", diff --git a/codex-rs/chatgpt/Cargo.toml b/codex-rs/chatgpt/Cargo.toml index 903dc14b..78ec5599 100644 --- a/codex-rs/chatgpt/Cargo.toml +++ b/codex-rs/chatgpt/Cargo.toml @@ -11,7 +11,7 @@ anyhow = "1" clap = { version = "4", features = ["derive"] } codex-common = { path = "../common", features = ["cli"] } codex-core = { path = "../core" } -codex-login = { path = "../login" } +codex-protocol = { path = "../protocol" } reqwest = { version = "0.12", features = ["json", "stream"] } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/codex-rs/chatgpt/src/chatgpt_token.rs b/codex-rs/chatgpt/src/chatgpt_token.rs index f003c439..15192ce3 100644 --- a/codex-rs/chatgpt/src/chatgpt_token.rs +++ b/codex-rs/chatgpt/src/chatgpt_token.rs @@ -1,10 +1,10 @@ -use codex_login::AuthMode; -use codex_login::CodexAuth; +use codex_core::CodexAuth; +use codex_protocol::mcp_protocol::AuthMode; use std::path::Path; use std::sync::LazyLock; use std::sync::RwLock; -use codex_login::TokenData; +use codex_core::token_data::TokenData; static CHATGPT_TOKEN: LazyLock>> = LazyLock::new(|| RwLock::new(None)); diff --git a/codex-rs/cli/src/login.rs b/codex-rs/cli/src/login.rs index 959bf46f..f0350961 100644 --- a/codex-rs/cli/src/login.rs +++ b/codex-rs/cli/src/login.rs @@ -1,14 +1,14 @@ use codex_common::CliConfigOverrides; +use codex_core::CodexAuth; +use codex_core::auth::CLIENT_ID; +use codex_core::auth::OPENAI_API_KEY_ENV_VAR; +use codex_core::auth::login_with_api_key; +use codex_core::auth::logout; use codex_core::config::Config; use codex_core::config::ConfigOverrides; -use codex_login::AuthMode; -use codex_login::CLIENT_ID; -use codex_login::CodexAuth; -use codex_login::OPENAI_API_KEY_ENV_VAR; use codex_login::ServerOptions; -use codex_login::login_with_api_key; -use codex_login::logout; use codex_login::run_login_server; +use codex_protocol::mcp_protocol::AuthMode; use std::env; use std::path::PathBuf; diff --git a/codex-rs/cli/src/proto.rs b/codex-rs/cli/src/proto.rs index 9f8c4d3b..9b8cb92e 100644 --- a/codex-rs/cli/src/proto.rs +++ b/codex-rs/cli/src/proto.rs @@ -2,6 +2,7 @@ use std::io::IsTerminal; use clap::Parser; use codex_common::CliConfigOverrides; +use codex_core::AuthManager; use codex_core::ConversationManager; use codex_core::NewConversation; use codex_core::config::Config; @@ -9,7 +10,6 @@ use codex_core::config::ConfigOverrides; use codex_core::protocol::Event; use codex_core::protocol::EventMsg; use codex_core::protocol::Submission; -use codex_login::AuthManager; use tokio::io::AsyncBufReadExt; use tokio::io::BufReader; use tracing::error; diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 8ccf54a4..a4e40c7b 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -18,7 +18,6 @@ base64 = "0.22" bytes = "1.10.1" chrono = { version = "0.4", features = ["serde"] } codex-apply-patch = { path = "../apply-patch" } -codex-login = { path = "../login" } codex-mcp-client = { path = "../mcp-client" } codex-protocol = { path = "../protocol" } dirs = "6" diff --git a/codex-rs/core/src/auth.rs b/codex-rs/core/src/auth.rs new file mode 100644 index 00000000..d2954ba6 --- /dev/null +++ b/codex-rs/core/src/auth.rs @@ -0,0 +1,766 @@ +use chrono::DateTime; +use chrono::Utc; +use serde::Deserialize; +use serde::Serialize; +use std::env; +use std::fs::File; +use std::fs::OpenOptions; +use std::io::Read; +use std::io::Write; +#[cfg(unix)] +use std::os::unix::fs::OpenOptionsExt; +use std::path::Path; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::Mutex; +use std::time::Duration; + +use codex_protocol::mcp_protocol::AuthMode; + +use crate::token_data::TokenData; +use crate::token_data::parse_id_token; + +#[derive(Debug, Clone)] +pub struct CodexAuth { + pub mode: AuthMode, + + pub(crate) api_key: Option, + pub(crate) auth_dot_json: Arc>>, + pub(crate) auth_file: PathBuf, +} + +impl PartialEq for CodexAuth { + fn eq(&self, other: &Self) -> bool { + self.mode == other.mode + } +} + +impl CodexAuth { + pub fn from_api_key(api_key: &str) -> Self { + Self { + api_key: Some(api_key.to_owned()), + mode: AuthMode::ApiKey, + auth_file: PathBuf::new(), + auth_dot_json: Arc::new(Mutex::new(None)), + } + } + + pub async fn refresh_token(&self) -> Result { + let token_data = self + .get_current_token_data() + .ok_or(std::io::Error::other("Token data is not available."))?; + let token = token_data.refresh_token; + + let refresh_response = try_refresh_token(token) + .await + .map_err(std::io::Error::other)?; + + let updated = update_tokens( + &self.auth_file, + refresh_response.id_token, + refresh_response.access_token, + refresh_response.refresh_token, + ) + .await?; + + if let Ok(mut auth_lock) = self.auth_dot_json.lock() { + *auth_lock = Some(updated.clone()); + } + + let access = match updated.tokens { + Some(t) => t.access_token, + None => { + return Err(std::io::Error::other( + "Token data is not available after refresh.", + )); + } + }; + Ok(access) + } + + /// Loads the available auth information from the auth.json or + /// OPENAI_API_KEY environment variable. + pub fn from_codex_home( + codex_home: &Path, + preferred_auth_method: AuthMode, + ) -> std::io::Result> { + load_auth(codex_home, true, preferred_auth_method) + } + + pub async fn get_token_data(&self) -> Result { + let auth_dot_json: Option = self.get_current_auth_json(); + match auth_dot_json { + Some(AuthDotJson { + tokens: Some(mut tokens), + last_refresh: Some(last_refresh), + .. + }) => { + if last_refresh < Utc::now() - chrono::Duration::days(28) { + let refresh_response = tokio::time::timeout( + Duration::from_secs(60), + try_refresh_token(tokens.refresh_token.clone()), + ) + .await + .map_err(|_| { + std::io::Error::other("timed out while refreshing OpenAI API key") + })? + .map_err(std::io::Error::other)?; + + let updated_auth_dot_json = update_tokens( + &self.auth_file, + refresh_response.id_token, + refresh_response.access_token, + refresh_response.refresh_token, + ) + .await?; + + tokens = updated_auth_dot_json + .tokens + .clone() + .ok_or(std::io::Error::other( + "Token data is not available after refresh.", + ))?; + + #[expect(clippy::unwrap_used)] + let mut auth_lock = self.auth_dot_json.lock().unwrap(); + *auth_lock = Some(updated_auth_dot_json); + } + + Ok(tokens) + } + _ => Err(std::io::Error::other("Token data is not available.")), + } + } + + pub async fn get_token(&self) -> Result { + match self.mode { + AuthMode::ApiKey => Ok(self.api_key.clone().unwrap_or_default()), + AuthMode::ChatGPT => { + let id_token = self.get_token_data().await?.access_token; + Ok(id_token) + } + } + } + + pub fn get_account_id(&self) -> Option { + self.get_current_token_data() + .and_then(|t| t.account_id.clone()) + } + + pub fn get_plan_type(&self) -> Option { + self.get_current_token_data() + .and_then(|t| t.id_token.chatgpt_plan_type.as_ref().map(|p| p.as_string())) + } + + fn get_current_auth_json(&self) -> Option { + #[expect(clippy::unwrap_used)] + self.auth_dot_json.lock().unwrap().clone() + } + + fn get_current_token_data(&self) -> Option { + self.get_current_auth_json().and_then(|t| t.tokens.clone()) + } + + /// Consider this private to integration tests. + pub fn create_dummy_chatgpt_auth_for_testing() -> Self { + let auth_dot_json = AuthDotJson { + openai_api_key: None, + tokens: Some(TokenData { + id_token: Default::default(), + access_token: "Access Token".to_string(), + refresh_token: "test".to_string(), + account_id: Some("account_id".to_string()), + }), + last_refresh: Some(Utc::now()), + }; + + let auth_dot_json = Arc::new(Mutex::new(Some(auth_dot_json))); + Self { + api_key: None, + mode: AuthMode::ChatGPT, + auth_file: PathBuf::new(), + auth_dot_json, + } + } +} + +pub const OPENAI_API_KEY_ENV_VAR: &str = "OPENAI_API_KEY"; + +fn read_openai_api_key_from_env() -> Option { + env::var(OPENAI_API_KEY_ENV_VAR) + .ok() + .filter(|s| !s.is_empty()) +} + +pub fn get_auth_file(codex_home: &Path) -> PathBuf { + codex_home.join("auth.json") +} + +/// 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. +pub fn logout(codex_home: &Path) -> std::io::Result { + let auth_file = get_auth_file(codex_home); + match std::fs::remove_file(&auth_file) { + Ok(_) => Ok(true), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(err) => Err(err), + } +} + +/// Writes an `auth.json` that contains only the API key. Intended for CLI use. +pub fn login_with_api_key(codex_home: &Path, api_key: &str) -> std::io::Result<()> { + let auth_dot_json = AuthDotJson { + openai_api_key: Some(api_key.to_string()), + tokens: None, + last_refresh: None, + }; + write_auth_json(&get_auth_file(codex_home), &auth_dot_json) +} + +fn load_auth( + codex_home: &Path, + include_env_var: bool, + preferred_auth_method: AuthMode, +) -> std::io::Result> { + // First, check to see if there is a valid auth.json file. If not, we fall + // back to AuthMode::ApiKey using the OPENAI_API_KEY environment variable + // (if it is set). + let auth_file = get_auth_file(codex_home); + let auth_dot_json = match try_read_auth_json(&auth_file) { + Ok(auth) => auth, + // If auth.json does not exist, try to read the OPENAI_API_KEY from the + // environment variable. + Err(e) if e.kind() == std::io::ErrorKind::NotFound && include_env_var => { + return match read_openai_api_key_from_env() { + Some(api_key) => Ok(Some(CodexAuth::from_api_key(&api_key))), + None => Ok(None), + }; + } + // Though if auth.json exists but is malformed, do not fall back to the + // env var because the user may be expecting to use AuthMode::ChatGPT. + Err(e) => { + return Err(e); + } + }; + + let AuthDotJson { + openai_api_key: auth_json_api_key, + tokens, + last_refresh, + } = auth_dot_json; + + // If the auth.json has an API key AND does not appear to be on a plan that + // should prefer AuthMode::ChatGPT, use AuthMode::ApiKey. + if let Some(api_key) = &auth_json_api_key { + // Should any of these be AuthMode::ChatGPT with the api_key set? + // Does AuthMode::ChatGPT indicate that there is an auth.json that is + // "refreshable" even if we are using the API key for auth? + match &tokens { + Some(tokens) => { + if tokens.should_use_api_key(preferred_auth_method, tokens.is_openai_email()) { + return Ok(Some(CodexAuth::from_api_key(api_key))); + } else { + // Ignore the API key and fall through to ChatGPT auth. + } + } + None => { + // We have an API key but no tokens in the auth.json file. + // Perhaps the user ran `codex login --api-key ` or updated + // auth.json by hand. Either way, let's assume they are trying + // to use their API key. + return Ok(Some(CodexAuth::from_api_key(api_key))); + } + } + } + + // For the AuthMode::ChatGPT variant, perhaps neither api_key nor + // openai_api_key should exist? + Ok(Some(CodexAuth { + api_key: None, + mode: AuthMode::ChatGPT, + auth_file, + auth_dot_json: Arc::new(Mutex::new(Some(AuthDotJson { + openai_api_key: None, + tokens, + last_refresh, + }))), + })) +} + +/// Attempt to read and refresh the `auth.json` file in the given `CODEX_HOME` directory. +/// Returns the full AuthDotJson structure after refreshing if necessary. +pub fn try_read_auth_json(auth_file: &Path) -> std::io::Result { + let mut file = File::open(auth_file)?; + let mut contents = String::new(); + file.read_to_string(&mut contents)?; + let auth_dot_json: AuthDotJson = serde_json::from_str(&contents)?; + + Ok(auth_dot_json) +} + +pub fn write_auth_json(auth_file: &Path, auth_dot_json: &AuthDotJson) -> std::io::Result<()> { + let json_data = serde_json::to_string_pretty(auth_dot_json)?; + let mut options = OpenOptions::new(); + options.truncate(true).write(true).create(true); + #[cfg(unix)] + { + options.mode(0o600); + } + let mut file = options.open(auth_file)?; + file.write_all(json_data.as_bytes())?; + file.flush()?; + Ok(()) +} + +async fn update_tokens( + auth_file: &Path, + id_token: String, + access_token: Option, + refresh_token: Option, +) -> std::io::Result { + let mut auth_dot_json = try_read_auth_json(auth_file)?; + + let tokens = auth_dot_json.tokens.get_or_insert_with(TokenData::default); + tokens.id_token = parse_id_token(&id_token).map_err(std::io::Error::other)?; + if let Some(access_token) = access_token { + tokens.access_token = access_token.to_string(); + } + if let Some(refresh_token) = refresh_token { + tokens.refresh_token = refresh_token.to_string(); + } + auth_dot_json.last_refresh = Some(Utc::now()); + write_auth_json(auth_file, &auth_dot_json)?; + Ok(auth_dot_json) +} + +async fn try_refresh_token(refresh_token: String) -> std::io::Result { + let refresh_request = RefreshRequest { + client_id: CLIENT_ID, + grant_type: "refresh_token", + refresh_token, + scope: "openid profile email", + }; + + let client = reqwest::Client::new(); + let response = client + .post("https://auth.openai.com/oauth/token") + .header("Content-Type", "application/json") + .json(&refresh_request) + .send() + .await + .map_err(std::io::Error::other)?; + + if response.status().is_success() { + let refresh_response = response + .json::() + .await + .map_err(std::io::Error::other)?; + Ok(refresh_response) + } else { + Err(std::io::Error::other(format!( + "Failed to refresh token: {}", + response.status() + ))) + } +} + +#[derive(Serialize)] +struct RefreshRequest { + client_id: &'static str, + grant_type: &'static str, + refresh_token: String, + scope: &'static str, +} + +#[derive(Deserialize, Clone)] +struct RefreshResponse { + id_token: String, + access_token: Option, + refresh_token: Option, +} + +/// Expected structure for $CODEX_HOME/auth.json. +#[derive(Deserialize, Serialize, Clone, Debug, PartialEq)] +pub struct AuthDotJson { + #[serde(rename = "OPENAI_API_KEY")] + pub openai_api_key: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tokens: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_refresh: Option>, +} + +// Shared constant for token refresh (client id used for oauth token refresh flow) +pub const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann"; + +use std::sync::RwLock; + +/// Internal cached auth state. +#[derive(Clone, Debug)] +struct CachedAuth { + preferred_auth_mode: AuthMode, + auth: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::token_data::IdTokenInfo; + use crate::token_data::KnownPlan; + use crate::token_data::PlanType; + use base64::Engine; + use pretty_assertions::assert_eq; + use serde::Serialize; + use serde_json::json; + use tempfile::tempdir; + + const LAST_REFRESH: &str = "2025-08-06T20:41:36.232376Z"; + + #[tokio::test] + async fn roundtrip_auth_dot_json() { + let codex_home = tempdir().unwrap(); + let _ = write_auth_file( + AuthFileParams { + openai_api_key: None, + chatgpt_plan_type: "pro".to_string(), + }, + codex_home.path(), + ) + .expect("failed to write auth file"); + + let file = get_auth_file(codex_home.path()); + let auth_dot_json = try_read_auth_json(&file).unwrap(); + write_auth_json(&file, &auth_dot_json).unwrap(); + + let same_auth_dot_json = try_read_auth_json(&file).unwrap(); + assert_eq!(auth_dot_json, same_auth_dot_json); + } + + #[tokio::test] + async fn pro_account_with_no_api_key_uses_chatgpt_auth() { + let codex_home = tempdir().unwrap(); + let fake_jwt = write_auth_file( + AuthFileParams { + openai_api_key: None, + chatgpt_plan_type: "pro".to_string(), + }, + codex_home.path(), + ) + .expect("failed to write auth file"); + + let CodexAuth { + api_key, + mode, + auth_dot_json, + auth_file: _, + } = super::load_auth(codex_home.path(), false, AuthMode::ChatGPT) + .unwrap() + .unwrap(); + assert_eq!(None, api_key); + assert_eq!(AuthMode::ChatGPT, mode); + + let guard = auth_dot_json.lock().unwrap(); + let auth_dot_json = guard.as_ref().expect("AuthDotJson should exist"); + assert_eq!( + &AuthDotJson { + openai_api_key: None, + tokens: Some(TokenData { + id_token: IdTokenInfo { + email: Some("user@example.com".to_string()), + chatgpt_plan_type: Some(PlanType::Known(KnownPlan::Pro)), + raw_jwt: fake_jwt, + }, + access_token: "test-access-token".to_string(), + refresh_token: "test-refresh-token".to_string(), + account_id: None, + }), + last_refresh: Some( + DateTime::parse_from_rfc3339(LAST_REFRESH) + .unwrap() + .with_timezone(&Utc) + ), + }, + auth_dot_json + ) + } + + /// Even if the OPENAI_API_KEY is set in auth.json, if the plan is not in + /// [`TokenData::is_plan_that_should_use_api_key`], it should use + /// [`AuthMode::ChatGPT`]. + #[tokio::test] + async fn pro_account_with_api_key_still_uses_chatgpt_auth() { + let codex_home = tempdir().unwrap(); + let fake_jwt = write_auth_file( + AuthFileParams { + openai_api_key: Some("sk-test-key".to_string()), + chatgpt_plan_type: "pro".to_string(), + }, + codex_home.path(), + ) + .expect("failed to write auth file"); + + let CodexAuth { + api_key, + mode, + auth_dot_json, + auth_file: _, + } = super::load_auth(codex_home.path(), false, AuthMode::ChatGPT) + .unwrap() + .unwrap(); + assert_eq!(None, api_key); + assert_eq!(AuthMode::ChatGPT, mode); + + let guard = auth_dot_json.lock().unwrap(); + let auth_dot_json = guard.as_ref().expect("AuthDotJson should exist"); + assert_eq!( + &AuthDotJson { + openai_api_key: None, + tokens: Some(TokenData { + id_token: IdTokenInfo { + email: Some("user@example.com".to_string()), + chatgpt_plan_type: Some(PlanType::Known(KnownPlan::Pro)), + raw_jwt: fake_jwt, + }, + access_token: "test-access-token".to_string(), + refresh_token: "test-refresh-token".to_string(), + account_id: None, + }), + last_refresh: Some( + DateTime::parse_from_rfc3339(LAST_REFRESH) + .unwrap() + .with_timezone(&Utc) + ), + }, + auth_dot_json + ) + } + + /// If the OPENAI_API_KEY is set in auth.json and it is an enterprise + /// account, then it should use [`AuthMode::ApiKey`]. + #[tokio::test] + async fn enterprise_account_with_api_key_uses_apikey_auth() { + let codex_home = tempdir().unwrap(); + write_auth_file( + AuthFileParams { + openai_api_key: Some("sk-test-key".to_string()), + chatgpt_plan_type: "enterprise".to_string(), + }, + codex_home.path(), + ) + .expect("failed to write auth file"); + + let CodexAuth { + api_key, + mode, + auth_dot_json, + auth_file: _, + } = super::load_auth(codex_home.path(), false, AuthMode::ChatGPT) + .unwrap() + .unwrap(); + assert_eq!(Some("sk-test-key".to_string()), api_key); + assert_eq!(AuthMode::ApiKey, mode); + + let guard = auth_dot_json.lock().expect("should unwrap"); + assert!(guard.is_none(), "auth_dot_json should be None"); + } + + #[tokio::test] + async fn loads_api_key_from_auth_json() { + let dir = tempdir().unwrap(); + let auth_file = dir.path().join("auth.json"); + std::fs::write( + auth_file, + r#"{"OPENAI_API_KEY":"sk-test-key","tokens":null,"last_refresh":null}"#, + ) + .unwrap(); + + let auth = super::load_auth(dir.path(), false, AuthMode::ChatGPT) + .unwrap() + .unwrap(); + assert_eq!(auth.mode, AuthMode::ApiKey); + assert_eq!(auth.api_key, Some("sk-test-key".to_string())); + + assert!(auth.get_token_data().await.is_err()); + } + + #[test] + fn logout_removes_auth_file() -> Result<(), std::io::Error> { + let dir = tempdir()?; + let auth_dot_json = AuthDotJson { + openai_api_key: Some("sk-test-key".to_string()), + tokens: None, + last_refresh: None, + }; + write_auth_json(&get_auth_file(dir.path()), &auth_dot_json)?; + assert!(dir.path().join("auth.json").exists()); + let removed = logout(dir.path())?; + assert!(removed); + assert!(!dir.path().join("auth.json").exists()); + Ok(()) + } + + struct AuthFileParams { + openai_api_key: Option, + chatgpt_plan_type: String, + } + + fn write_auth_file(params: AuthFileParams, codex_home: &Path) -> std::io::Result { + let auth_file = get_auth_file(codex_home); + // Create a minimal valid JWT for the id_token field. + #[derive(Serialize)] + struct Header { + alg: &'static str, + typ: &'static str, + } + let header = Header { + alg: "none", + typ: "JWT", + }; + let payload = serde_json::json!({ + "email": "user@example.com", + "email_verified": true, + "https://api.openai.com/auth": { + "chatgpt_account_id": "bc3618e3-489d-4d49-9362-1561dc53ba53", + "chatgpt_plan_type": params.chatgpt_plan_type, + "chatgpt_user_id": "user-12345", + "user_id": "user-12345", + } + }); + let b64 = |b: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b); + let header_b64 = b64(&serde_json::to_vec(&header)?); + let payload_b64 = b64(&serde_json::to_vec(&payload)?); + let signature_b64 = b64(b"sig"); + let fake_jwt = format!("{header_b64}.{payload_b64}.{signature_b64}"); + + let auth_json_data = json!({ + "OPENAI_API_KEY": params.openai_api_key, + "tokens": { + "id_token": fake_jwt, + "access_token": "test-access-token", + "refresh_token": "test-refresh-token" + }, + "last_refresh": LAST_REFRESH, + }); + let auth_json = serde_json::to_string_pretty(&auth_json_data)?; + std::fs::write(auth_file, auth_json)?; + Ok(fake_jwt) + } +} + +/// Central manager providing a single source of truth for auth.json derived +/// authentication data. It loads once (or on preference change) and then +/// hands out cloned `CodexAuth` values so the rest of the program has a +/// consistent snapshot. +/// +/// External modifications to `auth.json` will NOT be observed until +/// `reload()` is called explicitly. This matches the design goal of avoiding +/// different parts of the program seeing inconsistent auth data mid‑run. +#[derive(Debug)] +pub struct AuthManager { + codex_home: PathBuf, + inner: RwLock, +} + +impl AuthManager { + /// Create a new manager loading the initial auth using the provided + /// preferred auth method. Errors loading auth are swallowed; `auth()` will + /// simply return `None` in that case so callers can treat it as an + /// unauthenticated state. + pub fn new(codex_home: PathBuf, preferred_auth_mode: AuthMode) -> Self { + let auth = CodexAuth::from_codex_home(&codex_home, preferred_auth_mode) + .ok() + .flatten(); + Self { + codex_home, + inner: RwLock::new(CachedAuth { + preferred_auth_mode, + auth, + }), + } + } + + /// Create an AuthManager with a specific CodexAuth, for testing only. + pub fn from_auth_for_testing(auth: CodexAuth) -> Arc { + let preferred_auth_mode = auth.mode; + let cached = CachedAuth { + preferred_auth_mode, + auth: Some(auth), + }; + Arc::new(Self { + codex_home: PathBuf::new(), + inner: RwLock::new(cached), + }) + } + + /// Current cached auth (clone). May be `None` if not logged in or load failed. + pub fn auth(&self) -> Option { + self.inner.read().ok().and_then(|c| c.auth.clone()) + } + + /// Preferred auth method used when (re)loading. + pub fn preferred_auth_method(&self) -> AuthMode { + self.inner + .read() + .map(|c| c.preferred_auth_mode) + .unwrap_or(AuthMode::ApiKey) + } + + /// Force a reload using the existing preferred auth method. Returns + /// whether the auth value changed. + pub fn reload(&self) -> bool { + let preferred = self.preferred_auth_method(); + let new_auth = CodexAuth::from_codex_home(&self.codex_home, preferred) + .ok() + .flatten(); + if let Ok(mut guard) = self.inner.write() { + let changed = !AuthManager::auths_equal(&guard.auth, &new_auth); + guard.auth = new_auth; + changed + } else { + false + } + } + + fn auths_equal(a: &Option, b: &Option) -> bool { + match (a, b) { + (None, None) => true, + (Some(a), Some(b)) => a == b, + _ => false, + } + } + + /// Convenience constructor returning an `Arc` wrapper. + pub fn shared(codex_home: PathBuf, preferred_auth_mode: AuthMode) -> Arc { + Arc::new(Self::new(codex_home, preferred_auth_mode)) + } + + /// Attempt to refresh the current auth token (if any). On success, reload + /// the auth state from disk so other components observe refreshed token. + pub async fn refresh_token(&self) -> std::io::Result> { + let auth = match self.auth() { + Some(a) => a, + None => return Ok(None), + }; + match auth.refresh_token().await { + Ok(token) => { + // Reload to pick up persisted changes. + self.reload(); + Ok(Some(token)) + } + Err(e) => Err(e), + } + } + + /// Log out by deleting the on‑disk auth.json (if present). Returns Ok(true) + /// if a file was removed, Ok(false) if no auth file existed. On success, + /// reloads the in‑memory auth cache so callers immediately observe the + /// unauthenticated state. + pub fn logout(&self) -> std::io::Result { + let removed = super::auth::logout(&self.codex_home)?; + // Always reload to clear any cached auth (even if file absent). + self.reload(); + Ok(removed) + } +} diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index fa27972c..b92acef4 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -3,9 +3,9 @@ use std::path::Path; use std::sync::OnceLock; use std::time::Duration; +use crate::AuthManager; use bytes::Bytes; -use codex_login::AuthManager; -use codex_login::AuthMode; +use codex_protocol::mcp_protocol::AuthMode; use eventsource_stream::Eventsource; use futures::prelude::*; use regex_lite::Regex; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index fcd156e1..3b3d43bf 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -8,12 +8,12 @@ use std::sync::MutexGuard; use std::sync::atomic::AtomicU64; use std::time::Duration; +use crate::AuthManager; use async_channel::Receiver; use async_channel::Sender; use codex_apply_patch::ApplyPatchAction; use codex_apply_patch::MaybeApplyPatchVerified; use codex_apply_patch::maybe_parse_apply_patch_verified; -use codex_login::AuthManager; use codex_protocol::protocol::ConversationHistoryResponseEvent; use codex_protocol::protocol::TaskStartedEvent; use codex_protocol::protocol::TurnAbortReason; diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index 7845f5d4..57f4775c 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -15,10 +15,10 @@ use crate::model_provider_info::built_in_model_providers; use crate::openai_model_info::get_model_info; use crate::protocol::AskForApproval; use crate::protocol::SandboxPolicy; -use codex_login::AuthMode; use codex_protocol::config_types::ReasoningEffort; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::SandboxMode; +use codex_protocol::mcp_protocol::AuthMode; use dirs::home_dir; use serde::Deserialize; use std::collections::HashMap; diff --git a/codex-rs/core/src/conversation_manager.rs b/codex-rs/core/src/conversation_manager.rs index 22fa6827..78674cd7 100644 --- a/codex-rs/core/src/conversation_manager.rs +++ b/codex-rs/core/src/conversation_manager.rs @@ -2,8 +2,8 @@ use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; -use codex_login::AuthManager; -use codex_login::CodexAuth; +use crate::AuthManager; +use crate::CodexAuth; use tokio::sync::RwLock; use uuid::Uuid; @@ -52,7 +52,7 @@ impl ConversationManager { /// Construct with a dummy AuthManager containing the provided CodexAuth. /// Used for integration tests: should not be used by ordinary business logic. pub fn with_auth(auth: CodexAuth) -> Self { - Self::new(codex_login::AuthManager::from_auth_for_testing(auth)) + Self::new(crate::AuthManager::from_auth_for_testing(auth)) } pub async fn new_conversation(&self, config: Config) -> CodexResult { diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 9f23420c..e3d787e7 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -6,12 +6,14 @@ #![deny(clippy::print_stdout, clippy::print_stderr)] mod apply_patch; +pub mod auth; mod bash; mod chat_completions; mod client; mod client_common; pub mod codex; mod codex_conversation; +pub mod token_data; pub use codex_conversation::CodexConversation; pub mod config; pub mod config_profile; @@ -40,6 +42,9 @@ pub use model_provider_info::create_oss_provider_with_base_url; mod conversation_manager; pub use conversation_manager::ConversationManager; pub use conversation_manager::NewConversation; +// Re-export common auth types for workspace consumers +pub use auth::AuthManager; +pub use auth::CodexAuth; pub mod model_family; mod openai_model_info; mod openai_tools; diff --git a/codex-rs/core/src/model_provider_info.rs b/codex-rs/core/src/model_provider_info.rs index 1a3e5a27..c7da1194 100644 --- a/codex-rs/core/src/model_provider_info.rs +++ b/codex-rs/core/src/model_provider_info.rs @@ -5,8 +5,8 @@ //! 2. User-defined entries inside `~/.codex/config.toml` under the `model_providers` //! key. These override or extend the defaults at runtime. -use codex_login::AuthMode; -use codex_login::CodexAuth; +use crate::CodexAuth; +use codex_protocol::mcp_protocol::AuthMode; use serde::Deserialize; use serde::Serialize; use std::collections::HashMap; diff --git a/codex-rs/login/src/token_data.rs b/codex-rs/core/src/token_data.rs similarity index 82% rename from codex-rs/login/src/token_data.rs rename to codex-rs/core/src/token_data.rs index f6d04f16..626b0305 100644 --- a/codex-rs/login/src/token_data.rs +++ b/codex-rs/core/src/token_data.rs @@ -3,7 +3,7 @@ use serde::Deserialize; use serde::Serialize; use thiserror::Error; -use crate::AuthMode; +use codex_protocol::mcp_protocol::AuthMode; #[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Default)] pub struct TokenData { @@ -58,7 +58,7 @@ pub struct IdTokenInfo { pub email: Option, /// The ChatGPT subscription plan type /// (e.g., "free", "plus", "pro", "business", "enterprise", "edu"). - /// (Note: ae has not verified that those are the exact values.) + /// (Note: values may vary by backend.) pub(crate) chatgpt_plan_type: Option, pub raw_jwt: String, } @@ -137,7 +137,7 @@ pub enum IdTokenInfoError { Json(#[from] serde_json::Error), } -pub(crate) fn parse_id_token(id_token: &str) -> Result { +pub fn parse_id_token(id_token: &str) -> Result { // JWT format: header.payload.signature let mut parts = id_token.split('.'); let (_header_b64, payload_b64, _sig_b64) = match (parts.next(), parts.next(), parts.next()) { @@ -204,9 +204,33 @@ mod tests { let info = parse_id_token(&fake_jwt).expect("should parse"); assert_eq!(info.email.as_deref(), Some("user@example.com")); - assert_eq!( - info.chatgpt_plan_type, - Some(PlanType::Known(KnownPlan::Pro)) - ); + assert_eq!(info.get_chatgpt_plan_type().as_deref(), Some("Pro")); + } + + #[test] + fn id_token_info_handles_missing_fields() { + #[derive(Serialize)] + struct Header { + alg: &'static str, + typ: &'static str, + } + let header = Header { + alg: "none", + typ: "JWT", + }; + let payload = serde_json::json!({ "sub": "123" }); + + fn b64url_no_pad(bytes: &[u8]) -> String { + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes) + } + + let header_b64 = b64url_no_pad(&serde_json::to_vec(&header).unwrap()); + let payload_b64 = b64url_no_pad(&serde_json::to_vec(&payload).unwrap()); + let signature_b64 = b64url_no_pad(b"sig"); + let fake_jwt = format!("{header_b64}.{payload_b64}.{signature_b64}"); + + let info = parse_id_token(&fake_jwt).expect("should parse"); + assert!(info.email.is_none()); + assert!(info.get_chatgpt_plan_type().is_none()); } } diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index aed34dc3..f77749b4 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1,3 +1,4 @@ +use codex_core::CodexAuth; use codex_core::ConversationManager; use codex_core::ModelProviderInfo; use codex_core::NewConversation; @@ -7,8 +8,7 @@ use codex_core::protocol::EventMsg; use codex_core::protocol::InputItem; use codex_core::protocol::Op; use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; -use codex_login::AuthMode; -use codex_login::CodexAuth; +use codex_protocol::mcp_protocol::AuthMode; use core_test_support::load_default_config_for_test; use core_test_support::load_sse_fixture_with_id; use core_test_support::wait_for_event; @@ -416,7 +416,7 @@ async fn prefers_chatgpt_token_when_config_prefers_chatgpt() { let auth_manager = match CodexAuth::from_codex_home(codex_home.path(), config.preferred_auth_method) { - Ok(Some(auth)) => codex_login::AuthManager::from_auth_for_testing(auth), + Ok(Some(auth)) => codex_core::AuthManager::from_auth_for_testing(auth), Ok(None) => panic!("No CodexAuth found in codex_home"), Err(e) => panic!("Failed to load CodexAuth: {e}"), }; @@ -497,7 +497,7 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() { let auth_manager = match CodexAuth::from_codex_home(codex_home.path(), config.preferred_auth_method) { - Ok(Some(auth)) => codex_login::AuthManager::from_auth_for_testing(auth), + Ok(Some(auth)) => codex_core::AuthManager::from_auth_for_testing(auth), Ok(None) => panic!("No CodexAuth found in codex_home"), Err(e) => panic!("Failed to load CodexAuth: {e}"), }; diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index 404a88e8..f5e854d2 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -1,5 +1,6 @@ #![expect(clippy::unwrap_used)] +use codex_core::CodexAuth; use codex_core::ConversationManager; use codex_core::ModelProviderInfo; use codex_core::built_in_model_providers; @@ -7,7 +8,6 @@ use codex_core::protocol::EventMsg; use codex_core::protocol::InputItem; use codex_core::protocol::Op; use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; -use codex_login::CodexAuth; use core_test_support::load_default_config_for_test; use core_test_support::wait_for_event; use serde_json::Value; diff --git a/codex-rs/core/tests/suite/fork_conversation.rs b/codex-rs/core/tests/suite/fork_conversation.rs index de5a17f5..92e43dbf 100644 --- a/codex-rs/core/tests/suite/fork_conversation.rs +++ b/codex-rs/core/tests/suite/fork_conversation.rs @@ -1,3 +1,4 @@ +use codex_core::CodexAuth; use codex_core::ConversationManager; use codex_core::ModelProviderInfo; use codex_core::NewConversation; @@ -6,7 +7,6 @@ use codex_core::protocol::ConversationHistoryResponseEvent; use codex_core::protocol::EventMsg; use codex_core::protocol::InputItem; use codex_core::protocol::Op; -use codex_login::CodexAuth; use core_test_support::load_default_config_for_test; use core_test_support::wait_for_event; use tempfile::TempDir; diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 999f8072..11c6ea9a 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -1,5 +1,6 @@ #![allow(clippy::unwrap_used)] +use codex_core::CodexAuth; use codex_core::ConversationManager; use codex_core::ModelProviderInfo; use codex_core::built_in_model_providers; @@ -12,7 +13,6 @@ use codex_core::protocol::SandboxPolicy; use codex_core::protocol_config_types::ReasoningEffort; use codex_core::protocol_config_types::ReasoningSummary; use codex_core::shell::default_user_shell; -use codex_login::CodexAuth; use core_test_support::load_default_config_for_test; use core_test_support::load_sse_fixture_with_id; use core_test_support::wait_for_event; diff --git a/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs b/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs index 8d4b2c99..3395eef9 100644 --- a/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs +++ b/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs @@ -1,5 +1,6 @@ use std::time::Duration; +use codex_core::CodexAuth; use codex_core::ConversationManager; use codex_core::ModelProviderInfo; use codex_core::WireApi; @@ -7,7 +8,6 @@ use codex_core::protocol::EventMsg; use codex_core::protocol::InputItem; use codex_core::protocol::Op; use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; -use codex_login::CodexAuth; use core_test_support::load_default_config_for_test; use core_test_support::load_sse_fixture_with_id; use core_test_support::wait_for_event_with_timeout; diff --git a/codex-rs/core/tests/suite/stream_no_completed.rs b/codex-rs/core/tests/suite/stream_no_completed.rs index a425cfa7..6e6938b9 100644 --- a/codex-rs/core/tests/suite/stream_no_completed.rs +++ b/codex-rs/core/tests/suite/stream_no_completed.rs @@ -3,13 +3,13 @@ use std::time::Duration; +use codex_core::CodexAuth; use codex_core::ConversationManager; use codex_core::ModelProviderInfo; use codex_core::protocol::EventMsg; use codex_core::protocol::InputItem; use codex_core::protocol::Op; use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; -use codex_login::CodexAuth; use core_test_support::load_default_config_for_test; use core_test_support::load_sse_fixture; use core_test_support::load_sse_fixture_with_id; diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 124d3390..d57d55bc 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -8,6 +8,7 @@ use std::io::Read; use std::path::PathBuf; pub use cli::Cli; +use codex_core::AuthManager; use codex_core::BUILT_IN_OSS_MODEL_PROVIDER_ID; use codex_core::ConversationManager; use codex_core::NewConversation; @@ -20,7 +21,6 @@ use codex_core::protocol::EventMsg; use codex_core::protocol::InputItem; use codex_core::protocol::Op; use codex_core::protocol::TaskCompleteEvent; -use codex_login::AuthManager; use codex_ollama::DEFAULT_OSS_MODEL; use codex_protocol::config_types::SandboxMode; use event_processor_with_human_output::EventProcessorWithHumanOutput; diff --git a/codex-rs/login/Cargo.toml b/codex-rs/login/Cargo.toml index bf04a8e3..0a357929 100644 --- a/codex-rs/login/Cargo.toml +++ b/codex-rs/login/Cargo.toml @@ -9,6 +9,7 @@ workspace = true [dependencies] base64 = "0.22" chrono = { version = "0.4", features = ["serde"] } +codex-core = { path = "../core" } codex-protocol = { path = "../protocol" } rand = "0.8" reqwest = { version = "0.12", features = ["json", "blocking"] } diff --git a/codex-rs/login/src/auth_manager.rs b/codex-rs/login/src/auth_manager.rs deleted file mode 100644 index 5e892b28..00000000 --- a/codex-rs/login/src/auth_manager.rs +++ /dev/null @@ -1,129 +0,0 @@ -use std::path::PathBuf; -use std::sync::Arc; -use std::sync::RwLock; - -use crate::AuthMode; -use crate::CodexAuth; - -/// Internal cached auth state. -#[derive(Clone, Debug)] -struct CachedAuth { - preferred_auth_mode: AuthMode, - auth: Option, -} - -/// Central manager providing a single source of truth for auth.json derived -/// authentication data. It loads once (or on preference change) and then -/// hands out cloned `CodexAuth` values so the rest of the program has a -/// consistent snapshot. -/// -/// External modifications to `auth.json` will NOT be observed until -/// `reload()` is called explicitly. This matches the design goal of avoiding -/// different parts of the program seeing inconsistent auth data mid‑run. -#[derive(Debug)] -pub struct AuthManager { - codex_home: PathBuf, - inner: RwLock, -} - -impl AuthManager { - /// Create a new manager loading the initial auth using the provided - /// preferred auth method. Errors loading auth are swallowed; `auth()` will - /// simply return `None` in that case so callers can treat it as an - /// unauthenticated state. - pub fn new(codex_home: PathBuf, preferred_auth_mode: AuthMode) -> Self { - let auth = crate::CodexAuth::from_codex_home(&codex_home, preferred_auth_mode) - .ok() - .flatten(); - Self { - codex_home, - inner: RwLock::new(CachedAuth { - preferred_auth_mode, - auth, - }), - } - } - - /// Create an AuthManager with a specific CodexAuth, for testing only. - pub fn from_auth_for_testing(auth: CodexAuth) -> Arc { - let preferred_auth_mode = auth.mode; - let cached = CachedAuth { - preferred_auth_mode, - auth: Some(auth), - }; - Arc::new(Self { - codex_home: PathBuf::new(), - inner: RwLock::new(cached), - }) - } - - /// Current cached auth (clone). May be `None` if not logged in or load failed. - pub fn auth(&self) -> Option { - self.inner.read().ok().and_then(|c| c.auth.clone()) - } - - /// Preferred auth method used when (re)loading. - pub fn preferred_auth_method(&self) -> AuthMode { - self.inner - .read() - .map(|c| c.preferred_auth_mode) - .unwrap_or(AuthMode::ApiKey) - } - - /// Force a reload using the existing preferred auth method. Returns - /// whether the auth value changed. - pub fn reload(&self) -> bool { - let preferred = self.preferred_auth_method(); - let new_auth = crate::CodexAuth::from_codex_home(&self.codex_home, preferred) - .ok() - .flatten(); - if let Ok(mut guard) = self.inner.write() { - let changed = !AuthManager::auths_equal(&guard.auth, &new_auth); - guard.auth = new_auth; - changed - } else { - false - } - } - - fn auths_equal(a: &Option, b: &Option) -> bool { - match (a, b) { - (None, None) => true, - (Some(a), Some(b)) => a == b, - _ => false, - } - } - - /// Convenience constructor returning an `Arc` wrapper. - pub fn shared(codex_home: PathBuf, preferred_auth_mode: AuthMode) -> Arc { - Arc::new(Self::new(codex_home, preferred_auth_mode)) - } - - /// Attempt to refresh the current auth token (if any). On success, reload - /// the auth state from disk so other components observe refreshed token. - pub async fn refresh_token(&self) -> std::io::Result> { - let auth = match self.auth() { - Some(a) => a, - None => return Ok(None), - }; - match auth.refresh_token().await { - Ok(token) => { - // Reload to pick up persisted changes. - self.reload(); - Ok(Some(token)) - } - Err(e) => Err(e), - } - } - - /// Log out by deleting the on‑disk auth.json (if present). Returns Ok(true) - /// if a file was removed, Ok(false) if no auth file existed. On success, - /// reloads the in‑memory auth cache so callers immediately observe the - /// unauthenticated state. - pub fn logout(&self) -> std::io::Result { - let removed = crate::logout(&self.codex_home)?; - // Always reload to clear any cached auth (even if file absent). - self.reload(); - Ok(removed) - } -} diff --git a/codex-rs/login/src/lib.rs b/codex-rs/login/src/lib.rs index 6d5297ea..a737af22 100644 --- a/codex-rs/login/src/lib.rs +++ b/codex-rs/login/src/lib.rs @@ -1,693 +1,21 @@ -use chrono::DateTime; -use chrono::Utc; -use serde::Deserialize; -use serde::Serialize; -use std::env; -use std::fs::File; -use std::fs::OpenOptions; -use std::fs::remove_file; -use std::io::Read; -use std::io::Write; -#[cfg(unix)] -use std::os::unix::fs::OpenOptionsExt; -use std::path::Path; -use std::path::PathBuf; -use std::sync::Arc; -use std::sync::Mutex; -use std::time::Duration; - -pub use crate::server::LoginServer; -pub use crate::server::ServerOptions; -pub use crate::server::ShutdownHandle; -pub use crate::server::run_login_server; -pub use crate::token_data::TokenData; -use crate::token_data::parse_id_token; - -mod auth_manager; mod pkce; mod server; -mod token_data; -pub const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann"; -pub const OPENAI_API_KEY_ENV_VAR: &str = "OPENAI_API_KEY"; -pub use auth_manager::AuthManager; +pub use server::LoginServer; +pub use server::ServerOptions; +pub use server::ShutdownHandle; +pub use server::run_login_server; + +// Re-export commonly used auth types and helpers from codex-core for compatibility +pub use codex_core::AuthManager; +pub use codex_core::CodexAuth; +pub use codex_core::auth::AuthDotJson; +pub use codex_core::auth::CLIENT_ID; +pub use codex_core::auth::OPENAI_API_KEY_ENV_VAR; +pub use codex_core::auth::get_auth_file; +pub use codex_core::auth::login_with_api_key; +pub use codex_core::auth::logout; +pub use codex_core::auth::try_read_auth_json; +pub use codex_core::auth::write_auth_json; +pub use codex_core::token_data::TokenData; pub use codex_protocol::mcp_protocol::AuthMode; - -#[derive(Debug, Clone)] -pub struct CodexAuth { - pub mode: AuthMode, - - api_key: Option, - auth_dot_json: Arc>>, - auth_file: PathBuf, -} - -impl PartialEq for CodexAuth { - fn eq(&self, other: &Self) -> bool { - self.mode == other.mode - } -} - -impl CodexAuth { - pub fn from_api_key(api_key: &str) -> Self { - Self { - api_key: Some(api_key.to_owned()), - mode: AuthMode::ApiKey, - auth_file: PathBuf::new(), - auth_dot_json: Arc::new(Mutex::new(None)), - } - } - - pub async fn refresh_token(&self) -> Result { - let token_data = self - .get_current_token_data() - .ok_or(std::io::Error::other("Token data is not available."))?; - let token = token_data.refresh_token; - - let refresh_response = try_refresh_token(token) - .await - .map_err(std::io::Error::other)?; - - let updated = update_tokens( - &self.auth_file, - refresh_response.id_token, - refresh_response.access_token, - refresh_response.refresh_token, - ) - .await?; - - if let Ok(mut auth_lock) = self.auth_dot_json.lock() { - *auth_lock = Some(updated.clone()); - } - - let access = match updated.tokens { - Some(t) => t.access_token, - None => { - return Err(std::io::Error::other( - "Token data is not available after refresh.", - )); - } - }; - Ok(access) - } - - /// Loads the available auth information from the auth.json or - /// OPENAI_API_KEY environment variable. - pub fn from_codex_home( - codex_home: &Path, - preferred_auth_method: AuthMode, - ) -> std::io::Result> { - load_auth(codex_home, true, preferred_auth_method) - } - - pub async fn get_token_data(&self) -> Result { - let auth_dot_json: Option = self.get_current_auth_json(); - match auth_dot_json { - Some(AuthDotJson { - tokens: Some(mut tokens), - last_refresh: Some(last_refresh), - .. - }) => { - if last_refresh < Utc::now() - chrono::Duration::days(28) { - let refresh_response = tokio::time::timeout( - Duration::from_secs(60), - try_refresh_token(tokens.refresh_token.clone()), - ) - .await - .map_err(|_| { - std::io::Error::other("timed out while refreshing OpenAI API key") - })? - .map_err(std::io::Error::other)?; - - let updated_auth_dot_json = update_tokens( - &self.auth_file, - refresh_response.id_token, - refresh_response.access_token, - refresh_response.refresh_token, - ) - .await?; - - tokens = updated_auth_dot_json - .tokens - .clone() - .ok_or(std::io::Error::other( - "Token data is not available after refresh.", - ))?; - - #[expect(clippy::unwrap_used)] - let mut auth_lock = self.auth_dot_json.lock().unwrap(); - *auth_lock = Some(updated_auth_dot_json); - } - - Ok(tokens) - } - _ => Err(std::io::Error::other("Token data is not available.")), - } - } - - pub async fn get_token(&self) -> Result { - match self.mode { - AuthMode::ApiKey => Ok(self.api_key.clone().unwrap_or_default()), - AuthMode::ChatGPT => { - let id_token = self.get_token_data().await?.access_token; - - Ok(id_token) - } - } - } - - pub fn get_account_id(&self) -> Option { - self.get_current_token_data() - .and_then(|t| t.account_id.clone()) - } - - pub fn get_plan_type(&self) -> Option { - self.get_current_token_data() - .and_then(|t| t.id_token.chatgpt_plan_type.as_ref().map(|p| p.as_string())) - } - - fn get_current_auth_json(&self) -> Option { - #[expect(clippy::unwrap_used)] - self.auth_dot_json.lock().unwrap().clone() - } - - fn get_current_token_data(&self) -> Option { - self.get_current_auth_json().and_then(|t| t.tokens.clone()) - } - - /// Consider this private to integration tests. - pub fn create_dummy_chatgpt_auth_for_testing() -> Self { - let auth_dot_json = AuthDotJson { - openai_api_key: None, - tokens: Some(TokenData { - id_token: Default::default(), - access_token: "Access Token".to_string(), - refresh_token: "test".to_string(), - account_id: Some("account_id".to_string()), - }), - last_refresh: Some(Utc::now()), - }; - - let auth_dot_json = Arc::new(Mutex::new(Some(auth_dot_json))); - Self { - api_key: None, - mode: AuthMode::ChatGPT, - auth_file: PathBuf::new(), - auth_dot_json, - } - } -} - -fn load_auth( - codex_home: &Path, - include_env_var: bool, - preferred_auth_method: AuthMode, -) -> std::io::Result> { - // First, check to see if there is a valid auth.json file. If not, we fall - // back to AuthMode::ApiKey using the OPENAI_API_KEY environment variable - // (if it is set). - let auth_file = get_auth_file(codex_home); - let auth_dot_json = match try_read_auth_json(&auth_file) { - Ok(auth) => auth, - // If auth.json does not exist, try to read the OPENAI_API_KEY from the - // environment variable. - Err(e) if e.kind() == std::io::ErrorKind::NotFound && include_env_var => { - return match read_openai_api_key_from_env() { - Some(api_key) => Ok(Some(CodexAuth::from_api_key(&api_key))), - None => Ok(None), - }; - } - // Though if auth.json exists but is malformed, do not fall back to the - // env var because the user may be expecting to use AuthMode::ChatGPT. - Err(e) => { - return Err(e); - } - }; - - let AuthDotJson { - openai_api_key: auth_json_api_key, - tokens, - last_refresh, - } = auth_dot_json; - - // If the auth.json has an API key AND does not appear to be on a plan that - // should prefer AuthMode::ChatGPT, use AuthMode::ApiKey. - if let Some(api_key) = &auth_json_api_key { - // Should any of these be AuthMode::ChatGPT with the api_key set? - // Does AuthMode::ChatGPT indicate that there is an auth.json that is - // "refreshable" even if we are using the API key for auth? - match &tokens { - Some(tokens) => { - if tokens.should_use_api_key(preferred_auth_method, tokens.is_openai_email()) { - return Ok(Some(CodexAuth::from_api_key(api_key))); - } else { - // Ignore the API key and fall through to ChatGPT auth. - } - } - None => { - // We have an API key but no tokens in the auth.json file. - // Perhaps the user ran `codex login --api-key ` or updated - // auth.json by hand. Either way, let's assume they are trying - // to use their API key. - return Ok(Some(CodexAuth::from_api_key(api_key))); - } - } - } - - // For the AuthMode::ChatGPT variant, perhaps neither api_key nor - // openai_api_key should exist? - Ok(Some(CodexAuth { - api_key: None, - mode: AuthMode::ChatGPT, - auth_file, - auth_dot_json: Arc::new(Mutex::new(Some(AuthDotJson { - openai_api_key: None, - tokens, - last_refresh, - }))), - })) -} - -fn read_openai_api_key_from_env() -> Option { - env::var(OPENAI_API_KEY_ENV_VAR) - .ok() - .filter(|s| !s.is_empty()) -} - -pub fn get_auth_file(codex_home: &Path) -> PathBuf { - codex_home.join("auth.json") -} - -/// 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. -pub fn logout(codex_home: &Path) -> std::io::Result { - let auth_file = get_auth_file(codex_home); - match remove_file(&auth_file) { - Ok(_) => Ok(true), - Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(false), - Err(err) => Err(err), - } -} - -pub fn login_with_api_key(codex_home: &Path, api_key: &str) -> std::io::Result<()> { - let auth_dot_json = AuthDotJson { - openai_api_key: Some(api_key.to_string()), - tokens: None, - last_refresh: None, - }; - write_auth_json(&get_auth_file(codex_home), &auth_dot_json) -} - -/// Attempt to read and refresh the `auth.json` file in the given `CODEX_HOME` directory. -/// Returns the full AuthDotJson structure after refreshing if necessary. -pub fn try_read_auth_json(auth_file: &Path) -> std::io::Result { - let mut file = File::open(auth_file)?; - let mut contents = String::new(); - file.read_to_string(&mut contents)?; - let auth_dot_json: AuthDotJson = serde_json::from_str(&contents)?; - - Ok(auth_dot_json) -} - -fn write_auth_json(auth_file: &Path, auth_dot_json: &AuthDotJson) -> std::io::Result<()> { - let json_data = serde_json::to_string_pretty(auth_dot_json)?; - let mut options = OpenOptions::new(); - options.truncate(true).write(true).create(true); - #[cfg(unix)] - { - options.mode(0o600); - } - let mut file = options.open(auth_file)?; - file.write_all(json_data.as_bytes())?; - file.flush()?; - Ok(()) -} - -async fn update_tokens( - auth_file: &Path, - id_token: String, - access_token: Option, - refresh_token: Option, -) -> std::io::Result { - let mut auth_dot_json = try_read_auth_json(auth_file)?; - - let tokens = auth_dot_json.tokens.get_or_insert_with(TokenData::default); - tokens.id_token = parse_id_token(&id_token).map_err(std::io::Error::other)?; - if let Some(access_token) = access_token { - tokens.access_token = access_token.to_string(); - } - if let Some(refresh_token) = refresh_token { - tokens.refresh_token = refresh_token.to_string(); - } - auth_dot_json.last_refresh = Some(Utc::now()); - write_auth_json(auth_file, &auth_dot_json)?; - Ok(auth_dot_json) -} - -async fn try_refresh_token(refresh_token: String) -> std::io::Result { - let refresh_request = RefreshRequest { - client_id: CLIENT_ID, - grant_type: "refresh_token", - refresh_token, - scope: "openid profile email", - }; - - let client = reqwest::Client::new(); - let response = client - .post("https://auth.openai.com/oauth/token") - .header("Content-Type", "application/json") - .json(&refresh_request) - .send() - .await - .map_err(std::io::Error::other)?; - - if response.status().is_success() { - let refresh_response = response - .json::() - .await - .map_err(std::io::Error::other)?; - Ok(refresh_response) - } else { - Err(std::io::Error::other(format!( - "Failed to refresh token: {}", - response.status() - ))) - } -} - -#[derive(Serialize)] -struct RefreshRequest { - client_id: &'static str, - grant_type: &'static str, - refresh_token: String, - scope: &'static str, -} - -#[derive(Deserialize, Clone)] -struct RefreshResponse { - id_token: String, - access_token: Option, - refresh_token: Option, -} - -/// Expected structure for $CODEX_HOME/auth.json. -#[derive(Deserialize, Serialize, Clone, Debug, PartialEq)] -pub struct AuthDotJson { - #[serde(rename = "OPENAI_API_KEY")] - pub openai_api_key: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub tokens: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub last_refresh: Option>, -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::token_data::IdTokenInfo; - use crate::token_data::KnownPlan; - use crate::token_data::PlanType; - use base64::Engine; - use pretty_assertions::assert_eq; - use serde_json::json; - use tempfile::tempdir; - - const LAST_REFRESH: &str = "2025-08-06T20:41:36.232376Z"; - - #[test] - fn writes_api_key_and_loads_auth() { - let dir = tempdir().unwrap(); - login_with_api_key(dir.path(), "sk-test-key").unwrap(); - let auth = load_auth(dir.path(), false, AuthMode::ChatGPT) - .unwrap() - .unwrap(); - assert_eq!(auth.mode, AuthMode::ApiKey); - assert_eq!(auth.api_key.as_deref(), Some("sk-test-key")); - } - - #[test] - fn loads_from_env_var_if_env_var_exists() { - let dir = tempdir().unwrap(); - - let env_var = std::env::var(OPENAI_API_KEY_ENV_VAR); - - if let Ok(env_var) = env_var { - let auth = load_auth(dir.path(), true, AuthMode::ChatGPT) - .unwrap() - .unwrap(); - assert_eq!(auth.mode, AuthMode::ApiKey); - assert_eq!(auth.api_key, Some(env_var)); - } - } - - #[tokio::test] - async fn roundtrip_auth_dot_json() { - let codex_home = tempdir().unwrap(); - write_auth_file( - AuthFileParams { - openai_api_key: None, - chatgpt_plan_type: "pro".to_string(), - }, - codex_home.path(), - ) - .expect("failed to write auth file"); - - let file = get_auth_file(codex_home.path()); - let auth_dot_json = try_read_auth_json(&file).unwrap(); - write_auth_json(&file, &auth_dot_json).unwrap(); - - let same_auth_dot_json = try_read_auth_json(&file).unwrap(); - assert_eq!(auth_dot_json, same_auth_dot_json); - } - - #[tokio::test] - async fn pro_account_with_no_api_key_uses_chatgpt_auth() { - let codex_home = tempdir().unwrap(); - let fake_jwt = write_auth_file( - AuthFileParams { - openai_api_key: None, - chatgpt_plan_type: "pro".to_string(), - }, - codex_home.path(), - ) - .expect("failed to write auth file"); - - let CodexAuth { - api_key, - mode, - auth_dot_json, - auth_file: _, - } = load_auth(codex_home.path(), false, AuthMode::ChatGPT) - .unwrap() - .unwrap(); - assert_eq!(None, api_key); - assert_eq!(AuthMode::ChatGPT, mode); - - let guard = auth_dot_json.lock().unwrap(); - let auth_dot_json = guard.as_ref().expect("AuthDotJson should exist"); - assert_eq!( - &AuthDotJson { - openai_api_key: None, - tokens: Some(TokenData { - id_token: IdTokenInfo { - email: Some("user@example.com".to_string()), - chatgpt_plan_type: Some(PlanType::Known(KnownPlan::Pro)), - raw_jwt: fake_jwt, - }, - access_token: "test-access-token".to_string(), - refresh_token: "test-refresh-token".to_string(), - account_id: None, - }), - last_refresh: Some( - DateTime::parse_from_rfc3339(LAST_REFRESH) - .unwrap() - .with_timezone(&Utc) - ), - }, - auth_dot_json - ) - } - - /// Even if the OPENAI_API_KEY is set in auth.json, if the plan is not in - /// [`TokenData::is_plan_that_should_use_api_key`], it should use - /// [`AuthMode::ChatGPT`]. - #[tokio::test] - async fn pro_account_with_api_key_still_uses_chatgpt_auth() { - let codex_home = tempdir().unwrap(); - let fake_jwt = write_auth_file( - AuthFileParams { - openai_api_key: Some("sk-test-key".to_string()), - chatgpt_plan_type: "pro".to_string(), - }, - codex_home.path(), - ) - .expect("failed to write auth file"); - - let CodexAuth { - api_key, - mode, - auth_dot_json, - auth_file: _, - } = load_auth(codex_home.path(), false, AuthMode::ChatGPT) - .unwrap() - .unwrap(); - assert_eq!(None, api_key); - assert_eq!(AuthMode::ChatGPT, mode); - - let guard = auth_dot_json.lock().unwrap(); - let auth_dot_json = guard.as_ref().expect("AuthDotJson should exist"); - assert_eq!( - &AuthDotJson { - openai_api_key: None, - tokens: Some(TokenData { - id_token: IdTokenInfo { - email: Some("user@example.com".to_string()), - chatgpt_plan_type: Some(PlanType::Known(KnownPlan::Pro)), - raw_jwt: fake_jwt, - }, - access_token: "test-access-token".to_string(), - refresh_token: "test-refresh-token".to_string(), - account_id: None, - }), - last_refresh: Some( - DateTime::parse_from_rfc3339(LAST_REFRESH) - .unwrap() - .with_timezone(&Utc) - ), - }, - auth_dot_json - ) - } - - /// If the OPENAI_API_KEY is set in auth.json and it is an enterprise - /// account, then it should use [`AuthMode::ApiKey`]. - #[tokio::test] - async fn enterprise_account_with_api_key_uses_chatgpt_auth() { - let codex_home = tempdir().unwrap(); - write_auth_file( - AuthFileParams { - openai_api_key: Some("sk-test-key".to_string()), - chatgpt_plan_type: "enterprise".to_string(), - }, - codex_home.path(), - ) - .expect("failed to write auth file"); - - let CodexAuth { - api_key, - mode, - auth_dot_json, - auth_file: _, - } = load_auth(codex_home.path(), false, AuthMode::ChatGPT) - .unwrap() - .unwrap(); - assert_eq!(Some("sk-test-key".to_string()), api_key); - assert_eq!(AuthMode::ApiKey, mode); - - let guard = auth_dot_json.lock().expect("should unwrap"); - assert!(guard.is_none(), "auth_dot_json should be None"); - } - - struct AuthFileParams { - openai_api_key: Option, - chatgpt_plan_type: String, - } - - fn write_auth_file(params: AuthFileParams, codex_home: &Path) -> std::io::Result { - let auth_file = get_auth_file(codex_home); - // Create a minimal valid JWT for the id_token field. - #[derive(Serialize)] - struct Header { - alg: &'static str, - typ: &'static str, - } - let header = Header { - alg: "none", - typ: "JWT", - }; - let payload = serde_json::json!({ - "email": "user@example.com", - "email_verified": true, - "https://api.openai.com/auth": { - "chatgpt_account_id": "bc3618e3-489d-4d49-9362-1561dc53ba53", - "chatgpt_plan_type": params.chatgpt_plan_type, - "chatgpt_user_id": "user-12345", - "user_id": "user-12345", - } - }); - let b64 = |b: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b); - let header_b64 = b64(&serde_json::to_vec(&header)?); - let payload_b64 = b64(&serde_json::to_vec(&payload)?); - let signature_b64 = b64(b"sig"); - let fake_jwt = format!("{header_b64}.{payload_b64}.{signature_b64}"); - - let auth_json_data = json!({ - "OPENAI_API_KEY": params.openai_api_key, - "tokens": { - "id_token": fake_jwt, - "access_token": "test-access-token", - "refresh_token": "test-refresh-token" - }, - "last_refresh": LAST_REFRESH, - }); - let auth_json = serde_json::to_string_pretty(&auth_json_data)?; - std::fs::write(auth_file, auth_json)?; - - Ok(fake_jwt) - } - - #[test] - fn id_token_info_handles_missing_fields() { - // Payload without email or plan should yield None values. - let header = serde_json::json!({"alg": "none", "typ": "JWT"}); - let payload = serde_json::json!({"sub": "123"}); - let header_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD - .encode(serde_json::to_vec(&header).unwrap()); - let payload_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD - .encode(serde_json::to_vec(&payload).unwrap()); - let signature_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b"sig"); - let jwt = format!("{header_b64}.{payload_b64}.{signature_b64}"); - - let info = parse_id_token(&jwt).expect("should parse"); - assert!(info.email.is_none()); - assert!(info.chatgpt_plan_type.is_none()); - } - - #[tokio::test] - async fn loads_api_key_from_auth_json() { - let dir = tempdir().unwrap(); - let auth_file = dir.path().join("auth.json"); - std::fs::write( - auth_file, - r#" - { - "OPENAI_API_KEY": "sk-test-key", - "tokens": null, - "last_refresh": null - } - "#, - ) - .unwrap(); - - let auth = load_auth(dir.path(), false, AuthMode::ChatGPT) - .unwrap() - .unwrap(); - assert_eq!(auth.mode, AuthMode::ApiKey); - assert_eq!(auth.api_key, Some("sk-test-key".to_string())); - - assert!(auth.get_token_data().await.is_err()); - } - - #[test] - fn logout_removes_auth_file() -> Result<(), std::io::Error> { - let dir = tempdir()?; - login_with_api_key(dir.path(), "sk-test-key")?; - assert!(dir.path().join("auth.json").exists()); - let removed = logout(dir.path())?; - assert!(removed); - assert!(!dir.path().join("auth.json").exists()); - Ok(()) - } -} diff --git a/codex-rs/login/src/server.rs b/codex-rs/login/src/server.rs index 6ffc8621..5cc08d42 100644 --- a/codex-rs/login/src/server.rs +++ b/codex-rs/login/src/server.rs @@ -5,12 +5,14 @@ use std::path::PathBuf; use std::sync::Arc; use std::thread; -use crate::AuthDotJson; -use crate::get_auth_file; use crate::pkce::PkceCodes; use crate::pkce::generate_pkce; use base64::Engine; use chrono::Utc; +use codex_core::auth::AuthDotJson; +use codex_core::auth::get_auth_file; +use codex_core::token_data::TokenData; +use codex_core::token_data::parse_id_token; use rand::RngCore; use tiny_http::Header; use tiny_http::Request; @@ -374,10 +376,8 @@ async fn persist_tokens_async( if let Some(key) = api_key { auth.openai_api_key = Some(key); } - let tokens = auth - .tokens - .get_or_insert_with(crate::token_data::TokenData::default); - tokens.id_token = crate::token_data::parse_id_token(&id_token).map_err(io::Error::other)?; + let tokens = auth.tokens.get_or_insert_with(TokenData::default); + tokens.id_token = parse_id_token(&id_token).map_err(io::Error::other)?; // Persist chatgpt_account_id if present in claims if let Some(acc) = jwt_auth_claims(&id_token) .get("chatgpt_account_id") @@ -392,14 +392,14 @@ async fn persist_tokens_async( tokens.refresh_token = rt; } auth.last_refresh = Some(Utc::now()); - super::write_auth_json(&auth_file, &auth) + codex_core::auth::write_auth_json(&auth_file, &auth) }) .await .map_err(|e| io::Error::other(format!("persist task failed: {e}")))? } fn read_or_default(path: &Path) -> AuthDotJson { - match super::try_read_auth_json(path) { + match codex_core::auth::try_read_auth_json(path) { Ok(auth) => auth, Err(_) => AuthDotJson { openai_api_key: None, diff --git a/codex-rs/mcp-server/src/codex_message_processor.rs b/codex-rs/mcp-server/src/codex_message_processor.rs index aae463ad..72474d98 100644 --- a/codex-rs/mcp-server/src/codex_message_processor.rs +++ b/codex-rs/mcp-server/src/codex_message_processor.rs @@ -3,6 +3,7 @@ use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; +use codex_core::AuthManager; use codex_core::CodexConversation; use codex_core::ConversationManager; use codex_core::NewConversation; @@ -16,7 +17,6 @@ use codex_core::protocol::Event; use codex_core::protocol::EventMsg; use codex_core::protocol::ExecApprovalRequestEvent; use codex_core::protocol::ReviewDecision; -use codex_login::AuthManager; use codex_protocol::mcp_protocol::AuthMode; use codex_protocol::mcp_protocol::GitDiffToRemoteResponse; use mcp_types::JSONRPCErrorError; @@ -31,9 +31,9 @@ use crate::error_code::INVALID_REQUEST_ERROR_CODE; use crate::json_to_toml::json_to_toml; use crate::outgoing_message::OutgoingMessageSender; use crate::outgoing_message::OutgoingNotification; +use codex_core::auth::CLIENT_ID; use codex_core::protocol::InputItem as CoreInputItem; use codex_core::protocol::Op; -use codex_login::CLIENT_ID; use codex_login::ServerOptions as LoginServerOptions; use codex_login::ShutdownHandle; use codex_login::run_login_server; diff --git a/codex-rs/mcp-server/src/message_processor.rs b/codex-rs/mcp-server/src/message_processor.rs index 6be60151..dbd1077d 100644 --- a/codex-rs/mcp-server/src/message_processor.rs +++ b/codex-rs/mcp-server/src/message_processor.rs @@ -10,10 +10,10 @@ use crate::error_code::INVALID_REQUEST_ERROR_CODE; use crate::outgoing_message::OutgoingMessageSender; use codex_protocol::mcp_protocol::ClientRequest; +use codex_core::AuthManager; use codex_core::ConversationManager; use codex_core::config::Config; use codex_core::protocol::Submission; -use codex_login::AuthManager; use mcp_types::CallToolRequestParams; use mcp_types::CallToolResult; use mcp_types::ClientRequest as McpClientRequest; diff --git a/codex-rs/mcp-server/tests/suite/auth.rs b/codex-rs/mcp-server/tests/suite/auth.rs index 533cb903..3bf97284 100644 --- a/codex-rs/mcp-server/tests/suite/auth.rs +++ b/codex-rs/mcp-server/tests/suite/auth.rs @@ -1,6 +1,6 @@ use std::path::Path; -use codex_login::login_with_api_key; +use codex_core::auth::login_with_api_key; use codex_protocol::mcp_protocol::AuthMode; use codex_protocol::mcp_protocol::GetAuthStatusParams; use codex_protocol::mcp_protocol::GetAuthStatusResponse; diff --git a/codex-rs/mcp-server/tests/suite/login.rs b/codex-rs/mcp-server/tests/suite/login.rs index 7a796c01..ba5021c1 100644 --- a/codex-rs/mcp-server/tests/suite/login.rs +++ b/codex-rs/mcp-server/tests/suite/login.rs @@ -1,7 +1,7 @@ use std::path::Path; use std::time::Duration; -use codex_login::login_with_api_key; +use codex_core::auth::login_with_api_key; use codex_protocol::mcp_protocol::CancelLoginChatGptParams; use codex_protocol::mcp_protocol::CancelLoginChatGptResponse; use codex_protocol::mcp_protocol::GetAuthStatusParams; diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 5a74b458..439dbcdf 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -7,10 +7,10 @@ use crate::pager_overlay::Overlay; use crate::tui; use crate::tui::TuiEvent; use codex_ansi_escape::ansi_escape_line; +use codex_core::AuthManager; use codex_core::ConversationManager; use codex_core::config::Config; use codex_core::protocol::TokenUsage; -use codex_login::AuthManager; use color_eyre::eyre::Result; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index f7767f26..c873ac8d 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -2042,7 +2042,7 @@ mod tests { composer.handle_paste(paste.clone()); composer .textarea - .set_cursor((placeholder.len() - pos_from_end) as usize); + .set_cursor(placeholder.len() - pos_from_end); composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); let result = ( composer.textarea.text().contains(&placeholder), diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 75bf3ebd..0e9d3dd6 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -798,7 +798,7 @@ impl ChatWidget { self.app_event_tx.send(AppEvent::ExitRequest); } SlashCommand::Logout => { - if let Err(e) = codex_login::logout(&self.config.codex_home) { + if let Err(e) = codex_core::auth::logout(&self.config.codex_home) { tracing::error!("failed to logout: {e}"); } self.app_event_tx.send(AppEvent::ExitRequest); diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 4ad82c75..3ca1b841 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -1,6 +1,7 @@ use super::*; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; +use codex_core::CodexAuth; use codex_core::config::Config; use codex_core::config::ConfigOverrides; use codex_core::config::ConfigToml; @@ -23,7 +24,6 @@ use codex_core::protocol::PatchApplyEndEvent; use codex_core::protocol::StreamErrorEvent; use codex_core::protocol::TaskCompleteEvent; use codex_core::protocol::TaskStartedEvent; -use codex_login::CodexAuth; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 290d0b19..1d81fade 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -8,6 +8,8 @@ use base64::Engine; use codex_ansi_escape::ansi_escape_line; use codex_common::create_config_summary_entries; use codex_common::elapsed::format_duration; +use codex_core::auth::get_auth_file; +use codex_core::auth::try_read_auth_json; use codex_core::config::Config; use codex_core::plan_tool::PlanItemArg; use codex_core::plan_tool::StepStatus; @@ -18,8 +20,6 @@ use codex_core::protocol::McpInvocation; use codex_core::protocol::SandboxPolicy; use codex_core::protocol::SessionConfiguredEvent; use codex_core::protocol::TokenUsage; -use codex_login::get_auth_file; -use codex_login::try_read_auth_json; use codex_protocol::parse_command::ParsedCommand; use image::DynamicImage; use image::ImageReader; diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 6010c9f0..17506094 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -4,7 +4,9 @@ #![deny(clippy::print_stdout, clippy::print_stderr)] #![deny(clippy::disallowed_methods)] use app::App; +use codex_core::AuthManager; use codex_core::BUILT_IN_OSS_MODEL_PROVIDER_ID; +use codex_core::CodexAuth; use codex_core::config::Config; use codex_core::config::ConfigOverrides; use codex_core::config::ConfigToml; @@ -12,11 +14,9 @@ use codex_core::config::find_codex_home; use codex_core::config::load_config_as_toml_with_cli_overrides; use codex_core::protocol::AskForApproval; use codex_core::protocol::SandboxPolicy; -use codex_login::AuthManager; -use codex_login::AuthMode; -use codex_login::CodexAuth; use codex_ollama::DEFAULT_OSS_MODEL; use codex_protocol::config_types::SandboxMode; +use codex_protocol::mcp_protocol::AuthMode; use std::fs::OpenOptions; use std::path::PathBuf; use tracing::error; diff --git a/codex-rs/tui/src/onboarding/auth.rs b/codex-rs/tui/src/onboarding/auth.rs index 7f350fe3..c7217ac1 100644 --- a/codex-rs/tui/src/onboarding/auth.rs +++ b/codex-rs/tui/src/onboarding/auth.rs @@ -1,7 +1,7 @@ #![allow(clippy::unwrap_used)] -use codex_login::AuthManager; -use codex_login::CLIENT_ID; +use codex_core::AuthManager; +use codex_core::auth::CLIENT_ID; use codex_login::ServerOptions; use codex_login::ShutdownHandle; use codex_login::run_login_server; @@ -19,7 +19,7 @@ use ratatui::widgets::Paragraph; use ratatui::widgets::WidgetRef; use ratatui::widgets::Wrap; -use codex_login::AuthMode; +use codex_protocol::mcp_protocol::AuthMode; use std::sync::RwLock; use crate::LoginStatus; diff --git a/codex-rs/tui/src/onboarding/onboarding_screen.rs b/codex-rs/tui/src/onboarding/onboarding_screen.rs index 58ff5a44..5afd393c 100644 --- a/codex-rs/tui/src/onboarding/onboarding_screen.rs +++ b/codex-rs/tui/src/onboarding/onboarding_screen.rs @@ -1,5 +1,5 @@ +use codex_core::AuthManager; use codex_core::git_info::get_git_repo_root; -use codex_login::AuthManager; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; @@ -9,7 +9,7 @@ use ratatui::prelude::Widget; use ratatui::widgets::Clear; use ratatui::widgets::WidgetRef; -use codex_login::AuthMode; +use codex_protocol::mcp_protocol::AuthMode; use crate::LoginStatus; use crate::onboarding::auth::AuthModeWidget;