From 6661caf1319ec059dff8ca25e98a5d4fc9d842cf Mon Sep 17 00:00:00 2001 From: Rahul Thathoo Date: Sat, 9 May 2026 16:38:42 -0700 Subject: [PATCH] validate api key before login success --- codex-rs/app-server/src/request_processors.rs | 1 + .../request_processors/account_processor.rs | 17 ++++++ codex-rs/app-server/tests/suite/v2/account.rs | 57 ++++++++++++++++++- codex-rs/model-provider/src/lib.rs | 1 + .../model-provider/src/models_endpoint.rs | 25 ++++++++ 5 files changed, 100 insertions(+), 1 deletion(-) diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index 9de844c6cd..3c3e3a585b 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -343,6 +343,7 @@ use codex_mcp::resolve_oauth_scopes; use codex_memories_write::clear_memory_roots_contents; use codex_model_provider::ProviderAccountError; use codex_model_provider::create_model_provider; +use codex_model_provider::validate_api_key_with_models_endpoint; use codex_models_manager::collaboration_mode_presets::builtin_collaboration_mode_presets; use codex_protocol::ThreadId; use codex_protocol::config_types::CollaborationMode; diff --git a/codex-rs/app-server/src/request_processors/account_processor.rs b/codex-rs/app-server/src/request_processors/account_processor.rs index c73d6700e7..d174aca80b 100644 --- a/codex-rs/app-server/src/request_processors/account_processor.rs +++ b/codex-rs/app-server/src/request_processors/account_processor.rs @@ -271,6 +271,8 @@ impl AccountRequestProcessor { } } + self.validate_api_key(¶ms.api_key).await?; + match login_with_api_key( &self.config.codex_home, ¶ms.api_key, @@ -284,6 +286,21 @@ impl AccountRequestProcessor { } } + async fn validate_api_key(&self, api_key: &str) -> Result<(), JSONRPCErrorError> { + if !self.config.model_provider.requires_openai_auth { + return Ok(()); + } + + validate_api_key_with_models_endpoint(self.config.model_provider.clone(), api_key) + .await + .map_err(|err| match err { + CodexErr::UnexpectedStatus(err) if matches!(err.status.as_u16(), 401 | 403) => { + invalid_request("API key is invalid or unusable.") + } + err => internal_error(format!("failed to validate api key: {err}")), + }) + } + async fn login_api_key_v2(&self, request_id: ConnectionRequestId, params: LoginApiKeyParams) { let result = self .login_api_key_common(¶ms) diff --git a/codex-rs/app-server/tests/suite/v2/account.rs b/codex-rs/app-server/tests/suite/v2/account.rs index 50c365d633..ce06afdec5 100644 --- a/codex-rs/app-server/tests/suite/v2/account.rs +++ b/codex-rs/app-server/tests/suite/v2/account.rs @@ -883,7 +883,23 @@ async fn external_auth_refresh_invalid_access_token_fails_turn() -> Result<()> { #[tokio::test] async fn login_account_api_key_succeeds_and_notifies() -> Result<()> { let codex_home = TempDir::new()?; - create_config_toml(codex_home.path(), CreateConfigTomlParams::default())?; + let mock_server = MockServer::start().await; + create_config_toml( + codex_home.path(), + CreateConfigTomlParams { + requires_openai_auth: Some(true), + base_url: Some(format!("{}/v1", mock_server.uri())), + ..Default::default() + }, + )?; + Mock::given(method("GET")) + .and(path("/v1/models")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "models": [] + }))) + .expect(1) + .mount(&mock_server) + .await; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -928,6 +944,45 @@ async fn login_account_api_key_succeeds_and_notifies() -> Result<()> { Ok(()) } +#[tokio::test] +async fn login_account_api_key_rejects_unusable_key_before_persisting() -> Result<()> { + let codex_home = TempDir::new()?; + let mock_server = MockServer::start().await; + create_config_toml( + codex_home.path(), + CreateConfigTomlParams { + requires_openai_auth: Some(true), + base_url: Some(format!("{}/v1", mock_server.uri())), + ..Default::default() + }, + )?; + + Mock::given(method("GET")) + .and(path("/v1/models")) + .respond_with(ResponseTemplate::new(401).set_body_json(json!({ + "error": { "message": "Invalid API key" } + }))) + .expect(1) + .mount(&mock_server) + .await; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_login_account_api_key_request("sk-invalid-key") + .await?; + let err: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(err.error.message, "API key is invalid or unusable."); + assert!(!codex_home.path().join("auth.json").exists()); + Ok(()) +} + #[tokio::test] async fn login_account_api_key_rejected_when_forced_chatgpt() -> Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/model-provider/src/lib.rs b/codex-rs/model-provider/src/lib.rs index 4e4660812b..857cb4edd9 100644 --- a/codex-rs/model-provider/src/lib.rs +++ b/codex-rs/model-provider/src/lib.rs @@ -9,6 +9,7 @@ pub use auth::unauthenticated_auth_provider; pub use bearer_auth_provider::BearerAuthProvider; pub use bearer_auth_provider::BearerAuthProvider as CoreAuthProvider; pub use codex_protocol::account::ProviderAccount; +pub use models_endpoint::validate_api_key_with_models_endpoint; pub use provider::ModelProvider; pub use provider::ProviderAccountError; pub use provider::ProviderAccountResult; diff --git a/codex-rs/model-provider/src/models_endpoint.rs b/codex-rs/model-provider/src/models_endpoint.rs index 8a72beea70..142868bd6f 100644 --- a/codex-rs/model-provider/src/models_endpoint.rs +++ b/codex-rs/model-provider/src/models_endpoint.rs @@ -16,6 +16,7 @@ use codex_login::CodexAuth; use codex_login::collect_auth_env_telemetry; use codex_login::default_client::build_reqwest_client; use codex_model_provider_info::ModelProviderInfo; +use codex_models_manager::client_version_to_whole; use codex_models_manager::manager::ModelsEndpointClient; use codex_otel::TelemetryAuthMode; use codex_protocol::error::CodexErr; @@ -36,6 +37,7 @@ const MODELS_ENDPOINT: &str = "/models"; pub(crate) struct OpenAiModelsEndpoint { provider_info: ModelProviderInfo, auth_manager: Option>, + auth_override: Option, } impl OpenAiModelsEndpoint { @@ -46,10 +48,23 @@ impl OpenAiModelsEndpoint { Self { provider_info, auth_manager, + auth_override: None, + } + } + + fn with_auth(provider_info: ModelProviderInfo, auth: CodexAuth) -> Self { + Self { + provider_info, + auth_manager: None, + auth_override: Some(auth), } } async fn auth(&self) -> Option { + if let Some(auth) = self.auth_override.as_ref() { + return Some(auth.clone()); + } + match self.auth_manager.as_ref() { Some(auth_manager) => auth_manager.auth().await, None => None, @@ -65,6 +80,16 @@ impl OpenAiModelsEndpoint { } } +pub async fn validate_api_key_with_models_endpoint( + provider_info: ModelProviderInfo, + api_key: &str, +) -> CoreResult<()> { + OpenAiModelsEndpoint::with_auth(provider_info, CodexAuth::from_api_key(api_key)) + .list_models(&client_version_to_whole()) + .await + .map(|_| ()) +} + #[async_trait] impl ModelsEndpointClient for OpenAiModelsEndpoint { fn has_command_auth(&self) -> bool {