diff --git a/backend/crates/gateway-admin/src/ports/provider.rs b/backend/crates/gateway-admin/src/ports/provider.rs index 0ff37896f..6986105cf 100644 --- a/backend/crates/gateway-admin/src/ports/provider.rs +++ b/backend/crates/gateway-admin/src/ports/provider.rs @@ -40,11 +40,13 @@ pub enum ProviderAdminErrorKind { /// /// `message` 是 Provider 局部诊断,可能包含原始上游正文;通用管理用例不得自动把它作为公开文案。 /// 只有明确拥有原始诊断合同的调用方才能读取,`Debug` 始终只记录是否存在。 +/// `public_message` 则是明确标记为可公开的静态提示,不允许携带动态上游材料。 #[derive(Clone, PartialEq, Eq, thiserror::Error)] #[error("provider admin operation failed: {kind:?}")] pub struct ProviderAdminError { kind: ProviderAdminErrorKind, message: Option, + public_message: Option<&'static str>, } impl std::fmt::Debug for ProviderAdminError { @@ -53,6 +55,7 @@ impl std::fmt::Debug for ProviderAdminError { .debug_struct("ProviderAdminError") .field("kind", &self.kind) .field("message", &self.message.as_ref().map(|_| "")) + .field("public_message", &self.public_message) .finish() } } @@ -63,6 +66,7 @@ impl ProviderAdminError { Self { kind, message: None, + public_message: None, } } @@ -72,6 +76,18 @@ impl ProviderAdminError { self } + /// Provider 明确允许公开的静态提示;不得承载上游响应或凭据材料。 + #[must_use] + pub const fn with_public_message(mut self, message: &'static str) -> Self { + self.public_message = Some(message); + self + } + + #[must_use] + pub const fn public_message(&self) -> Option<&'static str> { + self.public_message + } + #[must_use] pub const fn kind(&self) -> ProviderAdminErrorKind { self.kind diff --git a/backend/crates/gateway-admin/src/use_case/mod.rs b/backend/crates/gateway-admin/src/use_case/mod.rs index 4223529d2..28d51e67c 100644 --- a/backend/crates/gateway-admin/src/use_case/mod.rs +++ b/backend/crates/gateway-admin/src/use_case/mod.rs @@ -83,7 +83,7 @@ fn map_provider_error( AdminErrorKind::Internal => "服务内部错误", _ => "Provider 操作失败", }; - AdminError::new(kind, message) + AdminError::new(kind, error.public_message().unwrap_or(message)) } async fn publish_committed( diff --git a/backend/crates/gateway-admin/tests/use_case/accounts.rs b/backend/crates/gateway-admin/tests/use_case/accounts.rs index 5f265678c..dc1c8c969 100644 --- a/backend/crates/gateway-admin/tests/use_case/accounts.rs +++ b/backend/crates/gateway-admin/tests/use_case/accounts.rs @@ -105,6 +105,18 @@ impl FakeProviderAdmin { Some(ProviderAdminError::new(kind).with_message(message)); } + pub(super) fn fail_next_with_public_message( + &self, + kind: ProviderAdminErrorKind, + message: &'static str, + ) { + *self.failure.lock().expect("provider failure") = Some( + ProviderAdminError::new(kind) + .with_message("upstream body containing a secret token") + .with_public_message(message), + ); + } + pub(super) fn fail_next_quota(&self, kind: ProviderAdminErrorKind) { *self.quota_failure.lock().expect("provider quota failure") = Some(kind); } diff --git a/backend/crates/gateway-admin/tests/use_case/openai.rs b/backend/crates/gateway-admin/tests/use_case/openai.rs index 3d83fd2d7..77263e528 100644 --- a/backend/crates/gateway-admin/tests/use_case/openai.rs +++ b/backend/crates/gateway-admin/tests/use_case/openai.rs @@ -78,6 +78,43 @@ async fn openai_import_should_prepare_before_atomic_store_commit() { assert_eq!(store.audit_requests(), ["import-openai"]); } +#[tokio::test] +async fn openai_import_should_expose_only_explicit_public_errors_without_committing() { + use gateway_admin::model::AdminErrorKind; + + for (provider_kind, admin_kind) in [ + (ProviderAdminErrorKind::Invalid, AdminErrorKind::Invalid), + ( + ProviderAdminErrorKind::Unavailable, + AdminErrorKind::Unavailable, + ), + ( + ProviderAdminErrorKind::BadGateway, + AdminErrorKind::BadGateway, + ), + ] { + let events = events(); + let provider = FakeProviderAdmin::new("openai", events.clone()); + provider + .fail_next_with_public_message(provider_kind, "Codex PAT 验证失败,请检查令牌后重试"); + let store = FakeAccountStore::new("openai", events.clone()); + let services = service(provider, store.clone()).await; + let error = services + .openai() + .import_document(ImportCredentials { + context: context("import-openai-pat-failure"), + document: document(), + }) + .await + .expect_err("validation must fail before committing accounts"); + assert_eq!(error.kind(), admin_kind); + assert_eq!(error.to_string(), "Codex PAT 验证失败,请检查令牌后重试"); + assert!(!format!("{error:?}").contains("secret token")); + assert_eq!(recorded(&events), ["provider.prepare_import"]); + assert!(store.audit_requests().is_empty()); + } +} + #[tokio::test] async fn openai_import_should_refresh_quota_for_every_imported_account() { let events = events(); diff --git a/backend/crates/providers/openai/src/admin.rs b/backend/crates/providers/openai/src/admin.rs index cba208630..46a7f25a0 100644 --- a/backend/crates/providers/openai/src/admin.rs +++ b/backend/crates/providers/openai/src/admin.rs @@ -1306,10 +1306,29 @@ fn map_store_error(error: gateway_core::error::StoreError) -> ProviderAdminError } fn map_credential_admin_error(error: CodexCredentialAdminError) -> ProviderAdminError { + use crate::credential::token_client::PersonalAccessTokenError; use CodexCredentialAdminError as Error; let upstream_message = error.upstream_message().map(ToOwned::to_owned); + let public_message = match &error { + Error::PersonalAccessToken(error) => Some(match error { + PersonalAccessTokenError::InvalidToken => { + "Codex PAT 格式无效:应为 at- 开头的完整令牌,不能包含空白或控制字符" + } + PersonalAccessTokenError::Rejected => { + "OpenAI 拒绝了 Codex PAT:令牌可能无效、已过期、已撤销或没有访问权限" + } + PersonalAccessTokenError::Unavailable => "暂时无法向 OpenAI 验证 Codex PAT,请稍后重试", + PersonalAccessTokenError::InvalidResponse => { + "OpenAI 返回的 Codex PAT 身份资料不完整或格式无效,请稍后重试" + } + }), + _ => None, + }; let kind = match error { - Error::InvalidInput + Error::PersonalAccessToken( + PersonalAccessTokenError::InvalidToken | PersonalAccessTokenError::Rejected, + ) + | Error::InvalidInput | Error::InvalidCredential | Error::MissingRefreshToken | Error::RefreshRejected { .. } @@ -1318,9 +1337,17 @@ fn map_credential_admin_error(error: CodexCredentialAdminError) -> ProviderAdmin Error::RefreshLeaseUnavailable | Error::RefreshAmbiguous { .. } => { ProviderAdminErrorKind::Conflict } - Error::RefreshUnavailable => ProviderAdminErrorKind::Unavailable, + Error::PersonalAccessToken(PersonalAccessTokenError::InvalidResponse) => { + ProviderAdminErrorKind::BadGateway + } + Error::PersonalAccessToken(PersonalAccessTokenError::Unavailable) + | Error::RefreshUnavailable => ProviderAdminErrorKind::Unavailable, }; let error = provider_admin_error(kind); + let error = match public_message { + Some(message) => error.with_public_message(message), + None => error, + }; match upstream_message { Some(message) => error.with_message(message), None => error, @@ -1329,6 +1356,9 @@ fn map_credential_admin_error(error: CodexCredentialAdminError) -> ProviderAdmin const fn credential_admin_error_code(error: &CodexCredentialAdminError) -> &'static str { match error { + CodexCredentialAdminError::PersonalAccessToken(_) => { + "personal_access_token_validation_failed" + } CodexCredentialAdminError::InvalidInput => "invalid_input", CodexCredentialAdminError::InvalidCredential => "invalid_credential", CodexCredentialAdminError::NotFound => "not_found", diff --git a/backend/crates/providers/openai/src/credential/admin.rs b/backend/crates/providers/openai/src/credential/admin.rs index 9f38cb0f0..f1c1d12ac 100644 --- a/backend/crates/providers/openai/src/credential/admin.rs +++ b/backend/crates/providers/openai/src/credential/admin.rs @@ -26,7 +26,9 @@ use thiserror::Error; use super::recovery_log::{CodexOAuthRecoveryOperation, record_oauth_recovery}; use super::security::CodexCredentialCodec; -use super::token_client::{RefreshFailure, TokenRefresher}; +use super::token_client::{ + OpenAiTokenClient, PersonalAccessTokenError, RefreshFailure, TokenRefresher, +}; use super::types::{ CODEX_AUTHENTICATION_KIND_OAUTH, CodexAccountProfile, CodexCredentialData, CodexCredentialPrincipal, CodexOAuthMetadata, CodexOAuthSecret, parse_access_token_expiration, @@ -48,8 +50,8 @@ pub struct ImportCodexOAuthCredential { /// OAuth credential 的最小创建输入。 /// -/// metadata 来自 ID token/access token 的官方本地 payload 解析;不包含签名、 -/// issuer、audience、nonce 或 token 验证。 +/// OAuth metadata 来自 ID token/access token 的本地 payload 解析;PAT metadata +/// 来自 auth whoami 验证。两者均不从导入文档信任身份字段。 pub(crate) struct UnresolvedCodexOAuthCredential { pub(crate) account_id: String, pub(crate) name: String, @@ -321,6 +323,8 @@ impl fmt::Debug for PreparedCodexCredentialRotationGuard { #[derive(Debug, Clone, PartialEq, Eq, Error)] pub enum CodexCredentialAdminError { + #[error(transparent)] + PersonalAccessToken(#[from] PersonalAccessTokenError), #[error("Codex account input is invalid")] InvalidInput, #[error("Codex credential JSON is invalid")] @@ -348,7 +352,8 @@ impl CodexCredentialAdminError { Self::RefreshRejected { message } | Self::AccountBanned { message } | Self::RefreshAmbiguous { message } => message.as_deref(), - Self::InvalidInput + Self::PersonalAccessToken(_) + | Self::InvalidInput | Self::InvalidCredential | Self::NotFound | Self::MissingRefreshToken @@ -414,7 +419,7 @@ impl CodexCredentialAdmin { /// 为已取得 OAuth access token、但资料尚未补全的账号创建最小记录。 /// - /// ID token payload 只用于本地字段投影;创建路径绝不发起 usage/profile 请求。 + /// 接受调用方已取得的身份投影;本方法不发起 usage/profile 请求。 pub(crate) fn prepare_unresolved_oauth( &self, input: UnresolvedCodexOAuthCredential, @@ -662,6 +667,7 @@ impl CodexCredentialAdmin { /// 有状态的 Codex 手工刷新边界;消费调用方刚读取的当前 credential 并准备 CAS。 pub struct CodexCredentialAdminService { refresher: Arc, + personal_access_token_client: Option>, leases: Arc, runtime_policy: Arc, } @@ -671,6 +677,10 @@ impl fmt::Debug for CodexCredentialAdminService { formatter .debug_struct("CodexCredentialAdminService") .field("refresher", &"TokenRefresher") + .field( + "personal_access_token_client", + &self.personal_access_token_client.is_some(), + ) .field("leases", &"ProviderLeasePort") .field("runtime_policy", &"ProviderRuntimePolicyPort") .finish() @@ -685,11 +695,19 @@ impl CodexCredentialAdminService { ) -> Self { Self { refresher, + personal_access_token_client: None, leases, runtime_policy, } } + /// 在生产组装时复用 OAuth auth client,为 at- 导入启用上游身份验证。 + #[must_use] + pub fn with_personal_access_token_client(mut self, client: Arc) -> Self { + self.personal_access_token_client = Some(client); + self + } + /// 官方 RT exchange;结果由 App 在同一 revision/audit 事务中提交。 pub async fn manual_refresh( &self, @@ -793,6 +811,7 @@ impl CodexCredentialAdminService { /// /// OAuth 导入先取得 access token(直接提供或 RT exchange),再按官方 /// `parse_chatgpt_jwt_claims` 从 ID token/access token 本地投影账号资料。 + /// at- PAT 使用 whoami 取得身份,并丢弃不适用的 RT、ID token 和刷新计划。 pub async fn prepare_import_document( &self, payload: Value, @@ -813,7 +832,7 @@ impl CodexCredentialAdminService { let account_id = format!("acct_{}", uuid::Uuid::now_v7().simple()); let typed_account_id = ProviderAccountId::new(account_id.clone()) .map_err(|_| CodexCredentialAdminError::InvalidInput)?; - let (secret, access_token_expires_at) = self + let (mut secret, mut access_token_expires_at) = self .resolve_import_tokens( &typed_account_id, candidate.authentication.access_token.clone(), @@ -821,26 +840,47 @@ impl CodexCredentialAdminService { candidate.authentication.id_token.clone(), ) .await?; - // 与官方 `parse_chatgpt_jwt_claims` 一致:先读 ID token,缺失字段 - // 再由 access token 补齐。解析失败不阻塞 token 入库。 - let id_metadata = secret - .id_token - .as_ref() - .and_then(|token| parse_chatgpt_jwt_claims(token.expose_secret()).ok()) - .unwrap_or_default(); - let access_metadata = - parse_chatgpt_jwt_claims(secret.access_token.expose_secret()).unwrap_or_default(); - let metadata = CodexOAuthMetadata { - email: id_metadata.email.or(access_metadata.email), - chatgpt_plan_type: id_metadata - .chatgpt_plan_type - .or(access_metadata.chatgpt_plan_type), - chatgpt_user_id: id_metadata - .chatgpt_user_id - .or(access_metadata.chatgpt_user_id), - chatgpt_account_id: id_metadata - .chatgpt_account_id - .or(access_metadata.chatgpt_account_id), + let metadata = if secret + .access_token + .expose_secret() + .trim() + .starts_with("at-") + { + let token = secret.access_token.expose_secret().trim(); + let metadata = self + .personal_access_token_client + .as_ref() + .ok_or(PersonalAccessTokenError::Unavailable)? + .personal_access_token_metadata(token) + .await?; + secret = CodexOAuthSecret { + access_token: SecretString::from(token), + refresh_token: None, + id_token: None, + }; + access_token_expires_at = None; + metadata + } else { + // 普通 OAuth 仍按 ID token、access token 的顺序投影,不调用 whoami。 + let id_metadata = secret + .id_token + .as_ref() + .and_then(|token| parse_chatgpt_jwt_claims(token.expose_secret()).ok()) + .unwrap_or_default(); + let access_metadata = parse_chatgpt_jwt_claims(secret.access_token.expose_secret()) + .unwrap_or_default(); + CodexOAuthMetadata { + email: id_metadata.email.or(access_metadata.email), + chatgpt_plan_type: id_metadata + .chatgpt_plan_type + .or(access_metadata.chatgpt_plan_type), + chatgpt_user_id: id_metadata + .chatgpt_user_id + .or(access_metadata.chatgpt_user_id), + chatgpt_account_id: id_metadata + .chatgpt_account_id + .or(access_metadata.chatgpt_account_id), + } }; let prepared = CodexCredentialAdmin.prepare_unresolved_oauth(UnresolvedCodexOAuthCredential { @@ -1032,7 +1072,10 @@ fn parse_oauth_import_tokens( Value::Object(object) => { for (key, value) in object { let token = match key.as_str() { - "accessToken" | "access_token" => &mut access_token, + "accessToken" + | "access_token" + | "personal_access_token" + | "personalAccessToken" => &mut access_token, "refreshToken" | "refresh_token" => &mut refresh_token, "idToken" | "id_token" => &mut id_token, _ => { diff --git a/backend/crates/providers/openai/src/credential/token_client.rs b/backend/crates/providers/openai/src/credential/token_client.rs index 64010bb41..ba854ba40 100644 --- a/backend/crates/providers/openai/src/credential/token_client.rs +++ b/backend/crates/providers/openai/src/credential/token_client.rs @@ -1,4 +1,6 @@ -//! OpenAI token 续期 Reqwest 适配器。 +//! OpenAI OAuth token exchange 与 Codex PAT 验证的 Reqwest 适配器。 + +use super::types::CodexOAuthMetadata; use crate::transport::{ headers::build_codex_profile_headers, @@ -22,6 +24,33 @@ pub const OFFICIAL_CODEX_OAUTH_CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann"; pub const OFFICIAL_CODEX_TOKEN_ENDPOINT: &str = "https://auth.openai.com/oauth/token"; /// Codex Desktop loopback callback;管理员复制完整回调 URL 交回固定 complete API。 pub const OFFICIAL_CODEX_REDIRECT_URI: &str = "http://localhost:1455/auth/callback"; +const PERSONAL_ACCESS_TOKEN_WHOAMI_PATH: &str = "/api/accounts/v1/user-auth-credential/whoami"; + +/// PAT 验证失败;不保留令牌、响应体或可能包含秘密的底层 HTTP 错误。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum PersonalAccessTokenError { + #[error("Codex PAT must be a non-empty at- token without whitespace or control characters")] + InvalidToken, + #[error( + "Codex PAT was rejected by OpenAI; it may be invalid, expired, revoked, or lack permission" + )] + Rejected, + #[error("Codex PAT validation is unavailable; retry later")] + Unavailable, + #[error("OpenAI returned an invalid Codex PAT identity response")] + InvalidResponse, +} + +// 与 Codex personal_access_token.rs 一致:email 可缺失,其余身份字段必填。 +#[derive(Deserialize)] +struct PersonalAccessTokenResponse { + email: Option, + chatgpt_user_id: String, + chatgpt_account_id: String, + chatgpt_plan_type: String, + #[serde(rename = "chatgpt_account_is_fedramp")] + _chatgpt_account_is_fedramp: bool, +} /// Token 刷新成功后得到的认证材料。 #[derive(Clone)] @@ -265,6 +294,69 @@ impl OpenAiTokenClient { profile, } } + + pub(crate) async fn personal_access_token_metadata( + &self, + access_token: &str, + ) -> Result { + if !access_token.starts_with("at-") + || access_token.len() <= 3 + || access_token.len() > MAX_OAUTH_RESPONSE_BYTES + || access_token + .chars() + .any(|ch| ch.is_whitespace() || ch.is_control()) + { + return Err(PersonalAccessTokenError::InvalidToken); + } + // 复用固定 auth origin 及其 TLS/超时/禁止重定向策略;导入文档不能指定验证地址。 + let mut endpoint = reqwest::Url::parse(&self.config.token_endpoint) + .map_err(|_| PersonalAccessTokenError::Unavailable)?; + endpoint.set_path(PERSONAL_ACCESS_TOKEN_WHOAMI_PATH); + endpoint.set_query(None); + endpoint.set_fragment(None); + let headers = build_codex_profile_headers(&self.profile.snapshot()) + .map_err(|_| PersonalAccessTokenError::Unavailable)?; + let response = self + .client + .get(endpoint) + .headers(headers) + .bearer_auth(access_token) + .header(reqwest::header::ACCEPT, "application/json") + .send() + .await + .map_err(|_| PersonalAccessTokenError::Unavailable)?; + match response.status() { + StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => { + return Err(PersonalAccessTokenError::Rejected); + } + status if !status.is_success() => return Err(PersonalAccessTokenError::Unavailable), + _ => {} + } + let (_, body) = read_bounded_response(response) + .await + .map_err(|_| PersonalAccessTokenError::InvalidResponse)?; + let identity: PersonalAccessTokenResponse = + serde_json::from_slice(&body).map_err(|_| PersonalAccessTokenError::InvalidResponse)?; + if [ + &identity.chatgpt_user_id, + &identity.chatgpt_account_id, + &identity.chatgpt_plan_type, + ] + .iter() + .any(|value| value.trim().is_empty() || value.chars().any(char::is_control)) + { + return Err(PersonalAccessTokenError::InvalidResponse); + } + Ok(CodexOAuthMetadata { + email: identity + .email + .map(|value| value.trim().to_owned()) + .filter(|value| !value.is_empty()), + chatgpt_user_id: Some(identity.chatgpt_user_id.trim().to_owned()), + chatgpt_account_id: Some(identity.chatgpt_account_id.trim().to_owned()), + chatgpt_plan_type: Some(identity.chatgpt_plan_type.trim().to_owned()), + }) + } } /// 构建禁止 redirect 且无自动重试的 Codex token client。 diff --git a/backend/crates/providers/openai/src/lib.rs b/backend/crates/providers/openai/src/lib.rs index cf61575e7..9dd6b44d4 100644 --- a/backend/crates/providers/openai/src/lib.rs +++ b/backend/crates/providers/openai/src/lib.rs @@ -153,12 +153,15 @@ pub async fn initialize( .map_err(|_| OpenAiInitializeError::TokenClient)?, ); let refresher: Arc = token_client.clone(); - let exchanger: Arc = token_client; - let credential_admin = Arc::new(CodexCredentialAdminService::new( - Arc::clone(&refresher), - Arc::clone(&leases), - Arc::clone(&runtime_policy), - )); + let exchanger: Arc = token_client.clone(); + let credential_admin = Arc::new( + CodexCredentialAdminService::new( + Arc::clone(&refresher), + Arc::clone(&leases), + Arc::clone(&runtime_policy), + ) + .with_personal_access_token_client(token_client), + ); let refresh = Arc::new(CodexCredentialRefreshService::new( repository, refresher, diff --git a/backend/crates/providers/openai/tests/admin.rs b/backend/crates/providers/openai/tests/admin.rs index f55e08ed8..d1dc280e9 100644 --- a/backend/crates/providers/openai/tests/admin.rs +++ b/backend/crates/providers/openai/tests/admin.rs @@ -950,6 +950,31 @@ async fn openai_admin_provider_rejects_unprepared_mutations_before_store_commit( assert_eq!(refresh_error.kind(), ProviderAdminErrorKind::NotFound); } +#[tokio::test] +async fn initialized_provider_reports_a_safe_pat_format_error_before_network_access() { + let config = valid_config(); + let bundle = provider_openai::initialize(config.config.clone(), provider_ports()) + .await + .expect("OpenAI bundle"); + let error = bundle + .admin_provider() + .prepare_import(PrepareCredentialImport { + document: ProviderDocument::new(OpaqueProviderData::new(Map::from_iter([( + "accessToken".to_owned(), + json!("at-sensitive-token with whitespace"), + )]))), + }) + .await + .expect_err("PAT format must be checked by the initialized provider"); + assert_eq!(error.kind(), ProviderAdminErrorKind::Invalid); + assert_eq!( + error.public_message(), + Some("Codex PAT 格式无效:应为 at- 开头的完整令牌,不能包含空白或控制字符") + ); + assert!(error.message().is_none()); + assert!(!format!("{error:?}").contains("sensitive-token")); +} + #[tokio::test] async fn openai_rotation_preserves_the_new_access_token_jwt_expiration() { let store = Arc::new(MemoryAccountStore::default()); diff --git a/backend/crates/providers/openai/tests/credential/admin.rs b/backend/crates/providers/openai/tests/credential/admin.rs index 4caffbace..0bb05fdc3 100644 --- a/backend/crates/providers/openai/tests/credential/admin.rs +++ b/backend/crates/providers/openai/tests/credential/admin.rs @@ -1,11 +1,22 @@ -use std::sync::Arc; +use std::{ + num::NonZeroU32, + sync::Arc, + time::{Duration, SystemTime}, +}; use async_trait::async_trait; use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use chrono::{TimeZone as _, Utc}; -use provider_openai::credential::token_client::{RefreshFailure, TokenPair, TokenRefresher}; +use provider_openai::credential::token_client::{ + OpenAiTokenClient, PersonalAccessTokenError, RefreshFailure, TokenClientConfig, TokenPair, + TokenRefresher, openai_token_client, +}; use provider_openai::credential::{CodexCredentialAdminService, CodexCredentialCodec}; use secrecy::ExposeSecret as _; +use wiremock::{ + Mock, MockServer, ResponseTemplate, + matchers::{header, method, path}, +}; use crate::support::{TestLeaseCoordinator, runtime_policy}; @@ -145,6 +156,390 @@ fn test_jwt(payload: serde_json::Value) -> String { ) } +fn pat_service(server: &MockServer) -> CodexCredentialAdminService { + let client = openai_token_client( + TokenClientConfig { + client_id: "test-public-client".to_owned(), + token_endpoint: format!("{}/oauth/token", server.uri()), + }, + provider_openai::OpenAiConfig::default().wire_profile_state(), + ) + .expect("auth client"); + CodexCredentialAdminService::new( + Arc::new(UnusedRefresher), + Arc::new(TestLeaseCoordinator::default()), + runtime_policy(), + ) + .with_personal_access_token_client(Arc::new(client)) +} + +fn pat_identity() -> serde_json::Value { + serde_json::json!({ + "email": "pat@example.com", + "chatgpt_user_id": "pat-user", + "chatgpt_account_id": "pat-workspace", + "chatgpt_plan_type": "team", + "chatgpt_account_is_fedramp": false + }) +} + +#[tokio::test] +async fn pat_import_verifies_identity_and_becomes_schedulable_without_oauth_refresh_fields() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/api/accounts/v1/user-auth-credential/whoami")) + .and(header("authorization", "Bearer at-test-token")) + .and(header("accept", "application/json")) + .respond_with(ResponseTemplate::new(200).set_body_json(pat_identity())) + .expect(1) + .mount(&server) + .await; + let service = pat_service(&server); + let prepared = service + .prepare_import_document(serde_json::json!({ + "accounts": [{ + "platform": "openai", "type": "codex", "name": "team PAT", + "access_token": " at-test-token ", + "refresh_token": "unrelated-refresh-token", + "id_token": test_jwt(serde_json::json!({ + "https://api.openai.com/auth": {"chatgpt_user_id": "untrusted-jwt-user"} + })), + "account_id": "untrusted-document-account", + "email": "untrusted-document@example.com", + "planType": "untrusted-plan", "expires_at": "2000-01-01T00:00:00Z" + }] + })) + .await + .expect("PAT import without a document user ID"); + let prepared = &prepared.accounts()[0]; + let account = &prepared.account; + assert_eq!(account.upstream_user_id(), Some("pat-user")); + assert_eq!(account.upstream_account_id(), Some("pat-workspace")); + assert_eq!(account.email(), Some("pat@example.com")); + assert_eq!(account.plan_type(), Some("team")); + assert_eq!(account.name(), "team PAT"); + assert_eq!( + account.credential_state(), + gateway_core::account::CredentialState::Ready + ); + assert_eq!( + account.status_projection(SystemTime::now(), None).status, + gateway_core::account::AccountStatus::Normal + ); + assert!(!account.has_refresh_token()); + assert!(account.access_token_expires_at().is_none()); + assert!(account.next_refresh_at().is_none()); + let runtime = CodexCredentialCodec::decode(&prepared.credential).expect("stored PAT"); + let secret = runtime + .authentication + .oauth() + .expect("shared Bearer transport"); + assert_eq!(secret.access_token.expose_secret(), "at-test-token"); + assert!(secret.refresh_token.is_none()); + assert!(secret.id_token.is_none()); + let now = SystemTime::now(); + let refresh_query = gateway_core::account::ProviderRefreshQuery::new( + account.provider().clone(), + now + Duration::from_secs(86_400), + now + Duration::from_secs(86_400), + now, + Vec::new(), + NonZeroU32::new(1).expect("nonzero limit"), + ); + assert!(!refresh_query.contains(account)); + let requests = server.received_requests().await.expect("requests"); + assert_eq!(requests[0].headers["originator"], "Codex Desktop"); + assert!(!requests[0].headers.contains_key("chatgpt-account-id")); +} + +#[tokio::test] +async fn pat_import_times_out_without_falling_back_to_document_identity() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(pat_identity()) + .set_delay(Duration::from_secs(2)), + ) + .mount(&server) + .await; + let client = OpenAiTokenClient::new( + reqwest::Client::builder() + .no_proxy() + .redirect(reqwest::redirect::Policy::none()) + .timeout(Duration::from_millis(100)) + .build() + .expect("short-lived test client"), + TokenClientConfig { + client_id: "test-public-client".to_owned(), + token_endpoint: format!("{}/oauth/token", server.uri()), + }, + provider_openai::OpenAiConfig::default().wire_profile_state(), + ); + let service = CodexCredentialAdminService::new( + Arc::new(UnusedRefresher), + Arc::new(TestLeaseCoordinator::default()), + runtime_policy(), + ) + .with_personal_access_token_client(Arc::new(client)); + let error = service + .prepare_import_document(serde_json::json!({ + "accessToken": "at-timeout-token", "userId": "untrusted-user" + })) + .await + .expect_err("timeout must abort PAT import"); + assert_eq!( + error, + provider_openai::credential::CodexCredentialAdminError::PersonalAccessToken( + PersonalAccessTokenError::Unavailable + ) + ); +} + +#[tokio::test] +async fn pat_import_requires_a_validation_client() { + let service = CodexCredentialAdminService::new( + Arc::new(UnusedRefresher), + Arc::new(TestLeaseCoordinator::default()), + runtime_policy(), + ); + let error = service + .prepare_import_document(serde_json::json!({ + "accessToken": "at-test-token", "userId": "untrusted-user" + })) + .await + .expect_err("PAT cannot be imported without validation"); + assert_eq!( + error, + provider_openai::credential::CodexCredentialAdminError::PersonalAccessToken( + PersonalAccessTokenError::Unavailable + ) + ); +} + +#[tokio::test] +async fn pat_auth_json_import_and_cpr_round_trip_preserve_a_token_without_email() { + use gateway_core::account::LoadedCredential; + use provider_openai::credential::{CodexCredentialAdmin, ExportManagedCodexCredential}; + + let server = MockServer::start().await; + let mut identity = pat_identity(); + identity.as_object_mut().expect("object").remove("email"); + Mock::given(method("GET")) + .and(path("/api/accounts/v1/user-auth-credential/whoami")) + .respond_with(ResponseTemplate::new(200).set_body_json(identity)) + .expect(2) + .mount(&server) + .await; + let service = pat_service(&server); + let imported = service + .prepare_import_document(serde_json::json!({ + "auth_mode": "personalAccessToken", "personal_access_token": "at-test-token" + })) + .await + .expect("official PAT auth.json"); + let prepared = imported.into_accounts().remove(0); + assert!(prepared.account.email().is_none()); + let exported = CodexCredentialAdmin + .format_cpr_export(vec![ExportManagedCodexCredential { + current: LoadedCredential { + account: prepared.account, + credential: prepared.credential, + }, + added_at: Utc::now(), + updated_at: Utc::now(), + }]) + .expect("export PAT using the existing credential schema"); + let imported = service + .prepare_import_document(serde_json::to_value(exported).expect("export JSON")) + .await + .expect("re-import verifies PAT again"); + assert_eq!( + imported.accounts()[0].account.upstream_user_id(), + Some("pat-user") + ); + assert!(!imported.accounts()[0].account.has_refresh_token()); +} + +#[tokio::test] +async fn pat_import_fails_closed_for_rejection_and_unavailable_upstream_without_leaking_secrets() { + for (status, expected) in [ + (401, PersonalAccessTokenError::Rejected), + (403, PersonalAccessTokenError::Rejected), + (429, PersonalAccessTokenError::Unavailable), + (500, PersonalAccessTokenError::Unavailable), + ] { + let server = MockServer::start().await; + Mock::given(method("GET")) + .respond_with( + ResponseTemplate::new(status).set_body_string("pat-response-secret-marker"), + ) + .expect(1) + .mount(&server) + .await; + let error = pat_service(&server) + .prepare_import_document(serde_json::json!({ + "accessToken": "at-request-secret-marker", "userId": "document-user" + })) + .await + .expect_err("no account may be prepared after failed validation"); + assert_eq!( + error, + provider_openai::credential::CodexCredentialAdminError::PersonalAccessToken(expected) + ); + let message = format!("{error} {error:?}"); + assert!(!message.contains("pat-response-secret-marker")); + assert!(!message.contains("at-request-secret-marker")); + } +} + +#[tokio::test] +async fn pat_import_rejects_missing_or_invalid_whoami_identity_fields() { + for field in [ + "chatgpt_user_id", + "chatgpt_account_id", + "chatgpt_plan_type", + "chatgpt_account_is_fedramp", + ] { + for value in [ + None, + Some(serde_json::json!("")), + Some(serde_json::json!(null)), + ] { + let server = MockServer::start().await; + let mut identity = pat_identity(); + if let Some(value) = value { + identity[field] = value; + } else { + identity.as_object_mut().expect("object").remove(field); + } + Mock::given(method("GET")) + .respond_with(ResponseTemplate::new(200).set_body_json(identity)) + .expect(1) + .mount(&server) + .await; + let error = pat_service(&server) + .prepare_import_document(serde_json::json!({ + "access_token": "at-test-token", "user_id": "not-a-fallback" + })) + .await + .expect_err("malformed whoami must not fall back to document identity"); + assert_eq!( + error, + provider_openai::credential::CodexCredentialAdminError::PersonalAccessToken( + PersonalAccessTokenError::InvalidResponse + ) + ); + } + } +} + +#[tokio::test] +async fn pat_import_rejects_invalid_json_and_oversized_chunked_success_bodies() { + for body in ["not-json-secret-marker".to_owned(), "x".repeat(70 * 1024)] { + let server = MockServer::start().await; + Mock::given(method("GET")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("transfer-encoding", "chunked") + .set_body_string(body), + ) + .expect(1) + .mount(&server) + .await; + let error = pat_service(&server) + .prepare_import_document(serde_json::json!({"accessToken": "at-test-token"})) + .await + .expect_err("invalid body"); + assert_eq!( + error, + provider_openai::credential::CodexCredentialAdminError::PersonalAccessToken( + PersonalAccessTokenError::InvalidResponse + ) + ); + assert!(!format!("{error:?}").contains("secret-marker")); + } +} + +#[tokio::test] +async fn pat_import_does_not_follow_redirects_or_forward_bearer_to_another_endpoint() { + let server = MockServer::start().await; + let destination = MockServer::start().await; + Mock::given(method("GET")) + .respond_with(ResponseTemplate::new(302).insert_header("location", destination.uri())) + .expect(1) + .mount(&server) + .await; + let error = pat_service(&server) + .prepare_import_document(serde_json::json!({"accessToken": "at-test-token"})) + .await + .expect_err("redirects are not accepted"); + assert_eq!( + error, + provider_openai::credential::CodexCredentialAdminError::PersonalAccessToken( + PersonalAccessTokenError::Unavailable + ) + ); + assert!( + destination + .received_requests() + .await + .expect("redirect target") + .is_empty() + ); +} + +#[tokio::test] +async fn pat_import_rejects_malformed_tokens_before_sending_a_request() { + let server = MockServer::start().await; + let service = pat_service(&server); + for token in ["at-", "at-has space", "at-has\nnewline", "at-has\0control"] { + let error = service + .prepare_import_document(serde_json::json!({"accessToken": token})) + .await + .expect_err("invalid PAT"); + assert_eq!( + error, + provider_openai::credential::CodexCredentialAdminError::PersonalAccessToken( + PersonalAccessTokenError::InvalidToken + ) + ); + } + assert!( + server + .received_requests() + .await + .expect("requests") + .is_empty() + ); +} + +#[tokio::test] +async fn ordinary_jwt_import_does_not_call_pat_whoami_even_when_client_is_configured() { + let server = MockServer::start().await; + let jwt = test_jwt( + serde_json::json!({"https://api.openai.com/auth": {"chatgpt_user_id": "jwt-user"}}), + ); + let imported = pat_service(&server) + .prepare_import_document(serde_json::json!({ + "accessToken": jwt, "refreshToken": "jwt-refresh", "userId": "untrusted" + })) + .await + .expect("existing JWT behavior"); + assert_eq!( + imported.accounts()[0].account.upstream_user_id(), + Some("jwt-user") + ); + assert!(imported.accounts()[0].account.has_refresh_token()); + assert!( + server + .received_requests() + .await + .expect("requests") + .is_empty() + ); +} + #[tokio::test] async fn oauth_import_uses_official_chatgpt_access_token_claims() { let access_token = test_jwt(serde_json::json!({ diff --git a/docs/api.md b/docs/api.md index 10f3b7f19..a94215413 100644 --- a/docs/api.md +++ b/docs/api.md @@ -295,10 +295,18 @@ OAuth start 使用: - OAuth 文件导入接受 camelCase 与 snake_case 的三个 token 字段,内部统一保存为 `accessToken`、`refreshToken`、`idToken`,不接受含义模糊的 `token`。 仅有 refresh token 时先换取 access token。 -- 身份补全复用官方 `token_data.rs::parse_chatgpt_jwt_claims`:优先解析 `idToken`,缺失字段再由 +- 普通 OAuth 的身份补全复用官方 `token_data.rs::parse_chatgpt_jwt_claims`:优先解析 `idToken`,缺失字段再由 `accessToken` 补齐;`email` 优先 JWT 顶层值、其次 `https://api.openai.com/profile.email`,用户 ID 优先 `chatgpt_user_id`、其次 `user_id`。该路径不调用 `whoami`,也不信任导入文档顶层的 `userId/accountId`。 +- `at-` 开头的 Codex Personal Access Token(PAT)可直接粘贴到现有 **AT 导入** 入口,或使用 + `{"accessToken":"at-..."}` JSON;也接受官方 `auth.json` 的 `personal_access_token` 字段 + (兼容 `personalAccessToken`)。导入时向 OpenAI auth 的 + `/api/accounts/v1/user-auth-credential/whoami` 验证令牌,以响应中的用户 ID、账号 ID 和套餐建立身份, + `email` 可缺失;不从导入文件的身份字段或附带的 ID token 回退补齐。验证失败不导入,接口区分提示 + PAT 格式无效、被上游拒绝、验证服务不可用和身份响应无效,不回显令牌或原始上游响应。 + PAT 不保存附带的 refresh token、ID token 或推测的过期时间,不参加 OAuth RT 刷新; + 失效后需取得新 PAT 再导入。普通 JWT 导入行为不变。 - 首次 OAuth 保留回调 `state`、PKCE 与官方 token exchange,并持久化 `idToken`、`accessToken`、 `refreshToken`。刷新响应中的三个 token 字段均按官方语义独立轮换:返回新值时替换,省略时分别保留 现值。重新授权也保留这些回调保护,但只轮换目标账号的 token。回调地址只承载 `code`/`state`,