From d0dc7ef92b9dfade4b80419772dcda45bbbdec41 Mon Sep 17 00:00:00 2001 From: Zyy Date: Thu, 6 Aug 2026 12:31:13 +0800 Subject: [PATCH] fix(openai): complete import profile from access token Co-authored-by: Codex --- backend/crates/providers/openai/src/admin.rs | 9 +- .../providers/openai/src/credential/admin.rs | 199 +++------- .../providers/openai/src/credential/oauth.rs | 23 +- backend/crates/providers/openai/src/lib.rs | 9 +- .../openai/tests/credential/admin.rs | 346 +++++++++++------- .../openai/tests/credential/oauth.rs | 157 ++------ docs/api.md | 16 +- docs/architecture.md | 15 +- 8 files changed, 350 insertions(+), 424 deletions(-) diff --git a/backend/crates/providers/openai/src/admin.rs b/backend/crates/providers/openai/src/admin.rs index 9a7c58c81..28a523ae9 100644 --- a/backend/crates/providers/openai/src/admin.rs +++ b/backend/crates/providers/openai/src/admin.rs @@ -1381,14 +1381,15 @@ fn map_credential_admin_error(error: CodexCredentialAdminError) -> ProviderAdmin | Error::MissingRefreshToken | Error::RefreshRejected | Error::AccountBanned + | Error::AccountProfileRejected | Error::IdentityRejected => ProviderAdminErrorKind::Invalid, Error::NotFound => ProviderAdminErrorKind::NotFound, Error::RefreshLeaseUnavailable | Error::RefreshAmbiguous => { ProviderAdminErrorKind::Conflict } - Error::RefreshUnavailable | Error::IdentityUnavailable => { - ProviderAdminErrorKind::Unavailable - } + Error::RefreshUnavailable + | Error::AccountProfileUnavailable + | Error::IdentityUnavailable => ProviderAdminErrorKind::Unavailable, }) } @@ -1402,6 +1403,8 @@ const fn credential_admin_error_code(error: CodexCredentialAdminError) -> &'stat CodexCredentialAdminError::RefreshLeaseUnavailable => "refresh_lease_unavailable", CodexCredentialAdminError::RefreshRejected => "refresh_rejected", CodexCredentialAdminError::AccountBanned => "account_banned", + CodexCredentialAdminError::AccountProfileRejected => "account_profile_rejected", + CodexCredentialAdminError::AccountProfileUnavailable => "account_profile_unavailable", CodexCredentialAdminError::RefreshUnavailable => "refresh_unavailable", CodexCredentialAdminError::RefreshAmbiguous => "refresh_ambiguous", CodexCredentialAdminError::IdentityRejected => "identity_rejected", diff --git a/backend/crates/providers/openai/src/credential/admin.rs b/backend/crates/providers/openai/src/credential/admin.rs index 3476915bc..8d808d3f1 100644 --- a/backend/crates/providers/openai/src/credential/admin.rs +++ b/backend/crates/providers/openai/src/credential/admin.rs @@ -8,10 +8,6 @@ use std::fmt; use std::sync::Arc; use std::time::SystemTime; -use base64::{ - Engine as _, - engine::general_purpose::{URL_SAFE, URL_SAFE_NO_PAD}, -}; use chrono::{DateTime, FixedOffset, Utc}; use gateway_core::engine::credential::{ AccountAvailability, CredentialCasUpdate, CredentialRevision, LoadedCredential, @@ -29,7 +25,9 @@ use serde::Serialize; use serde_json::Value; use thiserror::Error; -use super::identity::CodexAccountIdentityVerifier; +use super::identity::{ + CodexAuthenticatedAccountSource, CodexIdentityExpectation, CodexIdentityVerificationError, +}; use super::security::CodexCredentialCodec; use super::token_client::{RefreshFailure, TokenRefresher}; use super::types::{ @@ -42,7 +40,6 @@ const PROVIDER_NAME: &str = "openai"; const CODEX_CREDENTIAL_SCHEMA_VERSION: u32 = 1; const MAX_BATCH: usize = 200; const MAX_IMPORT_DOCUMENT_BYTES: usize = 64 * 1024 * 1024; -const MAX_IMPORT_IDENTITY_BYTES: usize = 2_048; const MAX_IMPORT_EMAIL_BYTES: usize = 512; const MAX_IMPORT_PLAN_BYTES: usize = 128; @@ -119,15 +116,6 @@ enum ParsedCodexAuthentication { }, } -#[derive(Default)] -struct UnverifiedOAuthClaims { - expires_at: Option>, - chatgpt_account_id: Option, - chatgpt_user_id: Option, - email: Option, - plan_type: Option, -} - impl fmt::Debug for ParsedCodexAuthentication { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { @@ -379,6 +367,10 @@ pub enum CodexCredentialAdminError { RefreshUnavailable, #[error("Codex refresh send state is ambiguous")] RefreshAmbiguous, + #[error("Codex account profile request was rejected")] + AccountProfileRejected, + #[error("Codex account profile request is unavailable")] + AccountProfileUnavailable, #[error("Codex refreshed identity was rejected")] IdentityRejected, #[error("Codex identity verification is unavailable")] @@ -401,8 +393,7 @@ impl CodexCredentialAdmin { if input.name.trim().is_empty() { return Err(CodexCredentialAdminError::InvalidInput); } - let access_token_expires_at = - required_time(input.verified_account.access_token_expires_at)?; + let access_token_expires_at = optional_time(input.verified_account.access_token_expires_at); let revision = CredentialRevision::new(1).map_err(|_| CodexCredentialAdminError::InvalidCredential)?; let upstream_user_id = input.verified_account.chatgpt_user_id.clone(); @@ -416,7 +407,7 @@ impl CodexCredentialAdmin { upstream_user_id, CODEX_AUTHENTICATION_KIND_OAUTH.to_owned(), revision, - Some(access_token_expires_at), + access_token_expires_at, ) .with_profile( input.verified_account.email, @@ -650,6 +641,7 @@ impl CodexCredentialAdmin { /// 有状态的 Codex 手工刷新边界;消费调用方刚读取的当前 credential 并准备 CAS。 pub struct CodexCredentialAdminService { refresher: Arc, + account_source: Arc, leases: Arc, runtime_policy: Arc, } @@ -659,6 +651,7 @@ impl fmt::Debug for CodexCredentialAdminService { formatter .debug_struct("CodexCredentialAdminService") .field("refresher", &"TokenRefresher") + .field("account_source", &"CodexAuthenticatedAccountSource") .field("leases", &"ProviderLeasePort") .field("runtime_policy", &"ProviderRuntimePolicyPort") .finish() @@ -668,12 +661,13 @@ impl fmt::Debug for CodexCredentialAdminService { impl CodexCredentialAdminService { pub fn new( refresher: Arc, - _verifier: Arc, + account_source: Arc, leases: Arc, runtime_policy: Arc, ) -> Self { Self { refresher, + account_source, leases, runtime_policy, } @@ -769,7 +763,7 @@ impl CodexCredentialAdminService { Ok(prepared) } - /// 对导入 token 做轻量 payload 检查,并归一到唯一 `NewProviderAccount` 写入路径。 + /// 用 access token 补全账号事实,并归一到唯一 `NewProviderAccount` 写入路径。 pub async fn prepare_import_document( &self, payload: Value, @@ -802,8 +796,13 @@ impl CodexCredentialAdminService { let (secret, access_token_expires_at) = self .resolve_import_tokens(access_token.clone(), refresh_token.clone()) .await?; - let mut verified_account = - unverified_oauth_profile(&secret, &account_id, access_token_expires_at); + let mut verified_account = complete_oauth_account_profile( + self.account_source.as_ref(), + &secret, + access_token_expires_at, + ) + .await + .map_err(map_account_profile_error)?; apply_import_metadata(&mut verified_account, &candidate); if !upstream_identities.insert(( verified_account.chatgpt_user_id.clone(), @@ -890,13 +889,12 @@ impl CodexCredentialAdminService { .map(normalize_bearer) .filter(|token| !token.is_empty()); if let Some(access_token) = access_token { - validate_unverified_import_access_token(&access_token)?; let secret = CodexOAuthSecret { - access_token: SecretString::from(access_token.clone()), + access_token: SecretString::from(access_token), refresh_token: refresh_token.map(SecretString::from), id_token: None, }; - return Ok((secret, unverified_access_token_expiry(&access_token))); + return Ok((secret, None)); } let refresh_token = refresh_token.ok_or(CodexCredentialAdminError::InvalidCredential)?; let tokens = self @@ -907,7 +905,6 @@ impl CodexCredentialAdminService { if tokens.access_token.trim().is_empty() || tokens.expires_in.is_zero() { return Err(CodexCredentialAdminError::InvalidCredential); } - validate_unverified_import_access_token(&tokens.access_token)?; let secret = CodexOAuthSecret { access_token: SecretString::from(tokens.access_token), // RT 未轮换时仍保留导入提供的 RT,保证本次补全不会丢失后续刷新能力。 @@ -1013,9 +1010,12 @@ pub(crate) fn refresh_time( if !has_refresh_token { return Ok(None); } - let expires_at = required_time(access_token_expires_at)?; + let observed_at = SystemTime::now(); + let expires_at = access_token_expires_at + .map(SystemTime::from) + .unwrap_or(observed_at); policy - .next_attempt_at(account_id, expires_at, SystemTime::now()) + .next_attempt_at(account_id, expires_at, observed_at) .map(DateTime::::from) .map(Some) .map_err(|_| CodexCredentialAdminError::InvalidCredential) @@ -1075,36 +1075,8 @@ fn parse_oauth_import_account( email: first_string(value, &["email"]).or_else(|| first_string(credentials, &["email"])), plan_type: first_string(value, &["plan_type", "planType"]) .or_else(|| first_string(credentials, &["plan_type", "planType"])), - chatgpt_account_id: first_string( - value, - &[ - "chatgpt_account_id", - "chatgptAccountId", - "account_id", - "accountId", - ], - ) - .or_else(|| { - first_string( - credentials, - &[ - "chatgpt_account_id", - "chatgptAccountId", - "account_id", - "accountId", - ], - ) - }), - chatgpt_user_id: first_string( - value, - &["chatgpt_user_id", "chatgptUserId", "user_id", "userId"], - ) - .or_else(|| { - first_string( - credentials, - &["chatgpt_user_id", "chatgptUserId", "user_id", "userId"], - ) - }), + chatgpt_account_id: None, + chatgpt_user_id: None, authentication: parse_oauth_import_tokens(value)?, status: first_string(value, &["status"]), }) @@ -1271,39 +1243,23 @@ fn normalize_bearer(value: &str) -> String { .to_owned() } -pub(crate) fn unverified_oauth_profile( +pub(crate) async fn complete_oauth_account_profile( + account_source: &dyn CodexAuthenticatedAccountSource, secret: &CodexOAuthSecret, - fallback_account_id: &str, access_token_expires_at: Option>, -) -> CodexAccountProfile { - let claims = - unverified_access_token_claims(secret.access_token.expose_secret()).unwrap_or_default(); - let fallback_identity = imported_identity(fallback_account_id); - let chatgpt_account_id = claims - .chatgpt_account_id - .as_deref() - .and_then(normalize_import_identity) - .unwrap_or_else(|| fallback_identity.clone()); - let chatgpt_user_id = claims - .chatgpt_user_id - .as_deref() - .and_then(normalize_import_identity) - .unwrap_or_else(|| chatgpt_account_id.clone()); - CodexAccountProfile { - email: claims - .email - .as_deref() - .and_then(|value| normalize_import_text(value, MAX_IMPORT_EMAIL_BYTES)), - oauth_subject: chatgpt_user_id.clone(), +) -> Result { + let account = account_source + .fetch(secret, &CodexIdentityExpectation::default()) + .await?; + Ok(CodexAccountProfile { + email: account.email, + oauth_subject: account.chatgpt_user_id.clone(), poid: None, - chatgpt_account_id, - chatgpt_user_id, - plan_type: claims - .plan_type - .as_deref() - .and_then(|value| normalize_import_text(value, MAX_IMPORT_PLAN_BYTES)), - access_token_expires_at: access_token_expires_at.or(claims.expires_at), - } + chatgpt_account_id: account.chatgpt_account_id, + chatgpt_user_id: account.chatgpt_user_id, + plan_type: account.plan_type, + access_token_expires_at, + }) } fn apply_import_metadata(profile: &mut CodexAccountProfile, candidate: &ParsedCodexImportAccount) { @@ -1321,64 +1277,17 @@ fn apply_import_metadata(profile: &mut CodexAccountProfile, candidate: &ParsedCo } } -fn validate_unverified_import_access_token(token: &str) -> Result<(), CodexCredentialAdminError> { - let claims = unverified_access_token_claims(token) - .ok_or(CodexCredentialAdminError::InvalidCredential)?; - if claims - .expires_at - .is_none_or(|expires_at| expires_at <= Utc::now()) - || claims - .chatgpt_account_id - .as_deref() - .is_none_or(|account_id| account_id.trim().is_empty()) - { - return Err(CodexCredentialAdminError::InvalidCredential); +const fn map_account_profile_error( + error: CodexIdentityVerificationError, +) -> CodexCredentialAdminError { + match error { + CodexIdentityVerificationError::Rejected => { + CodexCredentialAdminError::AccountProfileRejected + } + CodexIdentityVerificationError::Unavailable => { + CodexCredentialAdminError::AccountProfileUnavailable + } } - Ok(()) -} - -fn unverified_access_token_expiry(token: &str) -> Option> { - unverified_access_token_claims(token).and_then(|claims| claims.expires_at) -} - -fn unverified_access_token_claims(token: &str) -> Option { - let payload = token.split('.').nth(1)?; - let decoded = URL_SAFE_NO_PAD - .decode(payload) - .or_else(|_| URL_SAFE.decode(payload)) - .ok()?; - let claims = serde_json::from_slice::(&decoded).ok()?; - let claims = claims.as_object()?; - let auth = claims - .get("https://api.openai.com/auth") - .and_then(Value::as_object); - let profile = claims - .get("https://api.openai.com/profile") - .and_then(Value::as_object); - let text = |object: Option<&serde_json::Map>, key| { - object - .and_then(|object| object.get(key)) - .and_then(Value::as_str) - .map(str::to_owned) - }; - Some(UnverifiedOAuthClaims { - expires_at: claims - .get("exp") - .and_then(Value::as_i64) - .and_then(|seconds| DateTime::::from_timestamp(seconds, 0)), - chatgpt_account_id: text(auth, "chatgpt_account_id"), - chatgpt_user_id: text(auth, "chatgpt_user_id").or_else(|| text(profile, "chatgpt_user_id")), - email: text(profile, "email"), - plan_type: text(auth, "chatgpt_plan_type").or_else(|| text(profile, "chatgpt_plan_type")), - }) -} - -fn imported_identity(account_id: &str) -> String { - format!("imported_{}", account_id.trim_start_matches("acct_")) -} - -fn normalize_import_identity(value: &str) -> Option { - normalize_import_text(value, MAX_IMPORT_IDENTITY_BYTES) } fn normalize_import_text(value: &str, max_bytes: usize) -> Option { diff --git a/backend/crates/providers/openai/src/credential/oauth.rs b/backend/crates/providers/openai/src/credential/oauth.rs index 2ae67ae94..8001e2a56 100644 --- a/backend/crates/providers/openai/src/credential/oauth.rs +++ b/backend/crates/providers/openai/src/credential/oauth.rs @@ -28,9 +28,9 @@ use uuid::Uuid; use super::admin::{ CodexCredentialAdmin, CodexCredentialAdminError, ImportCodexOAuthCredential, - PreparedCodexCredentialRotation, refresh_time, unverified_oauth_profile, + PreparedCodexCredentialRotation, complete_oauth_account_profile, refresh_time, }; -use super::identity::CodexAccountIdentityVerifier; +use super::identity::{CodexAuthenticatedAccountSource, CodexIdentityVerificationError}; use super::token_client::{ AuthorizationCodeExchangeError, AuthorizationCodeExchanger, AuthorizationCodeGrant, OFFICIAL_CODEX_OAUTH_CLIENT_ID, OFFICIAL_CODEX_REDIRECT_URI, @@ -346,6 +346,7 @@ pub trait CodexOAuthAdmin: Send + Sync { pub struct CodexOAuthAdminService { pending: Arc, exchanger: Arc, + account_source: Arc, store: Arc, runtime_policy: Arc, credentials: CodexCredentialAdmin, @@ -397,7 +398,7 @@ impl CodexOAuthAdminService { pub fn new( pending: Arc, exchanger: Arc, - _verifier: Arc, + account_source: Arc, store: Arc, runtime_policy: Arc, credentials: CodexCredentialAdmin, @@ -405,6 +406,7 @@ impl CodexOAuthAdminService { Self { pending, exchanger, + account_source, store, runtime_policy, credentials, @@ -532,11 +534,13 @@ impl CodexOAuthAdminService { let account_id = format!("acct_{}", Uuid::now_v7().simple()); let typed_account_id = ProviderAccountId::new(account_id.clone()) .map_err(|_| CodexOAuthAdminError::Credential)?; - let profile = unverified_oauth_profile( + let profile = complete_oauth_account_profile( + self.account_source.as_ref(), &secret, - typed_account_id.as_str(), Some(access_token_expires_at), - ); + ) + .await + .map_err(map_account_profile_error)?; let next_refresh_at = refresh_time( policy, &typed_account_id, @@ -809,6 +813,13 @@ fn map_exchange_error(error: AuthorizationCodeExchangeError) -> CodexOAuthAdminE } } +const fn map_account_profile_error(error: CodexIdentityVerificationError) -> CodexOAuthAdminError { + match error { + CodexIdentityVerificationError::Rejected => CodexOAuthAdminError::TokenRejected, + CodexIdentityVerificationError::Unavailable => CodexOAuthAdminError::UpstreamUnavailable, + } +} + fn map_admin_error(_: CodexCredentialAdminError) -> CodexOAuthAdminError { CodexOAuthAdminError::Credential } diff --git a/backend/crates/providers/openai/src/lib.rs b/backend/crates/providers/openai/src/lib.rs index e78434742..d9955c21e 100644 --- a/backend/crates/providers/openai/src/lib.rs +++ b/backend/crates/providers/openai/src/lib.rs @@ -156,13 +156,14 @@ pub async fn initialize( ReqwestCodexAuthenticatedAccountSource::new(profile.clone(), config.base_url().to_owned()) .map_err(|_| OpenAiInitializeError::Identity)?, ); - let identity: Arc = - Arc::new(CodexAccountIdentityService::new(signed, account_source)); + let identity: Arc = Arc::new( + CodexAccountIdentityService::new(signed, Arc::clone(&account_source)), + ); let refresher: Arc = token_client.clone(); let exchanger: Arc = token_client; let credential_admin = Arc::new(CodexCredentialAdminService::new( Arc::clone(&refresher), - Arc::clone(&identity), + Arc::clone(&account_source), Arc::clone(&leases), Arc::clone(&runtime_policy), )); @@ -182,7 +183,7 @@ pub async fn initialize( CodexOAuthAdminService::new( pending, exchanger, - Arc::clone(&identity), + account_source, Arc::clone(&accounts), Arc::clone(&runtime_policy), CodexCredentialAdmin, diff --git a/backend/crates/providers/openai/tests/credential/admin.rs b/backend/crates/providers/openai/tests/credential/admin.rs index 6d3af378f..44df4991d 100644 --- a/backend/crates/providers/openai/tests/credential/admin.rs +++ b/backend/crates/providers/openai/tests/credential/admin.rs @@ -1,10 +1,11 @@ +use std::collections::VecDeque; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; use std::time::Duration; use async_trait::async_trait; use base64::Engine as _; -use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}; +use base64::engine::general_purpose::STANDARD; use chrono::{TimeZone, Utc}; use ed25519_dalek::SigningKey; use ed25519_dalek::pkcs8::EncodePrivateKey as _; @@ -20,13 +21,13 @@ use gateway_core::provider_ports::{ use gateway_core::routing::ProviderKind; use provider_openai::credential::token_client::{RefreshFailure, TokenPair, TokenRefresher}; use provider_openai::credential::{ - CODEX_AUTHENTICATION_KIND_AGENT_IDENTITY, CodexAccountIdentityVerifier, - CodexAgentIdentityAuthMode, CodexAgentIdentityCredentialData, CodexCredentialAdmin, - CodexCredentialAdminError, CodexCredentialAdminService, CodexCredentialCodec, - CodexIdentityExpectation, CodexIdentityVerification, CodexIdentityVerificationError, + CODEX_AUTHENTICATION_KIND_AGENT_IDENTITY, CodexAgentIdentityAuthMode, + CodexAgentIdentityCredentialData, CodexAuthenticatedAccount, CodexAuthenticatedAccountSource, + CodexCredentialAdmin, CodexCredentialAdminError, CodexCredentialAdminService, + CodexCredentialCodec, CodexIdentityExpectation, CodexIdentityVerificationError, ExportManagedCodexCredential, ImportCodexOAuthCredential, RotateManagedCodexCredential, }; -use secrecy::{ExposeSecret, SecretString}; +use secrecy::ExposeSecret; use crate::support::{MemoryAccountStore, codex_account, profile, runtime_policy, secret}; @@ -46,22 +47,6 @@ fn encoded_credential(account_id: &str, token: &str) -> PlaintextCredential { .expect("encode current credential") } -fn unverified_import_access_token(account_id: &str, user_id: &str) -> String { - let header = URL_SAFE_NO_PAD.encode(br#"{"alg":"none","typ":"JWT"}"#); - let payload = URL_SAFE_NO_PAD.encode( - serde_json::to_vec(&serde_json::json!({ - "sub": format!("subject-{user_id}"), - "exp": Utc::now().timestamp() + 3_600, - "https://api.openai.com/auth": { - "chatgpt_account_id": account_id, - "chatgpt_user_id": user_id, - }, - })) - .expect("serialize unsigned import JWT"), - ); - format!("{header}.{payload}.") -} - #[test] fn prepare_import_returns_core_account_with_plaintext_json() { let prepared = CodexCredentialAdmin @@ -331,18 +316,25 @@ fn cpr_export_preserves_agent_identity_material_without_oauth_fields() { #[tokio::test] async fn cpr_export_and_import_support_mixed_oauth_and_agent_identity_accounts() { let (agent, _) = agent_export_item("acct_mixed_agent"); - let access_token = - unverified_import_access_token("chatgpt-acct_mixed_oauth", "user-acct_mixed_oauth"); + let access_token = "opaque-mixed-oauth"; let value = CodexCredentialAdmin - .format_cpr_export(vec![export_item("acct_mixed_oauth", &access_token), agent]) + .format_cpr_export(vec![export_item("acct_mixed_oauth", access_token), agent]) .expect("mixed export") .into_json() .expect("serialize mixed export"); - let prepared = import_service(unused_import_refresher()) - .prepare_import_document(value) - .await - .expect("mixed CPR import"); + let prepared = import_service_with_source( + unused_import_refresher(), + account_source([authenticated_account( + "chatgpt-mixed-oauth", + "user-mixed-oauth", + None, + None, + )]), + ) + .prepare_import_document(value) + .await + .expect("mixed CPR import"); assert_eq!(prepared.accounts().len(), 2); assert!(prepared.accounts().iter().any(|account| { account.account.authentication_kind() == CODEX_AUTHENTICATION_KIND_AGENT_IDENTITY @@ -375,29 +367,59 @@ impl TokenRefresher for ManualRefresher { } } -struct RejectingManualVerifier; +struct ManualAccountSource { + outcomes: Mutex>>, + seen: Mutex>, + expectations: Mutex>, +} #[async_trait] -impl CodexAccountIdentityVerifier for RejectingManualVerifier { - async fn verify( +impl CodexAuthenticatedAccountSource for ManualAccountSource { + async fn fetch( &self, - _secret: &provider_openai::credential::CodexOAuthSecret, - _expectation: &CodexIdentityExpectation, - ) -> Result { - Err(CodexIdentityVerificationError::Rejected) + secret: &provider_openai::credential::CodexOAuthSecret, + expectation: &CodexIdentityExpectation, + ) -> Result { + self.seen + .lock() + .expect("seen account-source access tokens") + .push(secret.access_token.expose_secret().to_owned()); + self.expectations + .lock() + .expect("seen account-source expectations") + .push(expectation.clone()); + self.outcomes + .lock() + .expect("account source outcomes") + .pop_front() + .unwrap_or(Err(CodexIdentityVerificationError::Unavailable)) } +} - async fn verify_authorization( - &self, - _secret: &provider_openai::credential::CodexOAuthSecret, - _id_token: &SecretString, - _expected_nonce: &SecretString, - _expectation: &CodexIdentityExpectation, - ) -> Result { - Err(CodexIdentityVerificationError::Rejected) +fn authenticated_account( + account_id: &str, + user_id: &str, + email: Option<&str>, + plan_type: Option<&str>, +) -> CodexAuthenticatedAccount { + CodexAuthenticatedAccount { + chatgpt_account_id: account_id.to_owned(), + chatgpt_user_id: user_id.to_owned(), + email: email.map(str::to_owned), + plan_type: plan_type.map(str::to_owned), } } +fn account_source( + outcomes: impl IntoIterator, +) -> Arc { + Arc::new(ManualAccountSource { + outcomes: Mutex::new(outcomes.into_iter().map(Ok).collect()), + seen: Mutex::new(Vec::new()), + expectations: Mutex::new(Vec::new()), + }) +} + struct DropCountGuard(Arc); impl Drop for DropCountGuard { @@ -483,7 +505,7 @@ async fn manual_refresh_fixture( }); let service = CodexCredentialAdminService::new( refresher.clone(), - Arc::new(RejectingManualVerifier), + account_source([]), leases.clone(), runtime_policy(), ); @@ -579,35 +601,19 @@ async fn manual_refresh_missing_lease_fails_before_exchange() { ); } -struct RejectingImportVerifier; - -#[async_trait] -impl CodexAccountIdentityVerifier for RejectingImportVerifier { - async fn verify( - &self, - _secret: &provider_openai::credential::CodexOAuthSecret, - _expectation: &CodexIdentityExpectation, - ) -> Result { - Err(CodexIdentityVerificationError::Rejected) - } - - async fn verify_authorization( - &self, - _secret: &provider_openai::credential::CodexOAuthSecret, - _id_token: &SecretString, - _expected_nonce: &SecretString, - _expectation: &CodexIdentityExpectation, - ) -> Result { - Err(CodexIdentityVerificationError::Rejected) - } -} - pub(super) fn import_service( refresher: Arc, +) -> provider_openai::credential::CodexCredentialAdminService { + import_service_with_source(refresher, account_source([])) +} + +fn import_service_with_source( + refresher: Arc, + source: Arc, ) -> provider_openai::credential::CodexCredentialAdminService { CodexCredentialAdminService::new( refresher, - Arc::new(RejectingImportVerifier), + source, Arc::new(ManualLeases { requests: Mutex::new(Vec::new()), drops: Arc::new(AtomicUsize::new(0)), @@ -625,22 +631,25 @@ pub(super) fn unused_import_refresher() -> Arc { } #[tokio::test] -async fn oauth_import_uses_token_identity_and_source_display_metadata() { +async fn oauth_import_accepts_opaque_access_token_and_completes_profile_from_account_source() { let refresher = unused_import_refresher(); - let access_token = unverified_import_access_token("chatgpt-cpr", "user-token-cpr"); - let prepared = import_service(refresher.clone()) + let access_token = "opaque-cpr-access"; + let source = account_source([authenticated_account( + "chatgpt-cpr", + "user-cpr", + Some("remote-cpr@example.com"), + Some("team"), + )]); + let prepared = import_service_with_source(refresher.clone(), Arc::clone(&source)) .prepare_import_document(serde_json::json!({ "sourceFormat": "cpr", "accounts": [{ "id": "acct_cpr_import", - "email": "cpr@example.com", - "accountId": "source-account-cpr", - "userId": "source-user-cpr", + "email": "untrusted-cpr@example.com", "label": "CPR account", - "planType": "pro", + "planType": "untrusted-plan", "token": format!("Bearer {access_token}"), "refreshToken": "refresh-cpr", - "accessTokenExpiresAt": "2100-01-01T00:00:00+00:00", "status": "disabled", "addedAt": "2026-07-18T10:47:01+08:00", "updatedAt": "2026-07-19T11:00:00+08:00", @@ -654,10 +663,11 @@ async fn oauth_import_uses_token_identity_and_source_display_metadata() { assert!(account.account.id().as_str().starts_with("acct_")); assert!(account.account.enabled()); assert_eq!(account.account.upstream_account_id(), Some("chatgpt-cpr")); - assert_eq!(account.account.upstream_user_id(), "user-token-cpr"); + assert_eq!(account.account.upstream_user_id(), "user-cpr"); assert_eq!(account.account.name(), "CPR account"); - assert_eq!(account.account.email(), Some("cpr@example.com")); - assert_eq!(account.account.plan_type(), Some("pro")); + assert_eq!(account.account.email(), Some("remote-cpr@example.com")); + assert_eq!(account.account.plan_type(), Some("team")); + assert_eq!(account.account.access_token_expires_at(), None); let runtime = CodexCredentialCodec::decode(&account.credential).expect("credential"); assert_eq!( runtime @@ -669,23 +679,43 @@ async fn oauth_import_uses_token_identity_and_source_display_metadata() { access_token ); assert!(refresher.seen.lock().expect("seen tokens").is_empty()); + assert_eq!( + source + .seen + .lock() + .expect("seen account-source access tokens") + .as_slice(), + [access_token] + ); + assert_eq!( + source + .expectations + .lock() + .expect("seen account-source expectations") + .as_slice(), + [CodexIdentityExpectation::default()] + ); } #[tokio::test] -async fn refresh_token_only_import_persists_exchanged_tokens_without_identity_verification() { - let refreshed_access = - unverified_import_access_token("chatgpt-refreshed-import", "user-refreshed-import"); - let expected_access = refreshed_access.clone(); +async fn refresh_token_only_import_persists_exchanged_tokens_and_completes_profile() { + let refreshed_access = "opaque-refreshed-import"; let refresher = Arc::new(ManualRefresher { outcome: Mutex::new(Some(Ok(TokenPair { - access_token: refreshed_access, + access_token: refreshed_access.to_owned(), refresh_token: Some("rotated-import-refresh".to_owned()), expires_in: Duration::from_secs(3_600), }))), seen: Mutex::new(Vec::new()), }); + let source = account_source([authenticated_account( + "chatgpt-refreshed-import", + "user-refreshed-import", + Some("refreshed@example.com"), + Some("plus"), + )]); - let prepared = import_service(Arc::clone(&refresher)) + let prepared = import_service_with_source(Arc::clone(&refresher), Arc::clone(&source)) .prepare_import_document(serde_json::json!({ "refreshToken": "refresh-only" })) @@ -699,9 +729,11 @@ async fn refresh_token_only_import_persists_exchanged_tokens_without_identity_ve Some("chatgpt-refreshed-import") ); assert_eq!(account.account.upstream_user_id(), "user-refreshed-import"); + assert_eq!(account.account.email(), Some("refreshed@example.com")); + assert_eq!(account.account.plan_type(), Some("plus")); let runtime = CodexCredentialCodec::decode(&account.credential).expect("credential"); let oauth = runtime.authentication.oauth().expect("OAuth credential"); - assert_eq!(oauth.access_token.expose_secret(), expected_access); + assert_eq!(oauth.access_token.expose_secret(), refreshed_access); assert_eq!( oauth .refresh_token @@ -718,25 +750,43 @@ async fn refresh_token_only_import_persists_exchanged_tokens_without_identity_ve .as_slice(), ["refresh-only"] ); + assert_eq!( + source + .seen + .lock() + .expect("seen account-source access tokens") + .as_slice(), + [refreshed_access] + ); + assert_eq!( + source + .expectations + .lock() + .expect("seen account-source expectations") + .as_slice(), + [CodexIdentityExpectation::default()] + ); } #[tokio::test] async fn cpr_batch_allows_distinct_users_in_the_same_workspace() { - let shared_a = unverified_import_access_token("chatgpt-shared", "user-a"); - let shared_b = unverified_import_access_token("chatgpt-shared", "user-b"); - let prepared = import_service(unused_import_refresher()) + let source = account_source([ + authenticated_account("chatgpt-shared", "user-a", None, None), + authenticated_account("chatgpt-shared", "user-b", None, None), + ]); + let prepared = import_service_with_source(unused_import_refresher(), Arc::clone(&source)) .prepare_import_document(serde_json::json!({ "sourceFormat": "cpr", "accounts": [{ "id": "acct_shared_a", "accountId": "chatgpt-shared", "userId": "user-a", - "token": shared_a + "token": "opaque-shared-a" }, { "id": "acct_shared_b", "accountId": "chatgpt-shared", "userId": "user-b", - "token": shared_b + "token": "opaque-shared-b" }] })) .await @@ -750,20 +800,23 @@ async fn cpr_batch_allows_distinct_users_in_the_same_workspace() { .all(|account| { account.account.upstream_account_id() == Some("chatgpt-shared") }) ); - let duplicate_token = unverified_import_access_token("chatgpt-shared", "user-a"); - let duplicate = import_service(unused_import_refresher()) + let duplicate_source = account_source([ + authenticated_account("chatgpt-shared", "user-a", None, None), + authenticated_account("chatgpt-shared", "user-a", None, None), + ]); + let duplicate = import_service_with_source(unused_import_refresher(), duplicate_source) .prepare_import_document(serde_json::json!({ "sourceFormat": "cpr", "accounts": [{ "id": "acct_duplicate_a", "accountId": "chatgpt-shared", "userId": "user-a", - "token": duplicate_token.clone() + "token": "opaque-duplicate-a" }, { "id": "acct_duplicate_b", "accountId": "chatgpt-shared", "userId": "user-a", - "token": duplicate_token + "token": "opaque-duplicate-b" }] })) .await @@ -772,9 +825,14 @@ async fn cpr_batch_allows_distinct_users_in_the_same_workspace() { } #[tokio::test] -async fn oauth_import_extracts_tokens_and_preserves_source_display_metadata() { - let bundle_token = unverified_import_access_token("chatgpt-token-bundle", "user-token-bundle"); - let bundle = import_service(unused_import_refresher()) +async fn oauth_import_completes_profiles_from_account_source() { + let bundle_source = account_source([authenticated_account( + "chatgpt-bundle", + "user-bundle", + Some("remote-bundle@example.com"), + Some("team"), + )]); + let bundle = import_service_with_source(unused_import_refresher(), Arc::clone(&bundle_source)) .prepare_import_document(serde_json::json!({ "exported_at": "2026-07-03T15:46:38.717Z", "proxies": [], @@ -783,7 +841,7 @@ async fn oauth_import_extracts_tokens_and_preserves_source_display_metadata() { "platform": "openai", "type": "oauth", "credentials": { - "access_token": bundle_token, + "access_token": "opaque-bundle", "refresh_token": "refresh-bundle", "chatgpt_account_id": "source-bundle", "chatgpt_user_id": "source-user-bundle", @@ -796,62 +854,85 @@ async fn oauth_import_extracts_tokens_and_preserves_source_display_metadata() { })) .await .expect("credential bundle import"); - let auth_document_token = - unverified_import_access_token("chatgpt-token-auth-document", "user-token-auth-document"); - let auth_document = import_service(unused_import_refresher()) - .prepare_import_document(serde_json::json!({ - "accounts": [{ - "type": "openai", - "access_token": auth_document_token, - "refresh_token": "refresh-auth-document", - "chatgpt_account_id": "source-auth-document", - "chatgpt_user_id": "source-user-auth-document", - "email": "auth-document@example.com", - "label": "Auth document", - "planType": "plus" - }] - })) - .await - .expect("auth document import"); + let auth_document_source = account_source([authenticated_account( + "chatgpt-auth-document", + "user-auth-document", + Some("remote-auth-document@example.com"), + Some("plus"), + )]); + let auth_document = + import_service_with_source(unused_import_refresher(), Arc::clone(&auth_document_source)) + .prepare_import_document(serde_json::json!({ + "accounts": [{ + "type": "openai", + "access_token": "opaque-auth-document", + "refresh_token": "refresh-auth-document", + "chatgpt_account_id": "source-auth-document", + "chatgpt_user_id": "source-user-auth-document", + "email": "auth-document@example.com", + "label": "Auth document", + "planType": "plus" + }] + })) + .await + .expect("auth document import"); let bundle_account = &bundle.accounts()[0].account; assert_eq!(bundle.accounts().len(), 1); - assert_eq!( - bundle_account.upstream_account_id(), - Some("chatgpt-token-bundle") - ); - assert_eq!(bundle_account.upstream_user_id(), "user-token-bundle"); + assert_eq!(bundle_account.upstream_account_id(), Some("chatgpt-bundle")); + assert_eq!(bundle_account.upstream_user_id(), "user-bundle"); assert_eq!(bundle_account.name(), "bundle@example.com"); - assert_eq!(bundle_account.email(), Some("bundle-source@example.com")); + assert_eq!(bundle_account.email(), Some("remote-bundle@example.com")); assert_eq!(bundle_account.plan_type(), Some("team")); let auth_document_account = &auth_document.accounts()[0].account; assert_eq!(auth_document.accounts().len(), 1); assert_eq!( auth_document_account.upstream_account_id(), - Some("chatgpt-token-auth-document") + Some("chatgpt-auth-document") ); assert_eq!( auth_document_account.upstream_user_id(), - "user-token-auth-document" + "user-auth-document" ); assert_eq!(auth_document_account.name(), "Auth document"); assert_eq!( auth_document_account.email(), - Some("auth-document@example.com") + Some("remote-auth-document@example.com") ); assert_eq!(auth_document_account.plan_type(), Some("plus")); + assert_eq!( + bundle_source + .seen + .lock() + .expect("seen account-source access tokens") + .as_slice(), + ["opaque-bundle"] + ); + assert_eq!( + auth_document_source + .seen + .lock() + .expect("seen account-source access tokens") + .as_slice(), + ["opaque-auth-document"] + ); assert!(!format!("{bundle:?} {auth_document:?}").contains("refresh-bundle")); assert!(!format!("{bundle:?} {auth_document:?}").contains("refresh-auth-document")); } #[tokio::test] async fn cliproxyapi_codex_auth_file_is_recognized_as_an_openai_auth_document() { - let access_token = unverified_import_access_token("chatgpt-token-cpa", "user-token-cpa"); - let prepared = import_service(unused_import_refresher()) + let source = account_source([authenticated_account( + "chatgpt-cpa", + "user-cpa", + Some("remote-cpa@example.com"), + Some("pro"), + )]); + let prepared = import_service_with_source(unused_import_refresher(), Arc::clone(&source)) .prepare_import_document(serde_json::json!({ "type": "codex", - "access_token": access_token, + "access_token": "opaque-cpa", "refresh_token": "refresh-cpa", "id_token": "id-cpa", "account_id": "chatgpt-cpa", @@ -863,8 +944,9 @@ async fn cliproxyapi_codex_auth_file_is_recognized_as_an_openai_auth_document() assert_eq!(prepared.accounts().len(), 1); let account = &prepared.accounts()[0].account; - assert_eq!(account.upstream_account_id(), Some("chatgpt-token-cpa")); - assert_eq!(account.upstream_user_id(), "user-token-cpa"); - assert_eq!(account.email(), Some("cpa@example.com")); + assert_eq!(account.upstream_account_id(), Some("chatgpt-cpa")); + assert_eq!(account.upstream_user_id(), "user-cpa"); + assert_eq!(account.email(), Some("remote-cpa@example.com")); + assert_eq!(account.plan_type(), Some("pro")); assert_eq!(account.authentication_kind(), "oauth"); } diff --git a/backend/crates/providers/openai/tests/credential/oauth.rs b/backend/crates/providers/openai/tests/credential/oauth.rs index 76a47b403..14935c93a 100644 --- a/backend/crates/providers/openai/tests/credential/oauth.rs +++ b/backend/crates/providers/openai/tests/credential/oauth.rs @@ -19,8 +19,8 @@ use provider_openai::credential::token_client::{ AuthorizationTokenSet, }; use provider_openai::credential::{ - CodexAccountIdentityVerifier, CodexCredentialAdmin, CodexIdentityExpectation, - CodexIdentityVerification, CodexIdentityVerificationError, CodexOAuthAdmin, + CodexAuthenticatedAccount, CodexAuthenticatedAccountSource, CodexCredentialAdmin, + CodexIdentityExpectation, CodexIdentityVerificationError, CodexOAuthAdmin, CodexOAuthAdminError, CodexOAuthAdminService, CodexOAuthPendingClaimOutcome, CodexOAuthPendingStore, CodexOAuthPendingStoreError, CodexOAuthSecret, CodexPendingAuthorization, CompleteCodexOAuthAuthorization, CompletedCodexOAuthCredential, @@ -170,55 +170,50 @@ impl AuthorizationCodeExchanger for Exchanger { } #[derive(Default)] -struct RejectingVerifier { +struct StaticAccountSource { calls: AtomicUsize, } -impl RejectingVerifier { +impl StaticAccountSource { fn call_count(&self) -> usize { self.calls.load(Ordering::Relaxed) } } #[async_trait] -impl CodexAccountIdentityVerifier for RejectingVerifier { - async fn verify( +impl CodexAuthenticatedAccountSource for StaticAccountSource { + async fn fetch( &self, _secret: &CodexOAuthSecret, - _expectation: &CodexIdentityExpectation, - ) -> Result { + expectation: &CodexIdentityExpectation, + ) -> Result { self.calls.fetch_add(1, Ordering::Relaxed); - Err(CodexIdentityVerificationError::Rejected) - } - - async fn verify_authorization( - &self, - _secret: &CodexOAuthSecret, - id_token: &SecretString, - expected_nonce: &SecretString, - _expectation: &CodexIdentityExpectation, - ) -> Result { - let _ = (id_token, expected_nonce); - self.calls.fetch_add(1, Ordering::Relaxed); - Err(CodexIdentityVerificationError::Rejected) + assert_eq!(expectation, &CodexIdentityExpectation::default()); + Ok(CodexAuthenticatedAccount { + chatgpt_account_id: "chatgpt-oauth".to_owned(), + chatgpt_user_id: "user-chatgpt-oauth".to_owned(), + email: Some("oauth@example.com".to_owned()), + plan_type: Some("plus".to_owned()), + }) } } fn service() -> CodexOAuthAdminService { - service_with_store( - Arc::new(MemoryAccountStore::default()), - Arc::new(RejectingVerifier::default()), - ) + service_with_store(Arc::new(MemoryAccountStore::default())) } -fn service_with_store( +fn service_with_store(store: Arc) -> CodexOAuthAdminService { + service_with_account_source(store, Arc::new(StaticAccountSource::default())) +} + +fn service_with_account_source( store: Arc, - verifier: Arc, + account_source: Arc, ) -> CodexOAuthAdminService { CodexOAuthAdminService::new( Arc::new(PendingStore::default()), Arc::new(Exchanger), - verifier, + account_source, store, runtime_policy(), CodexCredentialAdmin, @@ -299,10 +294,10 @@ async fn start_authorization_uses_pkce_nonce_and_fixed_official_redirect() { } #[tokio::test] -async fn completion_prepares_core_account_without_identity_verification() { +async fn completion_populates_create_profile_from_authenticated_account_source() { let store = Arc::new(MemoryAccountStore::default()); - let verifier = Arc::new(RejectingVerifier::default()); - let service = service_with_store(store, verifier.clone()); + let account_source = Arc::new(StaticAccountSource::default()); + let service = service_with_account_source(store, account_source.clone()); let started = started(&service).await; let url = Url::parse(&started.authorization_url).expect("authorization URL"); let state = url @@ -325,22 +320,17 @@ async fn completion_prepares_core_account_without_identity_verification() { assert_eq!(prepared.account.provider().as_str(), "openai"); assert!(prepared.account.id().as_str().starts_with("acct_")); assert_eq!( - prepared - .credential - .expose_to_provider() - .get("access_token") - .and_then(serde_json::Value::as_str), - Some("oauth-access-token") + prepared.account.upstream_account_id(), + Some("chatgpt-oauth") ); + assert_eq!(prepared.account.upstream_user_id(), "user-chatgpt-oauth"); + assert_eq!(prepared.account.email(), Some("oauth@example.com")); + assert_eq!(prepared.account.plan_type(), Some("plus")); assert_eq!( - prepared - .credential - .expose_to_provider() - .get("id_token") - .and_then(serde_json::Value::as_str), - Some("oauth-id-token") + account_source.call_count(), + 1, + "first OAuth authorization completes the account profile through the access-token source" ); - assert_eq!(verifier.call_count(), 0); } #[tokio::test] @@ -418,70 +408,6 @@ async fn completion_rejects_repeated_state_callback_parameter() { assert_eq!(error, CodexOAuthAdminError::CallbackRejected); } -#[derive(Default)] -struct UnavailableVerifier { - calls: AtomicUsize, -} - -impl UnavailableVerifier { - fn call_count(&self) -> usize { - self.calls.load(Ordering::Relaxed) - } -} - -#[async_trait] -impl CodexAccountIdentityVerifier for UnavailableVerifier { - async fn verify( - &self, - _: &CodexOAuthSecret, - _: &CodexIdentityExpectation, - ) -> Result { - self.calls.fetch_add(1, Ordering::Relaxed); - Err(CodexIdentityVerificationError::Unavailable) - } - - async fn verify_authorization( - &self, - _: &CodexOAuthSecret, - _: &SecretString, - _: &SecretString, - _: &CodexIdentityExpectation, - ) -> Result { - self.calls.fetch_add(1, Ordering::Relaxed); - Err(CodexIdentityVerificationError::Unavailable) - } -} - -#[tokio::test] -async fn completion_ignores_unavailable_identity_verifier() { - let store = Arc::new(MemoryAccountStore::default()); - let verifier = Arc::new(UnavailableVerifier::default()); - let service = service_with_store(store, verifier.clone()); - let started = started(&service).await; - let state = Url::parse(&started.authorization_url) - .expect("authorization URL") - .query_pairs() - .find_map(|(key, value)| (key == "state").then(|| value.into_owned())) - .expect("state"); - - let prepared = service - .complete_authorization(CompleteCodexOAuthAuthorization { - owner_ref: owner_ref(), - flow_id: started.flow_id, - callback_url: SecretString::from(format!( - "http://localhost:1455/auth/callback?code=authorization-code&state={state}" - )), - }) - .await - .expect("complete OAuth without identity verification"); - - assert!(matches!( - prepared.credential, - CompletedCodexOAuthCredential::Create(_) - )); - assert_eq!(verifier.call_count(), 0); -} - #[tokio::test] async fn callback_state_mismatch_releases_flow_for_retry() { let service = service(); @@ -580,8 +506,8 @@ async fn reauthorization_preserves_existing_principal_without_identity_verificat .expect("original credential"); let original_installation_id = original_runtime.installation_id; let original_principal = original_runtime.principal; - let verifier = Arc::new(RejectingVerifier::default()); - let service = service_with_store(store.clone(), verifier.clone()); + let account_source = Arc::new(StaticAccountSource::default()); + let service = service_with_account_source(store.clone(), account_source.clone()); let started = service .start_authorization(StartCodexOAuthAuthorization { mutation: reauthorization_mutation("request-reauth", "acct_oauth_reauth"), @@ -617,19 +543,10 @@ async fn reauthorization_preserves_existing_principal_without_identity_verificat let runtime = provider_openai::credential::CodexCredentialCodec::decode(prepared.credential.credential()) .expect("prepared credential"); - assert_eq!( - runtime - .authentication - .oauth() - .expect("OAuth credential") - .access_token - .expose_secret(), - "oauth-access-token" - ); assert_eq!(runtime.installation_id, original_installation_id); assert_eq!(runtime.principal, original_principal); assert!(prepared.replacement_identity.is_none()); - assert_eq!(verifier.call_count(), 0); + assert_eq!(account_source.call_count(), 0); assert_eq!( store .account("acct_oauth_reauth") diff --git a/docs/api.md b/docs/api.md index ebfd985e6..3e7870a7b 100644 --- a/docs/api.md +++ b/docs/api.md @@ -122,7 +122,7 @@ OpenAI 路径保留客户端 Responses wire 语义:请求 body 的未知字段 - xAI 从单账号 object 或 `accounts` 数组中提取 OAuth token;包装中的代理、并发、优先级等字段不参与认证; - xAI 批量导入逐条独立校验:失败条目跳过并记录日志,不中断其余条目,仅当没有任何条目成功时整个导入才报错; - xAI API Key 不是受支持的账号 credential; -- 导入仍会通过 Provider 的身份或刷新链路校验,不能只凭文件外形写入账号。 +- 导入不会只凭文件外形写入账号;目标 Provider 使用认证材料完成必要的 token exchange 或已认证账号资料补全。 OpenAI 的 CPR 导出保持 OAuth 账号的既有字段;Agent Identity 账号则输出 `authMode: "agentIdentity"`、`agentRuntimeId`、`agentPrivateKey` 和可选的 @@ -171,20 +171,22 @@ OAuth start 使用: ### OpenAI 身份、额度与状态 -- 文件导入只轻量解析 access token 的未验签 JWT payload:要求 `exp` 未过期且存在 - `chatgpt_account_id`;不调用 JWKS 或 `/wham/usage`,也不做账号身份一致性比对。 -- 首次 OAuth 和重新授权保留回调 `state`、PKCE 与官方 token exchange。回调地址只承载 `code`/`state`, +- OAuth 文件导入使用可用的 access token 调用 OpenAI 已认证账号接口,补全实际使用的账号 ID、用户 ID、 + 邮箱和套餐;仅有 refresh token 时,先换取 access token 后执行同一补全。导入不要求 JWT payload 含有 + 特定字段,不执行 JWKS 验签,也不将文件中的身份字段与上游资料比对。 +- 首次 OAuth 保留回调 `state`、PKCE 与官方 token exchange;换得 access token 后调用同一已认证账号接口 + 补全账号资料。重新授权也保留这些回调保护,但只轮换目标账号的 token。回调地址只承载 `code`/`state`, 不以 host/path 形式作为拒绝条件。 - 账号文件导入和首次 OAuth 创建在 credential 提交后立即尝试一次额度观测。观测失败只记录告警, - 不回滚已提交的账号;重新授权只轮换目标账号 credential,不隐式等同于手工额度刷新,也不更新既有 - upstream account/user 或 OAuth principal。 + 不回滚已提交的账号;重新授权和手工或后台 RT 刷新只更新 token,不隐式等同于手工额度刷新,也不更新 + 既有账号资料或 OAuth principal。 - OAuth pending flow 先取得带过期时间的独占 claim,只有账号事务提交成功后才消费。失败会释放 claim, 但上游 authorization code 本身通常只能交换一次;已完成过 token exchange 时应重新创建 OAuth flow。 - `GET /accounts/quota` 只读取最后一次落库快照;`POST /accounts/quota/refresh` 才访问上游。access token 已过期时,额度刷新要求先走 credential 刷新或重新授权,不会拿过期 token 探测额度。 - 成功额度观测会 revision-fenced 写入 quota,并在 `active` 与 `quota_exhausted` 等额度所属状态间同步; 不会清除 `expired`、`banned` 等 credential/封禁终态。额度接口的 401/403 也不足以判定 refresh token - 永久失效,credential 终态只由 OAuth refresh 或身份校验链路写入。 + 永久失效,credential 终态只由 OAuth refresh 的明确永久错误写入。 - 正常 Responses 请求会解析上游响应的 rate-limit headers,合并进同一 quota 快照并同步状态。Free、 K12 等套餐共用该状态机;套餐只参与账号展示和按套餐隔离的模型目录 cache,不存在 K12 专属额度路径。 - 账号页没有定时静默轮询。手工额度刷新完成后页面会重新读取该账号;请求驱动或后台任务产生的状态 diff --git a/docs/architecture.md b/docs/architecture.md index 169ce53a2..db4b9301e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -120,11 +120,12 @@ credential 更新使用 `credential_revision` CAS。认证永久失败、封禁 `refresh_token_expires_at` 不是公共 SQL 列或 Core 权威状态。xAI 可在 `provider_credentials_json` 内保存它作为 Provider 私有提示;真正失效以 refresh endpoint 返回的永久错误为准。 -OpenAI 导入只对 access token 做轻量、未验签的 JWT payload 检查:payload 可解析、`exp` 未过期且 -带有 `chatgpt_account_id`。首次 OAuth 和重新授权保留服务端 `state`、PKCE 与官方 token exchange; -它们不再调用 JWKS、usage 或账号身份一致性校验。重新授权的目标绑定只保存目标账号 ID,不接受或 -冻结客户端提供的 credential revision;complete 阶段重新读取目标账号及其当前 revision,并以当前 -revision 做最终 CAS。重新授权只轮换目标账号的 token,保留既有 upstream account/user 与 principal。 +OpenAI OAuth 导入先取得可用 access token(仅有 refresh token 时先交换),再调用 OpenAI 已认证账号 +接口补全实际使用的账号 ID、用户 ID、邮箱和套餐。首次 OAuth 在服务端 `state`、PKCE 与官方 token +exchange 成功后执行同一补全。两条路径不执行 JWKS 验签,不要求 JWT payload 具备指定字段,也不将导入 +材料中的身份与上游资料做一致性比对。重新授权的目标绑定只保存目标账号 ID,不接受或冻结客户端提供的 +credential revision;complete 阶段重新读取目标账号及其当前 revision,并以当前 revision 做最终 CAS。 +重新授权和手工或后台 RT refresh 只轮换目标账号的 token,保留既有账号资料与 OAuth principal。 credential 与 quota 是两套状态机。主动 quota refresh 会拒绝过期 access token;quota 探测的 401/403 不具备判定 refresh token 永久失效的证据。成功主动观测和正常推理响应中的 rate-limit header @@ -241,8 +242,8 @@ worker 不得通过导入 Admin use case 绕过边界,也不得维护第二份 - FK、CAS、revision、recovery 使用真实 PostgreSQL 测试。 - admission、lease、cooldown、OAuth pending 使用真实 Redis 测试。 - 自动化测试不得发送真实对话或轮换生产 refresh token;仓库不提供破坏性 live fixture。 -- OpenAI identity 仅保留可选的外部 `CODEX_REAL_ACCOUNT_FILE` 官方 JWKS 签名检查,环境变量未设置时 - 直接跳过;它不经过 selector/transport,也不是数据面验收。 +- 可选的外部 `CODEX_REAL_ACCOUNT_FILE` 官方 JWKS 签名检查仅用于显式测试,环境变量未设置时直接跳过; + 它不属于 OpenAI OAuth 导入、首次授权、重新授权或 RT refresh 流程,也不经过 selector/transport。 终态门禁: