diff --git a/backend/crates/providers/openai/src/admin/mod.rs b/backend/crates/providers/openai/src/admin/mod.rs index e91201361..e810f25b5 100644 --- a/backend/crates/providers/openai/src/admin/mod.rs +++ b/backend/crates/providers/openai/src/admin/mod.rs @@ -1867,12 +1867,15 @@ fn log_import_failure(stage: &'static str, error: &'static str) { } fn map_oauth_error(error: CodexOAuthAdminError) -> ProviderAdminError { + use crate::credential::token_client::AuthorizationCodeExchangeError as Exchange; use CodexOAuthAdminError as Error; use ProviderAdminErrorKind as Kind; - let (kind, message) = match error { + let (kind, message) = match &error { Error::InvalidInput => (Kind::Invalid, "OpenAI 授权参数不合法,请重新发起授权"), Error::CallbackRejected => (Kind::Invalid, "OpenAI 授权回调校验失败,请重新发起授权"), - Error::TokenRejected => (Kind::Invalid, "OpenAI 拒绝了授权码,请重新发起授权"), + Error::TokenRejected | Error::Exchange(Exchange::Rejected) => { + (Kind::Invalid, "OpenAI 拒绝了授权码,请重新发起授权") + } Error::Credential => (Kind::Invalid, "OpenAI 授权返回的凭据不完整或无效"), Error::NotFound | Error::FlowExpired => ( Kind::NotFound, @@ -1882,29 +1885,51 @@ fn map_oauth_error(error: CodexOAuthAdminError) -> ProviderAdminError { Kind::Conflict, "OpenAI 授权流程正在处理或已被更新,请先检查授权状态", ), - Error::Ambiguous => ( + Error::Exchange(Exchange::Ambiguous { .. }) => ( Kind::Ambiguous, "OpenAI 授权结果未知,请先核对账号状态,不要立即重复提交", ), - Error::UpstreamUnavailable => ( + Error::Exchange(Exchange::Connection { + timed_out: true, .. + }) => ( + Kind::Unavailable, + "连接 OpenAI 授权服务超时,请检查出站连接", + ), + Error::Exchange(Exchange::Connection { + timed_out: false, .. + }) => ( + Kind::Unavailable, + "无法连接 OpenAI 授权服务,请检查出站连接", + ), + Error::Exchange(Exchange::Unavailable) => ( Kind::Unavailable, "OpenAI 授权服务暂不可用,请检查出站连接后重试", ), Error::StorageUnavailable => (Kind::Unavailable, "OpenAI 授权状态存储暂不可用,请稍后重试"), }; - provider_admin_error(kind).with_public_message(message) + provider_admin_error(kind) + .with_public_message(message) + .with_source(error) } const fn oauth_error_code(error: &CodexOAuthAdminError) -> &'static str { + use crate::credential::token_client::AuthorizationCodeExchangeError as Exchange; match error { CodexOAuthAdminError::InvalidInput => "invalid_input", CodexOAuthAdminError::NotFound => "not_found", CodexOAuthAdminError::Conflict => "conflict", CodexOAuthAdminError::FlowExpired => "flow_expired", CodexOAuthAdminError::CallbackRejected => "callback_rejected", - CodexOAuthAdminError::TokenRejected => "token_rejected", - CodexOAuthAdminError::UpstreamUnavailable => "upstream_unavailable", - CodexOAuthAdminError::Ambiguous => "ambiguous", + CodexOAuthAdminError::TokenRejected + | CodexOAuthAdminError::Exchange(Exchange::Rejected) => "token_rejected", + CodexOAuthAdminError::Exchange(Exchange::Unavailable) => "upstream_unavailable", + CodexOAuthAdminError::Exchange(Exchange::Connection { + timed_out: true, .. + }) => "connect_timeout", + CodexOAuthAdminError::Exchange(Exchange::Connection { + timed_out: false, .. + }) => "connect_failed", + CodexOAuthAdminError::Exchange(Exchange::Ambiguous { .. }) => "ambiguous", CodexOAuthAdminError::StorageUnavailable => "storage_unavailable", CodexOAuthAdminError::Credential => "credential", } diff --git a/backend/crates/providers/openai/src/credential/oauth.rs b/backend/crates/providers/openai/src/credential/oauth.rs index 08ce5a77d..0843131aa 100644 --- a/backend/crates/providers/openai/src/credential/oauth.rs +++ b/backend/crates/providers/openai/src/credential/oauth.rs @@ -318,7 +318,7 @@ pub enum CodexOAuthPendingClaimOutcome { InProgress, } -#[derive(Debug, PartialEq, Eq, thiserror::Error)] +#[derive(Debug, thiserror::Error)] pub enum CodexOAuthAdminError { #[error("invalid Codex OAuth admin input")] InvalidInput, @@ -332,10 +332,8 @@ pub enum CodexOAuthAdminError { CallbackRejected, #[error("Codex OAuth token exchange was rejected")] TokenRejected, - #[error("Codex OAuth upstream is unavailable")] - UpstreamUnavailable, - #[error("Codex OAuth exchange send state is ambiguous")] - Ambiguous, + #[error("Codex OAuth token exchange failed")] + Exchange(#[source] AuthorizationCodeExchangeError), #[error("Codex OAuth pending storage is unavailable")] StorageUnavailable, #[error("Codex OAuth account mutation failed")] @@ -702,7 +700,7 @@ impl CodexOAuthAdminService { proxy, ) .await - .map_err(map_exchange_error)?; + .map_err(CodexOAuthAdminError::Exchange)?; let mut secret = tokens.secret; if secret.refresh_token.is_none() { secret.refresh_token = fallback_refresh_token; @@ -905,14 +903,6 @@ fn map_authorization_settlement_error(error: CodexOAuthPendingStoreError) -> Adm } } -fn map_exchange_error(error: AuthorizationCodeExchangeError) -> CodexOAuthAdminError { - match error { - AuthorizationCodeExchangeError::Rejected => CodexOAuthAdminError::TokenRejected, - AuthorizationCodeExchangeError::Unavailable => CodexOAuthAdminError::UpstreamUnavailable, - AuthorizationCodeExchangeError::Ambiguous => CodexOAuthAdminError::Ambiguous, - } -} - fn map_admin_error(_: CodexCredentialAdminError) -> CodexOAuthAdminError { CodexOAuthAdminError::Credential } diff --git a/backend/crates/providers/openai/src/credential/token_client.rs b/backend/crates/providers/openai/src/credential/token_client.rs index 177b77da9..0e680698e 100644 --- a/backend/crates/providers/openai/src/credential/token_client.rs +++ b/backend/crates/providers/openai/src/credential/token_client.rs @@ -15,8 +15,9 @@ use std::fmt; use std::time::Duration; const MAX_OAUTH_RESPONSE_BYTES: usize = 64 * 1024; -const TOKEN_REQUEST_TIMEOUT: Duration = Duration::from_secs(30); -const TOKEN_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); +const TOKEN_CONNECT_TIMEOUT: Duration = Duration::from_secs(30); +// 总期限为连接后的响应留出预算,避免先于连接期限触发而丢失连接失败分类 +const TOKEN_REQUEST_TIMEOUT: Duration = Duration::from_secs(60); /// Codex Desktop 使用的官方 OAuth public client pub const OFFICIAL_CODEX_OAUTH_CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann"; @@ -332,15 +333,24 @@ impl fmt::Debug for AuthorizationTokenSet { } } -/// Authorization Code exchange 的低基数失败 -#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +/// Authorization Code exchange 的失败分类及受控原因链 +#[derive(Debug, Clone, thiserror::Error)] pub enum AuthorizationCodeExchangeError { #[error("authorization code was rejected")] Rejected, #[error("authorization code exchange is unavailable")] Unavailable, + #[error("authorization code connection failed (request URL redacted)")] + Connection { + timed_out: bool, + #[source] + source: gateway_core::error::ErrorSource, + }, #[error("authorization code exchange send state is ambiguous")] - Ambiguous, + Ambiguous { + #[source] + source: gateway_core::error::ErrorSource, + }, } #[async_trait] @@ -646,15 +656,23 @@ impl AuthorizationCodeExchanger for OpenAiTokenClient { .send() .await .map_err(|error| { - if is_safe_to_retry_refresh_transport(&error) { - AuthorizationCodeExchangeError::Unavailable + // 连接阶段包含代理隧道和 TLS 握手,此时授权码尚未发往上游 + if error.is_connect() { + AuthorizationCodeExchangeError::Connection { + timed_out: error.is_timeout(), + source: error.without_url().into(), + } } else { - AuthorizationCodeExchangeError::Ambiguous + AuthorizationCodeExchangeError::Ambiguous { + source: error.without_url().into(), + } } })?; - let (status, body) = read_bounded_response(response) - .await - .map_err(|_| AuthorizationCodeExchangeError::Ambiguous)?; + let (status, body) = read_bounded_response(response).await.map_err(|error| { + AuthorizationCodeExchangeError::Ambiguous { + source: error.into(), + } + })?; if !status.is_success() { return Err(match status.as_u16() { 429 | 500..=599 => AuthorizationCodeExchangeError::Unavailable, diff --git a/backend/crates/providers/openai/tests/admin/mod.rs b/backend/crates/providers/openai/tests/admin/mod.rs index bba3d6d74..8fbe9be35 100644 --- a/backend/crates/providers/openai/tests/admin/mod.rs +++ b/backend/crates/providers/openai/tests/admin/mod.rs @@ -833,6 +833,113 @@ async fn openai_admin_provider_persists_the_full_pending_envelope_and_binds_owne assert_eq!(pending.values.lock().expect("OAuth pending").len(), 1); } +#[tokio::test] +async fn oauth_connection_failures_preserve_safe_diagnostics_and_release_the_claim() { + use std::error::Error as _; + use tokio::io::AsyncReadExt as _; + + for timed_out in [false, true] { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let proxy_task = if timed_out { + Some(tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut request = [0; 4096]; + let count = socket.read(&mut request).await.unwrap(); + assert!(request[..count].starts_with(b"CONNECT ")); + std::future::pending::<()>().await; + })) + } else { + drop(listener); + None + }; + let pending = Arc::new(TestOAuthPending::default()); + let mut config = valid_config(); + config.config.auth.oauth_token_endpoint = + "https://oauth.example.invalid/oauth/token?PRIVATE_URL_MARKER".to_owned(); + let bundle = provider_openai::initialize( + config.config.clone(), + provider_ports_with(Arc::new(MemoryAccountStore::default()), pending.clone()), + Arc::new(gateway_core::account::UpstreamInfoReader::default()), + ) + .await + .unwrap(); + let context = MutationContext { + actor: MutationActor::System, + request_id: "oauth-connection-failure".to_owned(), + }; + let started = bundle + .admin_provider() + .start_authorization( + PendingAuthorizationMutation::new( + ProviderKind::new("openai").unwrap(), + AuthorizationMutationTarget::Create { + name: String::new(), + }, + AuthorizationOwnerBinding::from_context(&context), + ) + .with_outbound_proxy(Some( + gateway_core::account::OutboundProxy::parse(&format!( + "http://proxy-user:PRIVATE_PROXY_PASSWORD@{address}" + )) + .unwrap(), + )), + ) + .await + .unwrap(); + let state = { + let values = pending.values.lock().unwrap(); + let (_, payload, _, _) = values.values().next().unwrap(); + payload.expose_to_provider()["state"] + .as_str() + .unwrap() + .to_owned() + }; + let error = bundle + .admin_provider() + .complete_authorization(CompleteAuthorization { + settings: None, + context, + flow_id: started.flow_id, + callback_url: format!( + "http://127.0.0.1:1455/auth/callback?code=PRIVATE_CODE&state={state}" + ), + }) + .await + .unwrap_err(); + if let Some(task) = proxy_task { + task.abort(); + assert!(task.await.unwrap_err().is_cancelled()); + } + assert_eq!(error.kind(), ProviderAdminErrorKind::Unavailable); + assert_eq!( + error.public_message(), + Some(if timed_out { + "连接 OpenAI 授权服务超时,请检查出站连接" + } else { + "无法连接 OpenAI 授权服务,请检查出站连接" + }), + ); + let mut source = error.source(); + let transport = loop { + let cause = source.expect("transport cause must reach the admin boundary"); + if let Some(transport) = cause.downcast_ref::() { + break transport; + } + source = cause.source(); + }; + assert!(transport.is_connect()); + assert_eq!(transport.is_timeout(), timed_out); + assert!(transport.url().is_none()); + assert!(!format!("{error:?} {error}").contains("PRIVATE_")); + let details = + gateway_core::error::ErrorDetails::capture(Some(&error.into()), None, true).unwrap(); + assert!(!details.as_str().contains("PRIVATE_")); + let values = pending.values.lock().unwrap(); + assert!(values.values().next().unwrap().3.is_none()); + } +} + #[tokio::test] async fn openai_reauthorization_pending_payload_reuses_the_account_installation_id() { let accounts = Arc::new(MemoryAccountStore::default()); diff --git a/backend/crates/providers/openai/tests/credential/oauth.rs b/backend/crates/providers/openai/tests/credential/oauth.rs index 1686376ed..d8e72b71b 100644 --- a/backend/crates/providers/openai/tests/credential/oauth.rs +++ b/backend/crates/providers/openai/tests/credential/oauth.rs @@ -567,5 +567,5 @@ async fn first_exchange_permits_missing_identity_claims_but_requires_a_parseable let error = complete("header.not-base64.signature".to_owned()) .await .expect_err("official local payload parsing rejects malformed id tokens"); - assert_eq!(error, CodexOAuthAdminError::TokenRejected); + assert!(matches!(error, CodexOAuthAdminError::TokenRejected)); } diff --git a/backend/crates/providers/openai/tests/credential/token_client.rs b/backend/crates/providers/openai/tests/credential/token_client.rs index f0581546d..ecf6f1471 100644 --- a/backend/crates/providers/openai/tests/credential/token_client.rs +++ b/backend/crates/providers/openai/tests/credential/token_client.rs @@ -365,6 +365,68 @@ async fn authorization_code_exchange_keeps_upstream_access_and_refresh_tokens_ve ); } +#[tokio::test] +async fn authorization_code_response_timeouts_remain_ambiguous_without_exposing_request_material() { + use std::error::Error as _; + use std::time::Duration; + use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; + + for partial_body in [false, true] { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut request = [0; 4096]; + let count = socket.read(&mut request).await.unwrap(); + assert!(request[..count].starts_with(b"POST ")); + if partial_body { + socket.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\n{\"access_token\":\"PRIVATE_BODY").await.unwrap(); + } + std::future::pending::<()>().await; + }); + let client = OpenAiTokenClient::new( + reqwest::Client::builder() + .no_proxy() + .timeout(Duration::from_millis(250)) + .build() + .unwrap(), + TokenClientConfig { + client_id: "test-public-client".to_owned(), + token_endpoint: format!("http://{address}/oauth/token?PRIVATE_URL_MARKER"), + }, + provider_openai::transport::profile::CodexWireProfileState::new(Default::default()), + ); + let error = client + .exchange_authorization_code(AuthorizationCodeGrant { + code: SecretString::from("PRIVATE_CODE"), + code_verifier: SecretString::from("PRIVATE_VERIFIER"), + }) + .await + .unwrap_err(); + server.abort(); + assert!(server.await.unwrap_err().is_cancelled()); + assert!(matches!( + error, + AuthorizationCodeExchangeError::Ambiguous { .. } + )); + let mut source = error.source(); + let transport = loop { + let cause = source.expect("transport cause must survive classification"); + if let Some(transport) = cause.downcast_ref::() { + break transport; + } + source = cause.source(); + }; + assert!(transport.is_timeout()); + assert!(!transport.is_connect()); + assert!(transport.url().is_none()); + assert!(!format!("{error:?} {error}").contains("PRIVATE_")); + let details = + gateway_core::error::ErrorDetails::capture(Some(&error.into()), None, true).unwrap(); + assert!(!details.as_str().contains("PRIVATE_")); + } +} + #[tokio::test] async fn authorization_code_exchange_requires_id_token_and_json_response() { let missing_id = MockServer::start().await; @@ -393,7 +455,7 @@ async fn authorization_code_exchange_requires_id_token_and_json_response() { }) .await .expect_err("invalid token response fails closed"); - assert_eq!(error, AuthorizationCodeExchangeError::Rejected); + assert!(matches!(error, AuthorizationCodeExchangeError::Rejected)); } } diff --git a/docs/api.md b/docs/api.md index af026472a..ef75621ef 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1556,6 +1556,12 @@ OAuth start 使用: 账号提交成功后,`complete` 保留 24 小时的完成结果。同一管理员使用相同 `provider`、`flowId` 重试时返回原账号结果,不再执行授权或更新凭据;新提交的 `callbackUrl` 和 `settings` 不会再次应用 +OpenAI 授权码交换与令牌刷新请求的连接超时为 30 秒,单次上游请求总超时为 60 秒 + +- 授权码交换未能建立连接时返回 `50301`,提示区分连接超时与其他连接失败 +- 无法确认发送结果或未完整接收响应时返回 `50202`,应先核对账号状态,不立即重复提交授权码 +- 代理出口探测成功不代表 OpenAI 授权端点可达 + ### OpenAI 身份、额度与状态 - OAuth 文件导入接受 camelCase 与 snake_case 的三个 token 字段,内部统一保存为