fix(openai): complete import profile from access token

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-08-06 12:31:13 +08:00
co-authored by Codex
parent 194fb20d5f
commit d0dc7ef92b
8 changed files with 350 additions and 424 deletions
+6 -3
View File
@@ -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",
@@ -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<DateTime<Utc>>,
chatgpt_account_id: Option<String>,
chatgpt_user_id: Option<String>,
email: Option<String>,
plan_type: Option<String>,
}
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<dyn TokenRefresher>,
account_source: Arc<dyn CodexAuthenticatedAccountSource>,
leases: Arc<dyn ProviderLeasePort>,
runtime_policy: Arc<dyn ProviderRuntimePolicyPort>,
}
@@ -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<dyn TokenRefresher>,
_verifier: Arc<dyn CodexAccountIdentityVerifier>,
account_source: Arc<dyn CodexAuthenticatedAccountSource>,
leases: Arc<dyn ProviderLeasePort>,
runtime_policy: Arc<dyn ProviderRuntimePolicyPort>,
) -> 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::<Utc>::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<DateTime<Utc>>,
) -> 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<CodexAccountProfile, CodexIdentityVerificationError> {
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<DateTime<Utc>> {
unverified_access_token_claims(token).and_then(|claims| claims.expires_at)
}
fn unverified_access_token_claims(token: &str) -> Option<UnverifiedOAuthClaims> {
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::<Value>(&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<String, Value>>, 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::<Utc>::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<String> {
normalize_import_text(value, MAX_IMPORT_IDENTITY_BYTES)
}
fn normalize_import_text(value: &str, max_bytes: usize) -> Option<String> {
@@ -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<dyn CodexOAuthPendingStore>,
exchanger: Arc<dyn AuthorizationCodeExchanger>,
account_source: Arc<dyn CodexAuthenticatedAccountSource>,
store: Arc<dyn ProviderAccountStore>,
runtime_policy: Arc<dyn ProviderRuntimePolicyPort>,
credentials: CodexCredentialAdmin,
@@ -397,7 +398,7 @@ impl CodexOAuthAdminService {
pub fn new(
pending: Arc<dyn CodexOAuthPendingStore>,
exchanger: Arc<dyn AuthorizationCodeExchanger>,
_verifier: Arc<dyn CodexAccountIdentityVerifier>,
account_source: Arc<dyn CodexAuthenticatedAccountSource>,
store: Arc<dyn ProviderAccountStore>,
runtime_policy: Arc<dyn ProviderRuntimePolicyPort>,
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
}
+5 -4
View File
@@ -156,13 +156,14 @@ pub async fn initialize(
ReqwestCodexAuthenticatedAccountSource::new(profile.clone(), config.base_url().to_owned())
.map_err(|_| OpenAiInitializeError::Identity)?,
);
let identity: Arc<dyn CodexAccountIdentityVerifier> =
Arc::new(CodexAccountIdentityService::new(signed, account_source));
let identity: Arc<dyn CodexAccountIdentityVerifier> = Arc::new(
CodexAccountIdentityService::new(signed, Arc::clone(&account_source)),
);
let refresher: Arc<dyn TokenRefresher> = token_client.clone();
let exchanger: Arc<dyn AuthorizationCodeExchanger> = 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,
@@ -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<VecDeque<Result<CodexAuthenticatedAccount, CodexIdentityVerificationError>>>,
seen: Mutex<Vec<String>>,
expectations: Mutex<Vec<CodexIdentityExpectation>>,
}
#[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<CodexIdentityVerification, CodexIdentityVerificationError> {
Err(CodexIdentityVerificationError::Rejected)
secret: &provider_openai::credential::CodexOAuthSecret,
expectation: &CodexIdentityExpectation,
) -> Result<CodexAuthenticatedAccount, CodexIdentityVerificationError> {
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<CodexIdentityVerification, CodexIdentityVerificationError> {
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<Item = CodexAuthenticatedAccount>,
) -> Arc<ManualAccountSource> {
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<AtomicUsize>);
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<CodexIdentityVerification, CodexIdentityVerificationError> {
Err(CodexIdentityVerificationError::Rejected)
}
async fn verify_authorization(
&self,
_secret: &provider_openai::credential::CodexOAuthSecret,
_id_token: &SecretString,
_expected_nonce: &SecretString,
_expectation: &CodexIdentityExpectation,
) -> Result<CodexIdentityVerification, CodexIdentityVerificationError> {
Err(CodexIdentityVerificationError::Rejected)
}
}
pub(super) fn import_service(
refresher: Arc<ManualRefresher>,
) -> provider_openai::credential::CodexCredentialAdminService {
import_service_with_source(refresher, account_source([]))
}
fn import_service_with_source(
refresher: Arc<ManualRefresher>,
source: Arc<ManualAccountSource>,
) -> 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<ManualRefresher> {
}
#[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");
}
@@ -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<CodexIdentityVerification, CodexIdentityVerificationError> {
expectation: &CodexIdentityExpectation,
) -> Result<CodexAuthenticatedAccount, CodexIdentityVerificationError> {
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<CodexIdentityVerification, CodexIdentityVerificationError> {
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<MemoryAccountStore>) -> CodexOAuthAdminService {
service_with_account_source(store, Arc::new(StaticAccountSource::default()))
}
fn service_with_account_source(
store: Arc<MemoryAccountStore>,
verifier: Arc<dyn CodexAccountIdentityVerifier>,
account_source: Arc<dyn CodexAuthenticatedAccountSource>,
) -> 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<CodexIdentityVerification, CodexIdentityVerificationError> {
self.calls.fetch_add(1, Ordering::Relaxed);
Err(CodexIdentityVerificationError::Unavailable)
}
async fn verify_authorization(
&self,
_: &CodexOAuthSecret,
_: &SecretString,
_: &SecretString,
_: &CodexIdentityExpectation,
) -> Result<CodexIdentityVerification, CodexIdentityVerificationError> {
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")
+9 -7
View File
@@ -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 专属额度路径。
- 账号页没有定时静默轮询。手工额度刷新完成后页面会重新读取该账号;请求驱动或后台任务产生的状态
+8 -7
View File
@@ -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。
终态门禁: