From 97b91d340920953a9f3add97c9490f6825da4b64 Mon Sep 17 00:00:00 2001 From: zyycn Date: Sun, 26 Jul 2026 23:38:05 +0800 Subject: [PATCH] fix(xai): stop batch import from discarding rotated refresh tokens prepare_import exchanged refresh tokens entry by entry but aborted the whole batch on any later failure, dropping every already-rotated token: the source file's old tokens were consumed upstream, so a re-import hit invalid_grant and the credentials were unrecoverable. A document containing the same account twice triggered this deterministically via the duplicate- subject rejection. Deduplicate identical refresh tokens at document parse (side-effect-free, first entry wins) so duplicated entries can no longer invalidate each other's exchange. In prepare_import, make entries independent: a failed entry is skipped and the first failure is returned only when no entry succeeded; a second grant for an already-imported subject is skipped instead of failing the batch. Co-authored-by: Claude --- backend/crates/providers/xai/src/admin.rs | 64 ++++++++++++------- .../providers/xai/src/credential/import.rs | 6 ++ .../providers/xai/tests/credential/import.rs | 23 ++++++- 3 files changed, 69 insertions(+), 24 deletions(-) diff --git a/backend/crates/providers/xai/src/admin.rs b/backend/crates/providers/xai/src/admin.rs index 7676d4920..a757d4ab5 100644 --- a/backend/crates/providers/xai/src/admin.rs +++ b/backend/crates/providers/xai/src/admin.rs @@ -316,36 +316,56 @@ impl ProviderAdmin for XaiAdminProvider { .map_err(map_provider_store_error)?; let mut subjects = BTreeSet::new(); let mut credentials = Vec::new(); + // 逐条目独立成败:verify 会真实轮换 RT,后续条目的任何失败都不得 + // 丢弃已轮换成功的凭据(旧 RT 已被上游作废,丢弃即不可逆报废)。 + // 失败条目跳过;仅当没有任何条目成功时返回首个失败。 + let mut first_failure: Option = None; for entry in document.into_entries() { let name = entry.name().to_owned(); let email = entry.email().map(str::to_owned); - let tokens = self + let tokens = match self .oauth .verify_imported_credential(&discovery, entry.into_candidate()) .await - .map_err(|error| map_failure_class(error.class()))?; + { + Ok(tokens) => tokens, + Err(error) => { + first_failure.get_or_insert_with(|| map_failure_class(error.class())); + continue; + } + }; + // 同一账号的另一份 grant:账号已由更早条目入册,本条目跳过。 if !subjects.insert(tokens.evidence().subject().to_owned()) { - return Err(provider_error(ProviderAdminErrorKind::Invalid)); + continue; } - let prepared = GrokCredentialAdmin - .prepare_verified_account( - &VerifiedGrokAccount { - account_id: ProviderAccountId::new(format!( - "acct_{}", - Uuid::now_v7().simple() - )) - .map_err(|_| provider_error(ProviderAdminErrorKind::Internal))?, - name, - email, - upstream_account_id: None, - plan_type: None, - tokens, - enabled: true, - }, - policy, - ) - .map_err(map_repository_error)?; - credentials.push(prepared_create(prepared, Utc::now())?); + let account = VerifiedGrokAccount { + account_id: ProviderAccountId::new(format!("acct_{}", Uuid::now_v7().simple())) + .map_err(|_| provider_error(ProviderAdminErrorKind::Internal))?, + name, + email, + upstream_account_id: None, + plan_type: None, + tokens, + enabled: true, + }; + let prepared = match GrokCredentialAdmin.prepare_verified_account(&account, policy) { + Ok(prepared) => prepared, + Err(error) => { + first_failure.get_or_insert_with(|| map_repository_error(error)); + continue; + } + }; + match prepared_create(prepared, Utc::now()) { + Ok(credential) => credentials.push(credential), + Err(error) => { + first_failure.get_or_insert(error); + } + } + } + if credentials.is_empty() { + return Err( + first_failure.unwrap_or_else(|| provider_error(ProviderAdminErrorKind::Invalid)) + ); } Ok(PreparedCredentialImport { provider_kind: self.provider_kind.clone(), diff --git a/backend/crates/providers/xai/src/credential/import.rs b/backend/crates/providers/xai/src/credential/import.rs index 5e4d31fe1..ded48e350 100644 --- a/backend/crates/providers/xai/src/credential/import.rs +++ b/backend/crates/providers/xai/src/credential/import.rs @@ -172,6 +172,12 @@ impl GrokOAuthImportDocument { entries.push(entry); } } + // 同一 refresh token 的重复条目只保留首个:RT exchange 会作废旧 + // grant,重复条目逐条交换会让先完成的轮换被后续条目作废。 + let mut seen_refresh_tokens = HashSet::new(); + entries.retain(|entry| { + seen_refresh_tokens.insert(entry.candidate.refresh_token.expose().to_owned()) + }); if entries.is_empty() { return Err(GrokOAuthImportError::InvalidField("document")); } diff --git a/backend/crates/providers/xai/tests/credential/import.rs b/backend/crates/providers/xai/tests/credential/import.rs index e92120582..844e3ff71 100644 --- a/backend/crates/providers/xai/tests/credential/import.rs +++ b/backend/crates/providers/xai/tests/credential/import.rs @@ -283,7 +283,7 @@ fn minimal_oauth_credential_should_not_require_a_source_format() { } #[test] -fn oauth_account_document_should_not_require_unique_source_names() { +fn duplicate_refresh_token_entries_should_collapse_to_the_first() { let mut document: serde_json::Value = serde_json::from_slice(&oauth_account_document()).expect("fixture JSON"); let duplicate = document["accounts"][0].clone(); @@ -292,10 +292,29 @@ fn oauth_account_document_should_not_require_unique_source_names() { .expect("accounts array") .push(duplicate); + // 同一 refresh token 重复出现时解析层去重,避免逐条 RT exchange + // 让先完成的轮换被后续重复条目作废。 let parsed = GrokOAuthImportDocument::parse_json( &serde_json::to_vec(&document).expect("serialize fixture"), ) - .expect("upstream identity verification owns duplicate detection"); + .expect("duplicate refresh tokens collapse at parse"); + assert_eq!(parsed.into_entries().len(), 1); + + // 不同 refresh token 的同名条目不受影响,重复检测仍归上游身份验证。 + let mut document: serde_json::Value = + serde_json::from_slice(&oauth_account_document()).expect("fixture JSON"); + let mut distinct = document["accounts"][0].clone(); + distinct["credentials"]["refresh_token"] = + serde_json::Value::String("fixture-refresh-token-second".to_owned()); + document["accounts"] + .as_array_mut() + .expect("accounts array") + .push(distinct); + + let parsed = GrokOAuthImportDocument::parse_json( + &serde_json::to_vec(&document).expect("serialize fixture"), + ) + .expect("distinct refresh tokens keep their entries"); assert_eq!(parsed.into_entries().len(), 2); }