From 6cad2bb6a13d3aac78076bf861e661295bebb8ef Mon Sep 17 00:00:00 2001 From: Zyy Date: Wed, 12 Aug 2026 23:51:26 +0800 Subject: [PATCH] fix(openai): align account import with official token claims Co-authored-by: Codex --- .../crates/gateway-api/src/admin/accounts.rs | 20 ++- .../gateway-api/tests/admin/accounts.rs | 3 +- backend/crates/providers/openai/src/admin.rs | 13 +- .../providers/openai/src/credential/admin.rs | 103 ++++++------ .../providers/openai/src/credential/oauth.rs | 4 +- .../openai/src/credential/refresh.rs | 23 ++- .../openai/src/credential/token_client.rs | 24 ++- .../providers/openai/src/credential/types.rs | 85 +++++++++- .../crates/providers/openai/tests/admin.rs | 27 ++- .../openai/tests/credential/admin.rs | 154 +++++++++++++++++- .../openai/tests/credential/refresh.rs | 104 +++++++++++- .../openai/tests/credential/token_client.rs | 46 +++++- docs/api.md | 17 +- docs/architecture.md | 12 +- .../components/AccountTableActions.vue | 2 +- 15 files changed, 540 insertions(+), 97 deletions(-) diff --git a/backend/crates/gateway-api/src/admin/accounts.rs b/backend/crates/gateway-api/src/admin/accounts.rs index 254f3b62f..30c4a1bcc 100644 --- a/backend/crates/gateway-api/src/admin/accounts.rs +++ b/backend/crates/gateway-api/src/admin/accounts.rs @@ -51,6 +51,7 @@ const MAX_NAME_BYTES: usize = 512; const MAX_IMPORT_DATA_BYTES: usize = 64 * 1024 * 1024; const MAX_ACCESS_TOKEN_BYTES: usize = 16 * 1024; const MAX_REFRESH_TOKEN_BYTES: usize = 64 * 1024; +const MAX_ID_TOKEN_BYTES: usize = 16 * 1024; const MAX_CALLBACK_URL_BYTES: usize = 64 * 1024; const MAX_ACCOUNT_DELETE_BATCH: usize = 200; @@ -760,6 +761,7 @@ pub struct RotateAccountRequest { pub account_id: String, pub access_token: String, pub refresh_token: Option, + pub id_token: Option, } impl RotateAccountRequest { @@ -768,7 +770,11 @@ impl RotateAccountRequest { return Err(WireValidationError::new("provider")); } require_account_id(&self.account_id, "accountId")?; - validate_oauth_material(&self.access_token, self.refresh_token.as_deref()) + validate_oauth_material( + &self.access_token, + self.refresh_token.as_deref(), + self.id_token.as_deref(), + ) } fn into_command( @@ -782,6 +788,10 @@ impl RotateAccountRequest { "refresh_token".to_owned(), self.refresh_token.map_or(Value::Null, Value::String), ); + material.insert( + "id_token".to_owned(), + self.id_token.map_or(Value::Null, Value::String), + ); Ok(RotateCredential { mutation: CredentialMutation { context, @@ -1681,6 +1691,7 @@ fn require_text( fn validate_oauth_material( access_token: &str, refresh_token: Option<&str>, + id_token: Option<&str>, ) -> Result<(), WireValidationError> { if access_token.len() > MAX_ACCESS_TOKEN_BYTES || !valid_visible_ascii(access_token) @@ -1695,6 +1706,13 @@ fn validate_oauth_material( }) { return Err(WireValidationError::new("refreshToken")); } + if id_token.is_some_and(|token| { + token.len() > MAX_ID_TOKEN_BYTES + || !valid_visible_ascii(token) + || !valid_compact_jwt_shape(token) + }) { + return Err(WireValidationError::new("idToken")); + } Ok(()) } diff --git a/backend/crates/gateway-api/tests/admin/accounts.rs b/backend/crates/gateway-api/tests/admin/accounts.rs index 79fa600c3..3efc5ec1d 100644 --- a/backend/crates/gateway-api/tests/admin/accounts.rs +++ b/backend/crates/gateway-api/tests/admin/accounts.rs @@ -217,7 +217,8 @@ mod actions { "provider": "openai", "accountId": "acct_1", "accessToken": "header.payload.signature", - "refreshToken": "refresh-token" + "refreshToken": "refresh-token", + "idToken": "id-header.id-payload.id-signature" })) .expect("decode rotation"); assert!(rotation.validate().is_ok()); diff --git a/backend/crates/providers/openai/src/admin.rs b/backend/crates/providers/openai/src/admin.rs index 1bc659587..4f8740915 100644 --- a/backend/crates/providers/openai/src/admin.rs +++ b/backend/crates/providers/openai/src/admin.rs @@ -368,7 +368,15 @@ impl ProviderAdmin for OpenAiAdminProvider { if !account_matches_record(¤t.account, &command.account) { return Err(provider_admin_error(ProviderAdminErrorKind::Conflict)); } - let secret = rotation_secret(command.provider_material)?; + let mut secret = rotation_secret(command.provider_material)?; + if secret.id_token.is_none() { + let runtime = CodexCredentialCodec::decode(¤t.credential) + .map_err(|_| provider_admin_error(ProviderAdminErrorKind::Invalid))?; + secret.id_token = runtime + .authentication + .oauth() + .and_then(|oauth| oauth.id_token.clone()); + } let prepared = CodexCredentialAdmin .prepare_refreshed_oauth_rotation(current, secret, None, None) .map_err(map_credential_admin_error)?; @@ -574,6 +582,7 @@ fn prepared_rotation( struct RotationDocument { access_token: String, refresh_token: Option, + id_token: Option, } fn rotation_secret(document: ProviderDocument) -> Result { @@ -583,7 +592,7 @@ fn rotation_secret(document: ProviderDocument) -> Result, + id_token: Option, access_token_expires_at: Option, } @@ -546,8 +546,9 @@ impl CodexCredentialAdmin { } CodexCprExportAccount::OAuth(CodexCprOAuthExportAccount { common, - token: data.access_token, + access_token: data.access_token, refresh_token: data.refresh_token, + id_token: data.id_token, access_token_expires_at: account .access_token_expires_at() .map(DateTime::::from) @@ -794,23 +795,37 @@ impl CodexCredentialAdminService { .refresh(refresh_token.expose_secret()) .await .map_err(map_refresh_failure)?; + let access_token_rotated = tokens.access_token.is_some(); let secret = CodexOAuthSecret { - access_token: SecretString::from(tokens.access_token), + access_token: tokens + .access_token + .map(SecretString::from) + .unwrap_or_else(|| oauth.access_token.clone()), refresh_token: tokens .refresh_token .map(SecretString::from) .or_else(|| oauth.refresh_token.clone()), - id_token: oauth.id_token.clone(), + id_token: tokens + .id_token + .map(SecretString::from) + .or_else(|| oauth.id_token.clone()), }; Self::record_recovery_log( CodexOAuthRecoveryOperation::ManualRefresh, Some(account_id.as_str()), &secret, ); - let access_token_expires_at = tokens - .expires_in - .and_then(|expires_in| SystemTime::now().checked_add(expires_in)) - .map(DateTime::::from); + let access_token_expires_at = if access_token_rotated { + tokens + .expires_in + .and_then(|expires_in| SystemTime::now().checked_add(expires_in)) + .map(DateTime::::from) + } else { + current + .account + .access_token_expires_at() + .map(DateTime::::from) + }; // 正常预刷新由 worker 读取当时的 runtime policy 动态判断;这里仅清除 // 之前瞬态失败留下的 retry-not-before。 let next_refresh_at = None; @@ -829,8 +844,8 @@ impl CodexCredentialAdminService { /// 归一导入 OAuth/agent-identity 凭据到唯一 `NewProviderAccount` 写入路径。 /// - /// OAuth 导入只要取得 access token(直接提供或 RT exchange)即创建 Unknown - /// 账号;资格资料由后续 worker 异步补齐。 + /// OAuth 导入先取得 access token(直接提供或 RT exchange),再按官方 + /// `parse_chatgpt_jwt_claims` 从 ID token/access token 本地投影账号资料。 pub async fn prepare_import_document( &self, payload: Value, @@ -870,16 +885,28 @@ impl CodexCredentialAdminService { id_token.clone(), ) .await?; - // 导入材料按管理员输入原样保存;ID token 仅尽力补展示字段, - // 解析失败不阻塞 AT/RT 入库。 - let mut metadata = secret + // 与官方 `parse_chatgpt_jwt_claims` 一致:先读 ID token,缺失字段 + // 再由 access token 补齐。解析失败不阻塞 token 入库。 + let id_metadata = secret .id_token .as_ref() - .and_then(|token| { - super::types::parse_id_token_metadata(token.expose_secret()).ok() - }) + .and_then(|token| parse_chatgpt_jwt_claims(token.expose_secret()).ok()) .unwrap_or_default(); - apply_import_metadata(&mut metadata, &candidate); + 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), + }; CodexCredentialAdmin.prepare_unresolved_oauth( UnresolvedCodexOAuthCredential { account_id, @@ -961,14 +988,17 @@ impl CodexCredentialAdminService { .refresh(&refresh_token) .await .map_err(map_refresh_failure)?; + let access_token = tokens + .access_token + .ok_or(CodexCredentialAdminError::InvalidCredential)?; let secret = CodexOAuthSecret { - access_token: SecretString::from(tokens.access_token), + access_token: SecretString::from(access_token), // RT 未轮换时仍保留导入提供的 RT,保证本次补全不会丢失后续刷新能力。 refresh_token: tokens .refresh_token .map(SecretString::from) .or_else(|| Some(SecretString::from(refresh_token))), - id_token, + id_token: tokens.id_token.map(SecretString::from).or(id_token), }; Self::record_recovery_log( CodexOAuthRecoveryOperation::ImportRefreshToken, @@ -1221,9 +1251,9 @@ fn parse_oauth_import_tokens( Value::Object(object) => { for (key, value) in object { let token = match key.as_str() { - "access_token" | "accessToken" | "at" | "token" => &mut access_token, - "refresh_token" | "refreshToken" | "rt" => &mut refresh_token, - "id_token" | "idToken" => &mut id_token, + "accessToken" => &mut access_token, + "refreshToken" => &mut refresh_token, + "idToken" => &mut id_token, _ => { pending.push(value); continue; @@ -1288,27 +1318,6 @@ fn first_string(value: &Value, keys: &[&str]) -> Option { .map(str::to_owned) } -fn apply_import_metadata(metadata: &mut CodexOAuthMetadata, candidate: &ParsedCodexImportAccount) { - if metadata.email.is_none() { - metadata.email = candidate - .email - .as_deref() - .and_then(|value| normalize_import_text(value, MAX_IMPORT_EMAIL_BYTES)); - } - if metadata.chatgpt_plan_type.is_none() { - metadata.chatgpt_plan_type = candidate - .plan_type - .as_deref() - .and_then(|value| normalize_import_text(value, MAX_IMPORT_PLAN_BYTES)); - } -} - -fn normalize_import_text(value: &str, max_bytes: usize) -> Option { - let value = value.trim(); - (!value.is_empty() && value.len() <= max_bytes && !value.chars().any(char::is_control)) - .then(|| value.to_owned()) -} - struct ImportedCprAccountFacts { enabled: bool, credential_state: CredentialState, diff --git a/backend/crates/providers/openai/src/credential/oauth.rs b/backend/crates/providers/openai/src/credential/oauth.rs index ed6792799..1fbc92a22 100644 --- a/backend/crates/providers/openai/src/credential/oauth.rs +++ b/backend/crates/providers/openai/src/credential/oauth.rs @@ -35,7 +35,7 @@ use super::token_client::{ AuthorizationCodeExchangeError, AuthorizationCodeExchanger, AuthorizationCodeGrant, OFFICIAL_CODEX_OAUTH_CLIENT_ID, OFFICIAL_CODEX_REDIRECT_URI, }; -use super::types::parse_id_token_metadata; +use super::types::parse_chatgpt_jwt_claims; const AUTHORIZATION_ENDPOINT: &str = "https://auth.openai.com/oauth/authorize"; const AUTHORIZATION_SCOPE: &str = "openid profile email offline_access"; @@ -504,7 +504,7 @@ impl CodexOAuthAdminService { let access_token_expires_at = expires_in .and_then(|expires_in| SystemTime::now().checked_add(expires_in)) .map(DateTime::::from); - let metadata = parse_id_token_metadata(id_token.expose_secret()) + let metadata = parse_chatgpt_jwt_claims(id_token.expose_secret()) .map_err(|_| CodexOAuthAdminError::TokenRejected)?; secret.id_token = Some(id_token); let credential = if let Some(current) = current { diff --git a/backend/crates/providers/openai/src/credential/refresh.rs b/backend/crates/providers/openai/src/credential/refresh.rs index 4602b41f2..cf0f74048 100644 --- a/backend/crates/providers/openai/src/credential/refresh.rs +++ b/backend/crates/providers/openai/src/credential/refresh.rs @@ -402,15 +402,26 @@ impl CodexCredentialRefreshService { let TokenPair { access_token, refresh_token: rotated_refresh_token, + id_token: rotated_id_token, expires_in, } = tokens; + let access_token_rotated = access_token.is_some(); + let CodexOAuthSecret { + access_token: current_access_token, + refresh_token: current_refresh_token, + id_token: current_id_token, + } = due.secret; let refresh_token = rotated_refresh_token .map(SecretString::from) - .or(due.secret.refresh_token); + .or(current_refresh_token); let secret = CodexOAuthSecret { - access_token: SecretString::from(access_token), + access_token: access_token + .map(SecretString::from) + .unwrap_or(current_access_token), refresh_token, - id_token: due.secret.id_token, + id_token: rotated_id_token + .map(SecretString::from) + .or(current_id_token), }; record_oauth_recovery( CodexOAuthRecoveryOperation::ScheduledRefresh, @@ -422,7 +433,11 @@ impl CodexCredentialRefreshService { .map(ExposeSecret::expose_secret), ); let observed_at = SystemTime::now(); - let access_token_expires_at = expires_in.and_then(|value| observed_at.checked_add(value)); + let access_token_expires_at = if access_token_rotated { + expires_in.and_then(|value| observed_at.checked_add(value)) + } else { + due.account.access_token_expires_at() + }; // 成功轮换清掉仅用于失败退避的 retry-not-before;正常预刷新窗口由 // worker 结合当前 runtime policy 动态判断,不写入账号时间字段。 let next_refresh_at = None; diff --git a/backend/crates/providers/openai/src/credential/token_client.rs b/backend/crates/providers/openai/src/credential/token_client.rs index d3fa9da19..93c372b9f 100644 --- a/backend/crates/providers/openai/src/credential/token_client.rs +++ b/backend/crates/providers/openai/src/credential/token_client.rs @@ -21,8 +21,12 @@ pub const OFFICIAL_CODEX_REDIRECT_URI: &str = "http://localhost:1455/auth/callba /// Token 刷新成功后得到的认证材料。 #[derive(Clone)] pub struct TokenPair { - pub access_token: String, + /// 官方刷新响应省略时由持久化调用方保留当前 access token。 + pub access_token: Option, + /// 官方刷新响应省略时由持久化调用方保留当前 refresh token。 pub refresh_token: Option, + /// 官方刷新响应省略时由持久化调用方保留当前 ID token。 + pub id_token: Option, /// OAuth endpoint 可以省略或返回零 expiry;这不影响已经成功的 token /// exchange,只意味着调用方不能据此安排下一次刷新。 pub expires_in: Option, @@ -32,11 +36,15 @@ impl fmt::Debug for TokenPair { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter .debug_struct("TokenPair") - .field("access_token", &"[REDACTED]") + .field( + "access_token", + &self.access_token.as_ref().map(|_| "[REDACTED]"), + ) .field( "refresh_token", &self.refresh_token.as_ref().map(|_| "[REDACTED]"), ) + .field("id_token", &self.id_token.as_ref().map(|_| "[REDACTED]")) .field("expires_in", &self.expires_in) .finish() } @@ -172,8 +180,9 @@ pub fn official_openai_token_client() -> Result, refresh_token: Option, + id_token: Option, expires_in: Option, } @@ -279,10 +288,19 @@ async fn read_bounded_response( fn parse_token_pair(body: &[u8]) -> Result { let tokens = serde_json::from_slice::(body).map_err(|_| ())?; + if tokens + .id_token + .as_deref() + .is_some_and(|token| super::types::parse_chatgpt_jwt_claims(token).is_err()) + { + return Err(()); + } Ok(TokenPair { access_token: tokens.access_token, // OAuth 刷新响应可能省略未变更的 RT;缺失时由调用方保留当前值。 refresh_token: tokens.refresh_token, + // 官方刷新响应允许省略 ID token;缺失时由调用方保留当前值。 + id_token: tokens.id_token, expires_in: optional_expiry(tokens.expires_in), }) } diff --git a/backend/crates/providers/openai/src/credential/types.rs b/backend/crates/providers/openai/src/credential/types.rs index 2f69ff95f..c310021a5 100644 --- a/backend/crates/providers/openai/src/credential/types.rs +++ b/backend/crates/providers/openai/src/credential/types.rs @@ -42,7 +42,7 @@ pub struct CodexAccountProfile { pub access_token_expires_at: Option>, } -/// 从 OAuth ID token payload 尽力提取的展示资料。 +/// 从 ChatGPT OAuth JWT payload 尽力提取的账号资料。 /// /// 这只是与官方客户端一致的 base64 JSON 读取,不校验 JWT 签名或 claims。 #[derive(Clone, Default)] @@ -71,12 +71,13 @@ impl fmt::Debug for CodexOAuthMetadata { } } -/// 按官方 `token_data.rs` 的字段优先级读取 ID token payload。 +/// 按官方 `token_data.rs::parse_chatgpt_jwt_claims` 的字段优先级读取 JWT payload。 /// -/// 与官方一致,`id_token` 的 JWT 外形、payload base64 或 JSON 无法解析时返回错误; -/// 这不是签名或 claims 验证。各身份字段本身仍全部可缺失。 -pub(crate) fn parse_id_token_metadata(jwt: &str) -> Result { - let claims = decode_jwt_payload::(jwt)?; +/// 官方对 ID token 和外部 ChatGPT access token 复用同一解析逻辑。JWT 外形、 +/// payload base64 或 JSON 无法解析时返回错误;这不是签名或 claims 验证。 +/// 各账号字段本身仍全部可缺失。 +pub(crate) fn parse_chatgpt_jwt_claims(jwt: &str) -> Result { + let claims = decode_jwt_payload::(jwt)?; let email = claims .email .or_else(|| claims.profile.and_then(|profile| profile.email)); @@ -88,7 +89,7 @@ pub(crate) fn parse_id_token_metadata(jwt: &str) -> Result Option> } #[derive(Deserialize)] -struct IdTokenClaims { +struct IdClaims { #[serde(default)] email: Option, #[serde(rename = "https://api.openai.com/profile", default)] @@ -130,13 +131,79 @@ struct ProfileClaims { #[derive(Deserialize)] struct AuthClaims { #[serde(default)] - chatgpt_plan_type: Option, + chatgpt_plan_type: Option, #[serde(default)] chatgpt_user_id: Option, #[serde(default)] user_id: Option, #[serde(default)] chatgpt_account_id: Option, + #[serde(rename = "chatgpt_account_is_fedramp", default)] + _chatgpt_account_is_fedramp: bool, +} + +/// 与官方 `codex_protocol::auth::PlanType` 同构的 claims 反序列化类型。 +#[derive(Deserialize)] +#[serde(untagged)] +enum ChatgptPlanType { + Known(KnownChatgptPlan), + Unknown(String), +} + +impl ChatgptPlanType { + fn into_raw_value(self) -> String { + match self { + Self::Known(plan) => plan.raw_value().to_owned(), + Self::Unknown(raw) => raw, + } + } +} + +/// 与官方 `codex_protocol::auth::KnownPlan` 的 wire 名称和 aliases 保持一致。 +#[derive(Clone, Copy, Deserialize)] +#[serde(rename_all = "lowercase")] +enum KnownChatgptPlan { + Free, + Go, + Plus, + Pro, + ProLite, + Team, + #[serde(rename = "self_serve_business_prolite")] + SelfServeBusinessProLite, + #[serde(rename = "self_serve_business_usage_based")] + SelfServeBusinessUsageBased, + Business, + Ent26, + #[serde(rename = "enterprise_cbp_automation")] + EnterpriseCbpAutomation, + #[serde(rename = "enterprise_cbp_usage_based")] + EnterpriseCbpUsageBased, + #[serde(alias = "hc")] + Enterprise, + #[serde(alias = "education")] + Edu, +} + +impl KnownChatgptPlan { + const fn raw_value(self) -> &'static str { + match self { + Self::Free => "free", + Self::Go => "go", + Self::Plus => "plus", + Self::Pro => "pro", + Self::ProLite => "prolite", + Self::Team => "team", + Self::SelfServeBusinessProLite => "self_serve_business_prolite", + Self::SelfServeBusinessUsageBased => "self_serve_business_usage_based", + Self::Business => "business", + Self::Ent26 => "ent26", + Self::EnterpriseCbpAutomation => "enterprise_cbp_automation", + Self::EnterpriseCbpUsageBased => "enterprise_cbp_usage_based", + Self::Enterprise => "enterprise", + Self::Edu => "edu", + } + } } fn decode_jwt_payload(jwt: &str) -> Result { diff --git a/backend/crates/providers/openai/tests/admin.rs b/backend/crates/providers/openai/tests/admin.rs index e136a9246..19f7f2d29 100644 --- a/backend/crates/providers/openai/tests/admin.rs +++ b/backend/crates/providers/openai/tests/admin.rs @@ -35,6 +35,7 @@ use gateway_core::task::{WorkerContribution, WorkerKind, WorkerRunnable}; use provider_openai::config::{CodexWireProfileConfig, OpenAiConfig}; use provider_openai::credential::ImportCodexOAuthCredential; use provider_openai::transport::profile::APPCAST_POLL_INTERVAL; +use secrecy::SecretString; use serde_json::{Map, Value, json}; use tempfile::TempDir; @@ -360,11 +361,13 @@ async fn openai_reauthorization_pending_payload_contains_only_stable_account_ide #[tokio::test] async fn openai_admin_provider_projects_cached_quota_models_and_canonical_export() { let store = Arc::new(MemoryAccountStore::default()); + let mut oauth_secret = secret("admin-projection-access"); + oauth_secret.id_token = Some(SecretString::from("header.id-token.signature")); store .seed_oauth_credential(ImportCodexOAuthCredential { account_id: "acct_admin_projection".to_owned(), name: "admin projection".to_owned(), - secret: secret("admin-projection-access"), + secret: oauth_secret, verified_account: profile("chatgpt-admin-projection"), next_refresh_at: Some(chrono::Utc::now() + chrono::Duration::minutes(30)), enabled: true, @@ -438,15 +441,25 @@ async fn openai_admin_provider_projects_cached_quota_models_and_canonical_export .await .expect("canonical export"); assert_eq!(exported.account_ids, vec![account_id]); + let document = exported.document.expose_to_provider().expose_to_provider(); assert_eq!( - exported - .document - .expose_to_provider() - .expose_to_provider() - .get("sourceFormat") - .and_then(Value::as_str), + document.get("sourceFormat").and_then(Value::as_str), Some("cpr") ); + let exported_account = document + .get("accounts") + .and_then(Value::as_array) + .and_then(|accounts| accounts.first()) + .expect("exported OAuth account"); + assert_eq!( + exported_account.get("accessToken").and_then(Value::as_str), + Some("admin-projection-access") + ); + assert_eq!( + exported_account.get("idToken").and_then(Value::as_str), + Some("header.id-token.signature") + ); + assert!(exported_account.get("token").is_none()); } #[tokio::test] diff --git a/backend/crates/providers/openai/tests/credential/admin.rs b/backend/crates/providers/openai/tests/credential/admin.rs index 0a3df62a4..9c2657d06 100644 --- a/backend/crates/providers/openai/tests/credential/admin.rs +++ b/backend/crates/providers/openai/tests/credential/admin.rs @@ -27,9 +27,9 @@ async fn direct_import_persists_opaque_tokens_without_profile_or_token_validatio ); let prepared = service .prepare_import_document(serde_json::json!({ - "token": " ", - "refresh_token": "", - "id_token": "not.a.parseable.jwt" + "accessToken": " ", + "refreshToken": "", + "idToken": "not.a.parseable.jwt" })) .await .expect("opaque direct import must be accepted"); @@ -79,8 +79,8 @@ async fn direct_import_projects_access_token_jwt_expiry_without_persisting_refre ); let prepared = service .prepare_import_document(serde_json::json!({ - "token": access_token, - "refresh_token": "refresh-token" + "accessToken": access_token, + "refreshToken": "refresh-token" })) .await .expect("direct JWT import must be accepted"); @@ -95,3 +95,147 @@ async fn direct_import_projects_access_token_jwt_expiry_without_persisting_refre ); assert!(account.account.next_refresh_at().is_none()); } + +fn test_jwt(payload: serde_json::Value) -> String { + format!( + "unverified-header.{}.unverified-signature", + URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).expect("test JWT payload")) + ) +} + +#[tokio::test] +async fn oauth_import_uses_official_chatgpt_access_token_claims() { + let access_token = test_jwt(serde_json::json!({ + "email": "top-level@example.com", + "https://api.openai.com/profile": {"email": "profile@example.com"}, + "https://api.openai.com/auth": { + "chatgpt_plan_type": "pro", + "chatgpt_user_id": "chatgpt-user", + "user_id": "fallback-user", + "chatgpt_account_id": "chatgpt-account" + } + })); + let service = CodexCredentialAdminService::new( + Arc::new(UnusedRefresher), + Arc::new(TestLeaseCoordinator::default()), + runtime_policy(), + ); + let prepared = service + .prepare_import_document(serde_json::json!({ + "accessToken": access_token + })) + .await + .expect("official access token claims are accepted"); + + let account = &prepared.accounts().first().expect("one account").account; + assert_eq!(account.upstream_user_id(), Some("chatgpt-user")); + assert_eq!(account.upstream_account_id(), Some("chatgpt-account")); + assert_eq!(account.email(), Some("top-level@example.com")); + assert_eq!(account.plan_type(), Some("pro")); +} + +#[tokio::test] +async fn oauth_import_uses_official_plan_alias_projection_and_user_id_fallback() { + let access_token = test_jwt(serde_json::json!({ + "https://api.openai.com/auth": { + "chatgpt_plan_type": "hc", + "user_id": "fallback-user", + "chatgpt_account_is_fedramp": false + } + })); + let service = CodexCredentialAdminService::new( + Arc::new(UnusedRefresher), + Arc::new(TestLeaseCoordinator::default()), + runtime_policy(), + ); + let prepared = service + .prepare_import_document(serde_json::json!({ + "accessToken": access_token + })) + .await + .expect("official plan alias and user ID fallback are accepted"); + + let account = &prepared.accounts().first().expect("one account").account; + assert_eq!(account.upstream_user_id(), Some("fallback-user")); + assert_eq!(account.plan_type(), Some("enterprise")); +} + +#[tokio::test] +async fn oauth_import_uses_id_token_then_access_token_for_missing_claims() { + let id_token = test_jwt(serde_json::json!({ + "https://api.openai.com/profile": {"email": "id-token@example.com"}, + "https://api.openai.com/auth": {"chatgpt_user_id": "id-token-user"} + })); + let access_token = test_jwt(serde_json::json!({ + "email": "access-token@example.com", + "https://api.openai.com/auth": { + "chatgpt_plan_type": "plus", + "chatgpt_user_id": "access-token-user", + "chatgpt_account_id": "access-token-account" + } + })); + let service = CodexCredentialAdminService::new( + Arc::new(UnusedRefresher), + Arc::new(TestLeaseCoordinator::default()), + runtime_policy(), + ); + let prepared = service + .prepare_import_document(serde_json::json!({ + "accessToken": access_token, + "idToken": id_token + })) + .await + .expect("ID token and access token claims are accepted"); + + let account = &prepared.accounts().first().expect("one account").account; + assert_eq!(account.upstream_user_id(), Some("id-token-user")); + assert_eq!(account.upstream_account_id(), Some("access-token-account")); + assert_eq!(account.email(), Some("id-token@example.com")); + assert_eq!(account.plan_type(), Some("plus")); +} + +#[tokio::test] +async fn oauth_import_does_not_use_top_level_identity_fields() { + let access_token = test_jwt(serde_json::json!({"exp": 2_000_000_000_i64})); + let service = CodexCredentialAdminService::new( + Arc::new(UnusedRefresher), + Arc::new(TestLeaseCoordinator::default()), + runtime_policy(), + ); + let prepared = service + .prepare_import_document(serde_json::json!({ + "accessToken": access_token, + "userId": "untrusted-user", + "accountId": "untrusted-account", + "email": "untrusted@example.com", + "planType": "pro" + })) + .await + .expect("token without identity claims remains importable"); + + let account = &prepared.accounts().first().expect("one account").account; + assert!(account.upstream_user_id().is_none()); + assert!(account.upstream_account_id().is_none()); + assert!(account.email().is_none()); + assert!(account.plan_type().is_none()); +} + +#[tokio::test] +async fn oauth_import_rejects_legacy_bare_token_field() { + let service = CodexCredentialAdminService::new( + Arc::new(UnusedRefresher), + Arc::new(TestLeaseCoordinator::default()), + runtime_policy(), + ); + let error = service + .prepare_import_document(serde_json::json!({ + "token": "header.payload.signature" + })) + .await + .expect_err("ambiguous token field must not be accepted"); + + assert_eq!( + error, + provider_openai::credential::CodexCredentialAdminError::InvalidCredential + ); +} diff --git a/backend/crates/providers/openai/tests/credential/refresh.rs b/backend/crates/providers/openai/tests/credential/refresh.rs index b350d7002..11b67a9ac 100644 --- a/backend/crates/providers/openai/tests/credential/refresh.rs +++ b/backend/crates/providers/openai/tests/credential/refresh.rs @@ -6,7 +6,7 @@ use std::time::{Duration, SystemTime}; use async_trait::async_trait; use chrono::{DateTime, Utc}; use futures::future::BoxFuture; -use gateway_core::engine::credential::ProviderAccountId; +use gateway_core::engine::credential::{ProviderAccountId, ProviderAccountStore}; use gateway_core::provider_ports::{ ProviderCredentialState, ProviderCredentialStatePort, ProviderLeaseAcquisition, ProviderLeasePort, ProviderLeaseRequest, ProviderRefreshPolicy, ProviderRuntimePolicyPort, @@ -15,8 +15,10 @@ use gateway_core::provider_ports::{ use gateway_core::routing::ProviderKind; use provider_openai::credential::token_client::{RefreshFailure, TokenPair, TokenRefresher}; use provider_openai::credential::{ - CodexCredentialRefreshOutcome, CodexCredentialRefreshService, ImportCodexOAuthCredential, + CodexCredentialCodec, CodexCredentialRefreshOutcome, CodexCredentialRefreshService, + ImportCodexOAuthCredential, }; +use secrecy::{ExposeSecret as _, SecretString}; use crate::support::{MemoryAccountStore, profile, secret}; @@ -30,8 +32,9 @@ impl SingleUseRefresher { Arc::new(Self { calls: AtomicUsize::new(0), response: Mutex::new(Some(TokenPair { - access_token: "refreshed-access-token".to_owned(), + access_token: Some("refreshed-access-token".to_owned()), refresh_token: None, + id_token: None, expires_in: Some(Duration::from_secs(60 * 60)), })), }) @@ -40,6 +43,30 @@ impl SingleUseRefresher { fn calls(&self) -> usize { self.calls.load(Ordering::SeqCst) } + + fn with_id_token(id_token: &str) -> Arc { + Arc::new(Self { + calls: AtomicUsize::new(0), + response: Mutex::new(Some(TokenPair { + access_token: Some("refreshed-access-token".to_owned()), + refresh_token: None, + id_token: Some(id_token.to_owned()), + expires_in: Some(Duration::from_secs(60 * 60)), + })), + }) + } + + fn without_rotated_tokens() -> Arc { + Arc::new(Self { + calls: AtomicUsize::new(0), + response: Mutex::new(Some(TokenPair { + access_token: None, + refresh_token: None, + id_token: None, + expires_in: Some(Duration::from_secs(60 * 60)), + })), + }) + } } #[async_trait] @@ -238,6 +265,77 @@ async fn scheduled_refresh_uses_the_current_margin_without_persisting_a_normal_s ); } +#[tokio::test] +async fn scheduled_refresh_persists_a_returned_id_token() { + let store = Arc::new(MemoryAccountStore::default()); + let policy = MutableRuntimePolicy::new(Duration::from_secs(5 * 60)); + let refresher = SingleUseRefresher::with_id_token("header.rotated-id.signature"); + let service = refresh_service(&store, refresher, policy); + let account_id = "acct_rotated_id_token"; + seed_refreshable_account( + &store, + account_id, + SystemTime::now() + .checked_add(Duration::from_secs(120)) + .expect("test expiry"), + None, + ) + .await; + + service.refresh_due().await.expect("refresh cycle"); + + let account = store.account(account_id).expect("refreshed account"); + let loaded = store + .load_credential(account.id(), account.revision()) + .await + .expect("refreshed credential"); + let runtime = CodexCredentialCodec::decode(&loaded.credential).expect("runtime credential"); + assert_eq!( + runtime + .authentication + .oauth() + .and_then(|oauth| oauth.id_token.as_ref()) + .map(SecretString::expose_secret), + Some("header.rotated-id.signature") + ); +} + +#[tokio::test] +async fn scheduled_refresh_preserves_the_stored_token_set_when_rotation_fields_are_omitted() { + let store = Arc::new(MemoryAccountStore::default()); + let policy = MutableRuntimePolicy::new(Duration::from_secs(5 * 60)); + let refresher = SingleUseRefresher::without_rotated_tokens(); + let service = refresh_service(&store, refresher, policy); + let account_id = "acct_omitted_token_rotation"; + let expires_at = SystemTime::now() + .checked_add(Duration::from_secs(120)) + .expect("test expiry"); + seed_refreshable_account(&store, account_id, expires_at, None).await; + + service.refresh_due().await.expect("refresh cycle"); + + let account = store.account(account_id).expect("refreshed account"); + assert_eq!(account.access_token_expires_at(), Some(expires_at)); + let loaded = store + .load_credential(account.id(), account.revision()) + .await + .expect("refreshed credential"); + let runtime = CodexCredentialCodec::decode(&loaded.credential).expect("runtime credential"); + let oauth = runtime.authentication.oauth().expect("OAuth credential"); + assert_eq!( + oauth.access_token.expose_secret(), + format!("access-{account_id}") + ); + let expected_refresh_token = format!("rt-access-{account_id}"); + assert_eq!( + oauth + .refresh_token + .as_ref() + .map(SecretString::expose_secret), + Some(expected_refresh_token.as_str()) + ); +} + #[tokio::test] async fn scheduled_refresh_respects_retry_not_before_until_it_has_elapsed() { let store = Arc::new(MemoryAccountStore::default()); diff --git a/backend/crates/providers/openai/tests/credential/token_client.rs b/backend/crates/providers/openai/tests/credential/token_client.rs index 76b685226..60a90e2d4 100644 --- a/backend/crates/providers/openai/tests/credential/token_client.rs +++ b/backend/crates/providers/openai/tests/credential/token_client.rs @@ -59,6 +59,7 @@ async fn bounded_oauth_response_should_parse_lifetime_and_rotated_token() { .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "access_token": "access-rotated", "refresh_token": "refresh-rotated", + "id_token": "header.e30.signature", "expires_in": 3600 }))) .expect(1) @@ -70,11 +71,54 @@ async fn bounded_oauth_response_should_parse_lifetime_and_rotated_token() { .await .expect("bounded response"); - assert_eq!(tokens.access_token, "access-rotated"); + assert_eq!(tokens.access_token.as_deref(), Some("access-rotated")); assert_eq!(tokens.refresh_token.as_deref(), Some("refresh-rotated")); + assert_eq!(tokens.id_token.as_deref(), Some("header.e30.signature")); assert_eq!(tokens.expires_in, Some(Duration::from_secs(3600))); } +#[tokio::test] +async fn refresh_response_should_reject_a_malformed_rotated_id_token() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/oauth/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "access_token": "access-rotated", + "id_token": "not-a-jwt" + }))) + .expect(1) + .mount(&server) + .await; + + let failure = client(&server) + .refresh("refresh-initial") + .await + .expect_err("malformed ID token must not replace the stored token set"); + + assert_eq!(failure, RefreshFailure::Transport); +} + +#[tokio::test] +async fn refresh_response_should_allow_all_rotated_tokens_to_be_omitted() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/oauth/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({}))) + .expect(1) + .mount(&server) + .await; + + let tokens = client(&server) + .refresh("refresh-initial") + .await + .expect("official refresh response fields are optional"); + + assert!(tokens.access_token.is_none()); + assert!(tokens.refresh_token.is_none()); + assert!(tokens.id_token.is_none()); + assert!(tokens.expires_in.is_none()); +} + #[tokio::test] async fn refresh_response_keeps_the_upstream_rotated_token_verbatim() { let server = MockServer::start().await; diff --git a/docs/api.md b/docs/api.md index 013f042b2..cc5eac0b2 100644 --- a/docs/api.md +++ b/docs/api.md @@ -96,7 +96,7 @@ OpenAI 路径保留客户端 Responses wire 语义:请求 body 的未知字段 | `GET` | `/api/admin/accounts/detail` | `accountId` | 查询账号详情、额度和本地用量 | | `GET` | `/api/admin/accounts/export` | `accountIds`、`confirm=export_sensitive_accounts` | 显式导出最多 200 个账号的敏感 Provider 文档 | | `POST` | `/api/admin/accounts/import` | `{ provider, data }` | 导入或按上游身份更新账号 | -| `POST` | `/api/admin/accounts/refresh` | `{ accountId }` | 手工刷新 OAuth credential(AT/RT),不刷新额度 | +| `POST` | `/api/admin/accounts/refresh` | `{ accountId }` | 手工刷新 OAuth credential(`idToken` / `accessToken` / `refreshToken`),不刷新额度 | | `POST` | `/api/admin/accounts/rotate` | OpenAI rotation 字段 | 手工替换 OpenAI OAuth token | | `POST` | `/api/admin/accounts/enable` | `{ provider, accountId }` | 启用账号 | | `POST` | `/api/admin/accounts/disable` | `{ provider, accountId }` | 禁用账号 | @@ -150,6 +150,7 @@ OpenAI rotation 请求字段为: { "provider": "openai", "accountId": "acct_...", + "idToken": "...", "accessToken": "...", "refreshToken": "..." } @@ -171,11 +172,15 @@ OAuth start 使用: ### OpenAI 身份、额度与状态 -- OAuth 文件导入使用可用的 access token 调用 OpenAI 已认证账号接口,补全实际使用的账号 ID、用户 ID、 - 邮箱和套餐;仅有 refresh token 时,先换取 access token 后执行同一补全。导入不要求 JWT payload 含有 - 特定字段,不执行 JWKS 验签,也不将文件中的身份字段与上游资料比对。 -- 首次 OAuth 保留回调 `state`、PKCE 与官方 token exchange;换得 access token 后调用同一已认证账号接口 - 补全账号资料。重新授权也保留这些回调保护,但只轮换目标账号的 token。回调地址只承载 `code`/`state`, +- OAuth 文件导入的 canonical 凭据字段为 `accessToken`、`refreshToken`、`idToken`,不接受含义模糊的 + `token`。仅有 refresh token 时先换取 access token。 +- 身份补全复用官方 `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`。 +- 首次 OAuth 保留回调 `state`、PKCE 与官方 token exchange,并持久化 `idToken`、`accessToken`、 + `refreshToken`。刷新响应中的三个 token 字段均按官方语义独立轮换:返回新值时替换,省略时分别保留 + 现值。重新授权也保留这些回调保护,但只轮换目标账号的 token。回调地址只承载 `code`/`state`, 不以 host/path 形式作为拒绝条件。 - 账号文件导入和首次 OAuth 创建在 credential 提交后立即尝试一次额度观测。观测失败只记录告警, 不回滚已提交的账号;重新授权和手工或后台 RT 刷新只更新 token,不隐式等同于手工额度刷新,也不更新 diff --git a/docs/architecture.md b/docs/architecture.md index 8ef75f0eb..79f4d2595 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -130,11 +130,13 @@ OAuth refresh 请求状态不明确时只按 OAuth 自身的有界退避推进 `refresh_token_expires_at` 不是公共 SQL 列或 Core 权威状态。xAI 可在 `provider_credentials_json` 内保存它作为 Provider 私有提示;真正失效以 refresh endpoint 返回的永久错误为准。 -OpenAI OAuth 导入先取得可用 access token(仅有 refresh token 时先交换),再调用 OpenAI 已认证账号 -接口补全实际使用的账号 ID、用户 ID、邮箱和套餐。首次 OAuth 在服务端 `state`、PKCE 与官方 token -exchange 成功后执行同一补全。两条路径不执行 JWKS 验签,不要求 JWT payload 具备指定字段,也不将导入 -材料中的身份与上游资料做一致性比对。重新授权的目标绑定只保存目标账号 ID,不接受或冻结客户端提供的 -credential revision;complete 阶段重新读取目标账号及其当前 revision,并以当前 revision 做最终 CAS。 +OpenAI OAuth 导入先取得 access token(仅有 refresh token 时先交换),再复用官方 +`token_data.rs::parse_chatgpt_jwt_claims` 的本地 JWT payload 解析逻辑:优先读取 `idToken`,字段缺失时 +由 `accessToken` 补齐。解析器只读取 `email`、`https://api.openai.com/profile` 与 +`https://api.openai.com/auth` 中的官方 claims,不调用 `whoami`,也不使用导入文档顶层的 +`userId/accountId` 补身份。两个令牌都没有用户身份 claims 时,账号以未验证错误状态入库。首次 OAuth +在服务端 `state`、PKCE 与官方 token exchange 成功后解析并保存同一 token set。重新授权的目标绑定 +只保存目标账号 ID,不接受或冻结客户端提供的 credential revision;complete 阶段重新读取目标账号及其当前 revision,并以当前 revision 做最终 CAS。 重新授权和手工或后台 RT refresh 只轮换目标账号的 token,保留既有账号资料与 OAuth principal。 credential 与 quota 是两组独立事实,不是两套对外状态。主动 quota refresh 会拒绝过期 Access Token; diff --git a/frontend/src/views/accounts/components/AccountTableActions.vue b/frontend/src/views/accounts/components/AccountTableActions.vue index 50bfab847..bc59df81c 100644 --- a/frontend/src/views/accounts/components/AccountTableActions.vue +++ b/frontend/src/views/accounts/components/AccountTableActions.vue @@ -73,7 +73,7 @@ const canReauthorize = computed(() => props.account.provider === 'openai') @click.stop="(close(), emit('refresh', account.id))" > - 刷新 token + 刷新令牌