From c795098a87d72ed7651a55cae63bb0a974fad709 Mon Sep 17 00:00:00 2001 From: Zyy Date: Fri, 11 Sep 2026 00:11:29 +0800 Subject: [PATCH] feat(openai): support Codex personal access token imports Accept at- personal access tokens through existing AT and JSON imports, including the official personal_access_token field. Verify identity with auth whoami instead of trusting document metadata, preserve optional email, and exclude PAT credentials from OAuth refresh scheduling. Expose explicit static PAT validation errors without leaking credentials or raw upstream responses. Cover rejection, invalid identity, timeouts, redirect protection, export round trips, and unchanged JWT import behavior. Validation: OpenAI provider tests (562 passed), gateway-admin tests (100 passed), strict Clippy for both crates with all targets and features, workspace rustfmt, and diff whitespace checks passed. Upstream behavior was tested with mocks; no live PAT was used. Co-authored-by: Codex --- .../gateway-admin/src/ports/provider.rs | 16 + .../crates/gateway-admin/src/use_case/mod.rs | 2 +- .../gateway-admin/tests/use_case/accounts.rs | 12 + .../gateway-admin/tests/use_case/openai.rs | 37 ++ backend/crates/providers/openai/src/admin.rs | 34 +- .../providers/openai/src/credential/admin.rs | 97 +++-- .../openai/src/credential/token_client.rs | 94 ++++- backend/crates/providers/openai/src/lib.rs | 15 +- .../crates/providers/openai/tests/admin.rs | 25 ++ .../openai/tests/credential/admin.rs | 399 +++++++++++++++++- docs/api.md | 10 +- 11 files changed, 701 insertions(+), 40 deletions(-) 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`,