mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-08 00:05:59 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a2a4a56ae | ||
|
|
6dd0da1fbd |
@@ -138,10 +138,13 @@ async fn commit_authorization_in_transaction(
|
||||
context,
|
||||
gateway_admin::model::audit::MutationAuditOperation::ProviderAccountReauthorize,
|
||||
)?;
|
||||
let result =
|
||||
repository::rotate_provider_account_admin_in_transaction(transaction, prepared)
|
||||
.await
|
||||
.map_err(|error| admin_store_error(ENTITY, error))?;
|
||||
let result = repository::rotate_provider_account_admin_in_transaction(
|
||||
transaction,
|
||||
prepared,
|
||||
true,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| admin_store_error(ENTITY, error))?;
|
||||
admin_adapter::rotation_result(result, account_id.clone())?
|
||||
}
|
||||
_ => return Err(invalid_receipt()),
|
||||
|
||||
@@ -520,7 +520,8 @@ impl ProviderAccountAdminRepository for PgProviderAccountRepository {
|
||||
postgres_unavailable("begin provider account admin rotation", source)
|
||||
})?;
|
||||
let account_id = command.credential.account_id.clone();
|
||||
let result = rotate_provider_account_admin_in_transaction(&mut transaction, command).await;
|
||||
let result =
|
||||
rotate_provider_account_admin_in_transaction(&mut transaction, command, false).await;
|
||||
let rotation =
|
||||
finish_admin_transaction(transaction, result, "provider account admin rotation")
|
||||
.await?;
|
||||
@@ -849,6 +850,7 @@ pub(crate) async fn rotate_provider_account_in_transaction(
|
||||
profile: &UpdateProviderAccount,
|
||||
replacement_identity: Option<&ProviderAccountIdentity>,
|
||||
update: &ProviderCredentialUpdate,
|
||||
preserve_name: bool,
|
||||
) -> StoreResult<Revision> {
|
||||
let replace_identity = replacement_identity.is_some();
|
||||
let upstream_user_id = replacement_identity.map(ProviderAccountIdentity::upstream_user_id);
|
||||
@@ -857,11 +859,15 @@ pub(crate) async fn rotate_provider_account_in_transaction(
|
||||
// 事务时间可能早于应用写入的额度观测,保留既有时间下界,避免轮换破坏时间约束
|
||||
let next = sqlx::query_scalar::<_, i64>(
|
||||
"update provider_accounts
|
||||
set name = case when $14 then name else $4 end,
|
||||
set name = case when $14 or $16 then name else $4 end,
|
||||
email = case when $14 then email else $5 end,
|
||||
plan_type = case when $14 then plan_type else $6 end,
|
||||
provider_quota_json = case when $14 then provider_quota_json else null end,
|
||||
quota_observed_at = case when $14 then quota_observed_at else null end,
|
||||
quota_access_state = case when $11 then 'unknown' else quota_access_state end,
|
||||
quota_evidence = case when $11 then null else quota_evidence end,
|
||||
quota_access_observed_at = case when $11 then null else quota_access_observed_at end,
|
||||
quota_reset_at = case when $11 then null else quota_reset_at end,
|
||||
provider_credentials_json = $7,
|
||||
credential_revision = credential_revision + 1,
|
||||
has_refresh_token = $8,
|
||||
@@ -900,6 +906,7 @@ pub(crate) async fn rotate_provider_account_in_transaction(
|
||||
.bind(upstream_account_id)
|
||||
.bind(update.preserve_profile)
|
||||
.bind(update.preserve_credential_state)
|
||||
.bind(preserve_name)
|
||||
.fetch_optional(&mut **transaction)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
@@ -1166,6 +1173,7 @@ pub(super) async fn import_provider_accounts_in_transaction(
|
||||
pub(super) async fn rotate_provider_account_admin_in_transaction(
|
||||
transaction: &mut Transaction<'_, Postgres>,
|
||||
command: RotateProviderAccount,
|
||||
preserve_name: bool,
|
||||
) -> StoreResult<ProviderAccountAdminRotation> {
|
||||
command.scope.validate()?;
|
||||
require_nonempty(ENTITY, "account_id", &command.profile.id)?;
|
||||
@@ -1194,6 +1202,7 @@ pub(super) async fn rotate_provider_account_admin_in_transaction(
|
||||
&command.profile,
|
||||
command.replacement_identity.as_ref(),
|
||||
&command.credential,
|
||||
preserve_name,
|
||||
)
|
||||
.await?;
|
||||
// 凭据 CAS、普通设置和审计共享事务,任何设置失败都回滚凭据更新
|
||||
|
||||
@@ -310,11 +310,14 @@ async fn reauthorization_replay_keeps_the_committed_revision_and_later_profile_e
|
||||
account_id: id.clone(),
|
||||
provider_kind: ProviderKind::new("openai").unwrap(),
|
||||
expected_credential_revision: gateway_admin::model::Revision::new(1).unwrap(),
|
||||
replacement_identity: None,
|
||||
replacement_identity: Some(ProviderAccountIdentity::new(
|
||||
"new-user".into(),
|
||||
Some("new-workspace".into()),
|
||||
)),
|
||||
name: "remote profile".into(),
|
||||
email: None,
|
||||
plan_type: None,
|
||||
preserve_profile: true,
|
||||
email: Some("new@example.com".into()),
|
||||
plan_type: Some("plus".into()),
|
||||
preserve_profile: false,
|
||||
preserve_credential_state: false,
|
||||
provider_material: ProviderDocument::new(OpaqueProviderData::new(
|
||||
json!({"access_token":"rotated-test-only"})
|
||||
@@ -347,15 +350,17 @@ async fn reauthorization_replay_keeps_the_committed_revision_and_later_profile_e
|
||||
.commit_authorization(command.clone(), &context)
|
||||
.await
|
||||
.unwrap();
|
||||
let account = store
|
||||
.load_account(id.as_str(), Default::default())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.account;
|
||||
assert_eq!(account.name, "renamed team");
|
||||
assert_eq!(account.email.as_deref(), Some("new@example.com"));
|
||||
assert_eq!(
|
||||
store
|
||||
.load_account(id.as_str(), Default::default())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.account
|
||||
.name,
|
||||
"renamed team"
|
||||
account.upstream_account_id.as_deref(),
|
||||
Some("new-workspace")
|
||||
);
|
||||
store
|
||||
.update_account(
|
||||
|
||||
@@ -3007,7 +3007,7 @@ async fn provider_account_import_and_reauthorization_preserve_existing_membershi
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn verified_credential_rotation_preserves_quota_exhaustion() {
|
||||
async fn quota_exhaustion_survives_rotation_only_for_the_same_upstream_identity() {
|
||||
let Some(database) = TestDatabase::create("provider_account_rotation_quota").await else {
|
||||
return;
|
||||
};
|
||||
@@ -3043,7 +3043,7 @@ async fn verified_credential_rotation_preserves_quota_exhaustion() {
|
||||
repository
|
||||
.rotate_provider_account(RotateProviderAccount {
|
||||
settings: None,
|
||||
scope,
|
||||
scope: scope.clone(),
|
||||
profile: profile("acct_rotation_quota", "reauthorized account"),
|
||||
replacement_identity: None,
|
||||
credential: credential_update("acct_rotation_quota", 1, "reauthorized-secret"),
|
||||
@@ -3064,6 +3064,28 @@ async fn verified_credential_rotation_preserves_quota_exhaustion() {
|
||||
.expect("load exhausted account after credential rotation");
|
||||
assert_eq!(quota_access_state, "exhausted".to_owned());
|
||||
|
||||
repository
|
||||
.rotate_provider_account(RotateProviderAccount {
|
||||
settings: None,
|
||||
scope,
|
||||
profile: profile("acct_rotation_quota", "reauthorized account"),
|
||||
replacement_identity: Some(ProviderAccountIdentity::new(
|
||||
"new-user".into(),
|
||||
Some("new-workspace".into()),
|
||||
)),
|
||||
credential: credential_update("acct_rotation_quota", 2, "new-account-secret"),
|
||||
audit: audit("audit_quota_rebind", "reauthorize", "acct_rotation_quota"),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let account = repository
|
||||
.get_account(&ProviderAccountId::new("acct_rotation_quota").unwrap())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(account.upstream_account_id(), Some("new-workspace"));
|
||||
assert_eq!(account.quota(), QuotaState::unknown());
|
||||
|
||||
database.close().await;
|
||||
}
|
||||
|
||||
|
||||
@@ -661,7 +661,7 @@ impl ProviderAdmin for OpenAiAdminProvider {
|
||||
let access_token_expires_at =
|
||||
parse_access_token_expiration(secret.access_token.expose_secret());
|
||||
let prepared = CodexCredentialAdmin
|
||||
.prepare_refreshed_oauth_rotation(current, secret, access_token_expires_at, None)
|
||||
.prepare_oauth_rotation(current, secret, None, access_token_expires_at, None)
|
||||
.map_err(map_credential_admin_error)?;
|
||||
prepared_rotation(prepared, command.account.provider_kind)
|
||||
}
|
||||
|
||||
@@ -31,9 +31,8 @@ use super::token_client::{
|
||||
OpenAiTokenClient, PersonalAccessTokenError, RefreshFailure, TokenRefresher,
|
||||
};
|
||||
use super::types::{
|
||||
CODEX_AUTHENTICATION_KIND_OAUTH, CodexAccountProfile, CodexCredentialData,
|
||||
CodexCredentialPrincipal, CodexOAuthMetadata, CodexOAuthSecret, ResponsesTransport,
|
||||
parse_access_token_expiration, parse_chatgpt_jwt_claims,
|
||||
CODEX_AUTHENTICATION_KIND_OAUTH, CodexAccountProfile, CodexCredentialData, CodexOAuthMetadata,
|
||||
CodexOAuthSecret, ResponsesTransport, parse_access_token_expiration, parse_chatgpt_jwt_claims,
|
||||
};
|
||||
|
||||
const PROVIDER_NAME: &str = "openai";
|
||||
@@ -272,26 +271,6 @@ struct CodexCprOAuthExportAccount {
|
||||
access_token_expires_at: Option<String>,
|
||||
}
|
||||
|
||||
/// App 已从 Store 读取的当前账号、revision 与明文 Provider JSON
|
||||
pub struct RotateManagedCodexCredential {
|
||||
pub current: LoadedCredential,
|
||||
pub secret: CodexOAuthSecret,
|
||||
pub verified_account: CodexAccountProfile,
|
||||
pub next_refresh_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for RotateManagedCodexCredential {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter
|
||||
.debug_struct("RotateManagedCodexCredential")
|
||||
.field("current", &self.current)
|
||||
.field("secret", &"<redacted>")
|
||||
.field("verified_account", &self.verified_account)
|
||||
.field("next_refresh_at", &self.next_refresh_at)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider 验证后的 rotation;App 只做 Core -> Store command 的机械映射
|
||||
pub struct PreparedCodexCredentialRotation {
|
||||
pub profile: ProviderAccountUpdate,
|
||||
@@ -725,21 +704,12 @@ impl CodexCredentialAdmin {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn prepare_rotation(
|
||||
&self,
|
||||
input: RotateManagedCodexCredential,
|
||||
) -> Result<PreparedCodexCredentialRotation, CodexCredentialAdminError> {
|
||||
self.prepare_oauth_rotation(input, false)
|
||||
}
|
||||
|
||||
/// 构建一次成功 RT exchange 的 CAS 写入,保留已存账号身份资料
|
||||
///
|
||||
/// Refresh endpoint 已经完成本次 token 交换的授权;这里不对新 access
|
||||
/// token 重新执行身份验证
|
||||
pub(crate) fn prepare_refreshed_oauth_rotation(
|
||||
/// 重新授权提供本次登录的身份;普通令牌续期只轮换凭据
|
||||
pub(crate) fn prepare_oauth_rotation(
|
||||
&self,
|
||||
current: LoadedCredential,
|
||||
secret: CodexOAuthSecret,
|
||||
metadata: Option<CodexOAuthMetadata>,
|
||||
access_token_expires_at: Option<DateTime<Utc>>,
|
||||
next_refresh_at: Option<DateTime<Utc>>,
|
||||
) -> Result<PreparedCodexCredentialRotation, CodexCredentialAdminError> {
|
||||
@@ -753,6 +723,32 @@ impl CodexCredentialAdmin {
|
||||
let oauth = data
|
||||
.oauth_mut()
|
||||
.ok_or(CodexCredentialAdminError::InvalidCredential)?;
|
||||
let mut profile = ProviderAccountUpdate {
|
||||
account_id: current.account.id().clone(),
|
||||
name: current.account.name().to_owned(),
|
||||
email: current.account.email().map(str::to_owned),
|
||||
plan_type: current.account.plan_type().map(str::to_owned),
|
||||
};
|
||||
let reauthorization = metadata.is_some();
|
||||
let mut replacement_identity = None;
|
||||
if let Some(metadata) = metadata {
|
||||
let user_id = metadata
|
||||
.chatgpt_user_id
|
||||
.filter(|id| !id.trim().is_empty())
|
||||
.ok_or(CodexCredentialAdminError::InvalidCredential)?;
|
||||
if current.account.upstream_user_id() != Some(user_id.as_str())
|
||||
|| current.account.upstream_account_id() != metadata.chatgpt_account_id.as_deref()
|
||||
{
|
||||
replacement_identity = Some(ProviderAccountIdentity::new(
|
||||
user_id,
|
||||
metadata.chatgpt_account_id,
|
||||
));
|
||||
oauth.cookies.clear();
|
||||
oauth.principal = None;
|
||||
}
|
||||
profile.email = metadata.email;
|
||||
profile.plan_type = metadata.chatgpt_plan_type;
|
||||
}
|
||||
oauth.access_token = secret.access_token.expose_secret().to_owned();
|
||||
oauth.refresh_token = secret
|
||||
.refresh_token
|
||||
@@ -764,12 +760,6 @@ impl CodexCredentialAdmin {
|
||||
.map(|value| value.expose_secret().to_owned());
|
||||
let credential = CodexCredentialCodec::encode_complete(data)
|
||||
.map_err(|_| CodexCredentialAdminError::InvalidCredential)?;
|
||||
let profile = ProviderAccountUpdate {
|
||||
account_id: current.account.id().clone(),
|
||||
name: current.account.name().to_owned(),
|
||||
email: current.account.email().map(str::to_owned),
|
||||
plan_type: current.account.plan_type().map(str::to_owned),
|
||||
};
|
||||
let credential = CredentialCasUpdate::new(
|
||||
current.account.id().clone(),
|
||||
current.account.revision(),
|
||||
@@ -779,76 +769,14 @@ impl CodexCredentialAdmin {
|
||||
optional_time(access_token_expires_at),
|
||||
optional_time(next_refresh_at),
|
||||
)
|
||||
.map_err(|_| CodexCredentialAdminError::InvalidCredential)?
|
||||
.preserving_profile();
|
||||
Ok(PreparedCodexCredentialRotation {
|
||||
profile,
|
||||
credential,
|
||||
replacement_identity: None,
|
||||
refresh_guards: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn prepare_oauth_rotation(
|
||||
&self,
|
||||
input: RotateManagedCodexCredential,
|
||||
replace_identity: bool,
|
||||
) -> Result<PreparedCodexCredentialRotation, CodexCredentialAdminError> {
|
||||
let access_token_expires_at = input.verified_account.access_token_expires_at;
|
||||
let mut data = CodexCredentialCodec::decode_complete(&input.current.credential)
|
||||
.map_err(|_| CodexCredentialAdminError::InvalidCredential)?;
|
||||
let oauth = data
|
||||
.oauth_mut()
|
||||
.ok_or(CodexCredentialAdminError::InvalidCredential)?;
|
||||
if input.current.account.provider().as_str() != PROVIDER_NAME
|
||||
|| input.current.account.authentication_kind() != CODEX_AUTHENTICATION_KIND_OAUTH
|
||||
{
|
||||
return Err(CodexCredentialAdminError::InvalidCredential);
|
||||
}
|
||||
let replacement_identity = replace_identity.then(|| {
|
||||
ProviderAccountIdentity::new(
|
||||
input.verified_account.chatgpt_user_id.clone(),
|
||||
Some(input.verified_account.chatgpt_account_id.clone()),
|
||||
)
|
||||
});
|
||||
if replace_identity {
|
||||
oauth.principal = Some(CodexCredentialPrincipal {
|
||||
oauth_subject: input.verified_account.oauth_subject.clone(),
|
||||
poid: input.verified_account.poid.clone(),
|
||||
});
|
||||
}
|
||||
oauth.access_token = input.secret.access_token.expose_secret().to_owned();
|
||||
oauth.refresh_token = input
|
||||
.secret
|
||||
.refresh_token
|
||||
.as_ref()
|
||||
.map(|value| value.expose_secret().to_owned());
|
||||
oauth.id_token = input
|
||||
.secret
|
||||
.id_token
|
||||
.as_ref()
|
||||
.map(|value| value.expose_secret().to_owned());
|
||||
let credential = CodexCredentialCodec::encode_complete(data)
|
||||
.map_err(|_| CodexCredentialAdminError::InvalidCredential)?;
|
||||
let profile = ProviderAccountUpdate {
|
||||
account_id: input.current.account.id().clone(),
|
||||
name: input.current.account.name().to_owned(),
|
||||
email: input.verified_account.email,
|
||||
plan_type: input.verified_account.plan_type,
|
||||
};
|
||||
let credential = CredentialCasUpdate::new(
|
||||
input.current.account.id().clone(),
|
||||
input.current.account.revision(),
|
||||
profile.clone(),
|
||||
credential,
|
||||
input.secret.refresh_token.is_some(),
|
||||
optional_time(access_token_expires_at),
|
||||
optional_time(input.next_refresh_at),
|
||||
)
|
||||
.map_err(|_| CodexCredentialAdminError::InvalidCredential)?;
|
||||
Ok(PreparedCodexCredentialRotation {
|
||||
profile,
|
||||
credential,
|
||||
credential: if reauthorization {
|
||||
credential
|
||||
} else {
|
||||
credential.preserving_profile()
|
||||
},
|
||||
replacement_identity,
|
||||
refresh_guards: None,
|
||||
})
|
||||
@@ -1002,9 +930,10 @@ impl CodexCredentialAdminService {
|
||||
// 正常预刷新由 worker 读取当时的 runtime policy 动态判断;这里仅清除
|
||||
// 之前瞬态失败留下的 retry-not-before
|
||||
let next_refresh_at = None;
|
||||
let mut prepared = CodexCredentialAdmin.prepare_refreshed_oauth_rotation(
|
||||
let mut prepared = CodexCredentialAdmin.prepare_oauth_rotation(
|
||||
current,
|
||||
secret,
|
||||
None,
|
||||
access_token_expires_at,
|
||||
next_refresh_at,
|
||||
)?;
|
||||
@@ -1095,20 +1024,7 @@ impl CodexCredentialAdminService {
|
||||
.as_ref()
|
||||
.and_then(|token| parse_chatgpt_jwt_claims(token.expose_secret()).ok())
|
||||
.unwrap_or_default();
|
||||
let access_metadata = parse_chatgpt_jwt_claims(secret.access_token.expose_secret())
|
||||
.unwrap_or_default();
|
||||
CodexOAuthMetadata {
|
||||
email: id_metadata.email.or(access_metadata.email),
|
||||
chatgpt_plan_type: id_metadata
|
||||
.chatgpt_plan_type
|
||||
.or(access_metadata.chatgpt_plan_type),
|
||||
chatgpt_user_id: id_metadata
|
||||
.chatgpt_user_id
|
||||
.or(access_metadata.chatgpt_user_id),
|
||||
chatgpt_account_id: id_metadata
|
||||
.chatgpt_account_id
|
||||
.or(access_metadata.chatgpt_account_id),
|
||||
}
|
||||
id_metadata.with_access_token_fallback(&secret.access_token)
|
||||
};
|
||||
let prepared =
|
||||
CodexCredentialAdmin.prepare_unresolved_oauth(UnresolvedCodexOAuthCredential {
|
||||
|
||||
@@ -36,7 +36,7 @@ pub use admin::{
|
||||
CodexCprExportDocument, CodexCredentialAdmin, CodexCredentialAdminError,
|
||||
CodexCredentialAdminService, ExportManagedCodexCredential, ImportCodexOAuthCredential,
|
||||
PreparedCodexAccountImport, PreparedCodexCredentialRotation,
|
||||
PreparedCodexCredentialRotationGuard, RotateManagedCodexCredential,
|
||||
PreparedCodexCredentialRotationGuard,
|
||||
};
|
||||
pub use catalog::{
|
||||
CodexCatalogScope, CodexCredentialCatalogError, CodexCredentialCatalogService,
|
||||
@@ -72,6 +72,7 @@ pub use selector::{
|
||||
SelectCodexCredential,
|
||||
};
|
||||
pub(crate) use selector::{CodexCyberPolicyScope, SelectCodexProviderEndpointCredential};
|
||||
pub(crate) use types::oauth_identity_key;
|
||||
pub use types::{
|
||||
CODEX_AUTHENTICATION_KIND_OAUTH, CodexAccountProfile, CodexCookie, CodexCookieCaptureOutcome,
|
||||
CodexCredentialData, CodexCredentialPrincipal, CodexOAuthCredentialData, CodexOAuthSecret,
|
||||
|
||||
@@ -516,16 +516,10 @@ impl CodexOAuthAdminService {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let fallback_refresh_token = current
|
||||
.as_ref()
|
||||
.map(current_oauth_refresh_token)
|
||||
.transpose()?
|
||||
.flatten();
|
||||
let (mut secret, id_token) = self
|
||||
.exchange_pending(
|
||||
&pending,
|
||||
callback_url,
|
||||
fallback_refresh_token,
|
||||
current
|
||||
.as_ref()
|
||||
.map_or(pending.mutation.outbound_proxy(), |current| {
|
||||
@@ -537,14 +531,16 @@ impl CodexOAuthAdminService {
|
||||
let access_token_expires_at =
|
||||
parse_access_token_expiration(secret.access_token.expose_secret());
|
||||
let metadata = parse_chatgpt_jwt_claims(id_token.expose_secret())
|
||||
.map_err(|_| CodexOAuthAdminError::TokenRejected)?;
|
||||
.map_err(|_| CodexOAuthAdminError::TokenRejected)?
|
||||
.with_access_token_fallback(&secret.access_token);
|
||||
secret.id_token = Some(id_token);
|
||||
let credential = if let Some(current) = current {
|
||||
CompletedCodexOAuthCredential::Reauthorize(Box::new(
|
||||
self.credentials
|
||||
.prepare_refreshed_oauth_rotation(
|
||||
.prepare_oauth_rotation(
|
||||
current,
|
||||
secret,
|
||||
Some(metadata),
|
||||
access_token_expires_at,
|
||||
None,
|
||||
)
|
||||
@@ -680,7 +676,6 @@ impl CodexOAuthAdminService {
|
||||
&self,
|
||||
pending: &CodexPendingAuthorization,
|
||||
callback_url: &str,
|
||||
fallback_refresh_token: Option<SecretString>,
|
||||
proxy: Option<&gateway_core::account::OutboundProxy>,
|
||||
) -> Result<(super::types::CodexOAuthSecret, SecretString), CodexOAuthAdminError> {
|
||||
let (code, callback_state) = callback_parts(callback_url)?;
|
||||
@@ -701,10 +696,7 @@ impl CodexOAuthAdminService {
|
||||
)
|
||||
.await
|
||||
.map_err(CodexOAuthAdminError::Exchange)?;
|
||||
let mut secret = tokens.secret;
|
||||
if secret.refresh_token.is_none() {
|
||||
secret.refresh_token = fallback_refresh_token;
|
||||
}
|
||||
let secret = tokens.secret;
|
||||
record_oauth_recovery(
|
||||
CodexOAuthRecoveryOperation::AuthorizationCode,
|
||||
pending
|
||||
@@ -720,17 +712,6 @@ impl CodexOAuthAdminService {
|
||||
}
|
||||
}
|
||||
|
||||
fn current_oauth_refresh_token(
|
||||
current: &LoadedCredential,
|
||||
) -> Result<Option<SecretString>, CodexOAuthAdminError> {
|
||||
let runtime = CodexCredentialCodec::decode(¤t.credential)
|
||||
.map_err(|_| CodexOAuthAdminError::Credential)?;
|
||||
Ok(runtime
|
||||
.authentication
|
||||
.oauth()
|
||||
.and_then(|oauth| oauth.refresh_token.clone()))
|
||||
}
|
||||
|
||||
fn current_installation_id(current: &LoadedCredential) -> Result<String, CodexOAuthAdminError> {
|
||||
let runtime = CodexCredentialCodec::decode(¤t.credential)
|
||||
.map_err(|_| CodexOAuthAdminError::Credential)?;
|
||||
|
||||
@@ -110,7 +110,7 @@ pub struct CodexCredentialProfileService {
|
||||
profile: CodexWireProfileState,
|
||||
http: Client,
|
||||
base_url: String,
|
||||
avatar_sources: Mutex<HashMap<ProviderAccountId, CachedProfileAvatarSource>>,
|
||||
avatar_sources: Mutex<HashMap<String, CachedProfileAvatarSource>>,
|
||||
}
|
||||
|
||||
impl CodexCredentialProfileService {
|
||||
@@ -183,23 +183,36 @@ impl CodexCredentialProfileService {
|
||||
) -> Result<CodexProfileStatistics, CodexProfileStatisticsError> {
|
||||
let (authorization, upstream_account_id, account) =
|
||||
self.account_authentication(account_id).await?;
|
||||
self.fetch_profile_statistics(&authorization, upstream_account_id.as_deref(), &account)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn fetch_profile_statistics(
|
||||
&self,
|
||||
authorization: &SecretString,
|
||||
upstream_account_id: Option<&str>,
|
||||
account: &gateway_core::account::ProviderAccount,
|
||||
) -> Result<CodexProfileStatistics, CodexProfileStatisticsError> {
|
||||
let request_id = format!("profile_statistics_{}", Uuid::now_v7().simple());
|
||||
let statistics = CodexBackendClient::new(
|
||||
self.http.clone(),
|
||||
self.base_url.clone(),
|
||||
self.profile.clone(),
|
||||
)
|
||||
.for_account(&account)
|
||||
.for_account(account)
|
||||
.map_err(map_client_error)?
|
||||
.fetch_profile_statistics(CodexRequestContext::auxiliary(
|
||||
authorization.expose_secret(),
|
||||
upstream_account_id.as_deref(),
|
||||
upstream_account_id,
|
||||
&request_id,
|
||||
None,
|
||||
))
|
||||
.await
|
||||
.map_err(map_client_error)?;
|
||||
self.remember_avatar_source(account_id, statistics.image_url.as_deref());
|
||||
self.remember_avatar_source(
|
||||
super::oauth_identity_key(account),
|
||||
statistics.image_url.as_deref(),
|
||||
);
|
||||
Ok(statistics)
|
||||
}
|
||||
|
||||
@@ -253,16 +266,16 @@ impl CodexCredentialProfileService {
|
||||
&self,
|
||||
account_id: &ProviderAccountId,
|
||||
) -> Result<CodexProfileAvatar, CodexProfileAvatarError> {
|
||||
let source = match self.cached_avatar_source(account_id) {
|
||||
let (authorization, upstream_account_id, account) =
|
||||
self.account_authentication(account_id).await?;
|
||||
let source = match self.cached_avatar_source(&super::oauth_identity_key(&account)) {
|
||||
Some(source) => source,
|
||||
None => self
|
||||
.profile_statistics(account_id)
|
||||
.fetch_profile_statistics(&authorization, upstream_account_id.as_deref(), &account)
|
||||
.await?
|
||||
.image_url
|
||||
.ok_or(CodexProfileAvatarError::Missing)?,
|
||||
};
|
||||
let (authorization, upstream_account_id, account) =
|
||||
self.account_authentication(account_id).await?;
|
||||
let http = if account.outbound_proxy().is_some() {
|
||||
crate::transport::client::build_account_http_client(
|
||||
account.id().as_str(),
|
||||
@@ -288,17 +301,17 @@ impl CodexCredentialProfileService {
|
||||
.map_err(map_avatar_fetch_error)
|
||||
}
|
||||
|
||||
fn cached_avatar_source(&self, account_id: &ProviderAccountId) -> Option<String> {
|
||||
fn cached_avatar_source(&self, identity: &str) -> Option<String> {
|
||||
let now = Instant::now();
|
||||
let mut sources = self
|
||||
.avatar_sources
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
sources.retain(|_, source| source.expires_at > now);
|
||||
sources.get(account_id).map(|source| source.source.clone())
|
||||
sources.get(identity).map(|source| source.source.clone())
|
||||
}
|
||||
|
||||
fn remember_avatar_source(&self, account_id: &ProviderAccountId, source: Option<&str>) {
|
||||
fn remember_avatar_source(&self, identity: String, source: Option<&str>) {
|
||||
let now = Instant::now();
|
||||
let mut sources = self
|
||||
.avatar_sources
|
||||
@@ -308,7 +321,7 @@ impl CodexCredentialProfileService {
|
||||
match source {
|
||||
Some(source) => {
|
||||
sources.insert(
|
||||
account_id.clone(),
|
||||
identity,
|
||||
CachedProfileAvatarSource {
|
||||
source: source.to_owned(),
|
||||
expires_at: now + PROFILE_AVATAR_SOURCE_TTL,
|
||||
@@ -316,7 +329,7 @@ impl CodexCredentialProfileService {
|
||||
);
|
||||
}
|
||||
None => {
|
||||
sources.remove(account_id);
|
||||
sources.remove(&identity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,26 @@ use std::fmt;
|
||||
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use chrono::{DateTime, Utc};
|
||||
use secrecy::SecretString;
|
||||
use secrecy::{ExposeSecret, SecretString};
|
||||
use serde::{Deserialize, Serialize, de::DeserializeOwned};
|
||||
use url::Url;
|
||||
|
||||
/// 复用上游会话与账号资料时绑定本地记录和真实 OAuth 身份,正常续期不改变归属
|
||||
pub(crate) fn oauth_identity_key(account: &gateway_core::account::ProviderAccount) -> String {
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
let mut hash = Sha256::new();
|
||||
for part in [
|
||||
account.id().as_str(),
|
||||
account.upstream_user_id().unwrap_or_default(),
|
||||
account.upstream_account_id().unwrap_or_default(),
|
||||
] {
|
||||
hash.update((part.len() as u64).to_le_bytes());
|
||||
hash.update(part.as_bytes());
|
||||
}
|
||||
hex::encode(hash.finalize())
|
||||
}
|
||||
|
||||
/// OAuth AT/RT/ID Token;`Debug` 永不输出明文
|
||||
#[derive(Clone)]
|
||||
pub struct CodexOAuthSecret {
|
||||
@@ -53,6 +69,18 @@ pub(crate) struct CodexOAuthMetadata {
|
||||
pub(crate) chatgpt_account_id: Option<String>,
|
||||
}
|
||||
|
||||
impl CodexOAuthMetadata {
|
||||
pub(crate) fn with_access_token_fallback(self, access_token: &SecretString) -> Self {
|
||||
let fallback = parse_chatgpt_jwt_claims(access_token.expose_secret()).unwrap_or_default();
|
||||
Self {
|
||||
email: self.email.or(fallback.email),
|
||||
chatgpt_plan_type: self.chatgpt_plan_type.or(fallback.chatgpt_plan_type),
|
||||
chatgpt_user_id: self.chatgpt_user_id.or(fallback.chatgpt_user_id),
|
||||
chatgpt_account_id: self.chatgpt_account_id.or(fallback.chatgpt_account_id),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for CodexOAuthMetadata {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
|
||||
@@ -429,6 +429,8 @@ pub(super) struct OpenAiSessionState {
|
||||
pub(super) account_id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub(super) credential_revision: Option<u64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub(super) oauth_identity: Option<String>,
|
||||
pub(super) conversation_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(super) turn_state: Option<String>,
|
||||
@@ -450,6 +452,7 @@ pub(super) enum OpenAiContinuationScope {
|
||||
pub(super) struct OpenAiSessionCapture {
|
||||
pub(super) account_id: String,
|
||||
pub(super) credential_revision: Option<u64>,
|
||||
pub(super) oauth_identity: Option<String>,
|
||||
pub(super) conversation_id: Option<String>,
|
||||
pub(super) turn_state: Option<String>,
|
||||
pub(super) client_turn_id: Option<String>,
|
||||
@@ -498,6 +501,7 @@ fn encode_openai_session_capture(
|
||||
encode_openai_session_state(OpenAiSessionState {
|
||||
account_id: capture.account_id.clone(),
|
||||
credential_revision: capture.credential_revision,
|
||||
oauth_identity: capture.oauth_identity.clone(),
|
||||
conversation_id: capture.conversation_id.clone(),
|
||||
turn_state: capture.turn_state.clone(),
|
||||
client_turn_id: capture.client_turn_id.clone(),
|
||||
@@ -1164,7 +1168,7 @@ pub(super) fn cold_response_stream(response: ColdResponse) -> EventStream {
|
||||
if let Some(affinity) = &routing_affinity {
|
||||
for fact in events.iter().flat_map(ProviderEvent::canonical_facts) {
|
||||
if let GatewayEvent::Started(meta) = fact {
|
||||
guardian_routing.remember(context.client_api_key_ref(), active_account.id(), meta.response_id(), affinity);
|
||||
guardian_routing.remember(context.client_api_key_ref(), &active_account, meta.response_id(), affinity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ use std::{
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use gateway_core::{account::ProviderAccountId, policy::ClientApiKeyId};
|
||||
use gateway_core::{account::ProviderAccount, policy::ClientApiKeyId};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::transport::routing_affinity::ChatGptRoutingAffinity;
|
||||
@@ -29,7 +29,7 @@ impl GuardianRouting {
|
||||
pub(super) fn get(
|
||||
&self,
|
||||
client_key: &ClientApiKeyId,
|
||||
account: &ProviderAccountId,
|
||||
account: &ProviderAccount,
|
||||
response_id: &str,
|
||||
) -> Option<ChatGptRoutingAffinity> {
|
||||
let key = parent_key(client_key, account, response_id);
|
||||
@@ -48,7 +48,7 @@ impl GuardianRouting {
|
||||
pub(super) fn remember(
|
||||
&self,
|
||||
client_key: &ClientApiKeyId,
|
||||
account: &ProviderAccountId,
|
||||
account: &ProviderAccount,
|
||||
response_id: &str,
|
||||
affinity: &ChatGptRoutingAffinity,
|
||||
) {
|
||||
@@ -83,11 +83,12 @@ impl GuardianRouting {
|
||||
|
||||
fn parent_key(
|
||||
client_key: &ClientApiKeyId,
|
||||
account: &ProviderAccountId,
|
||||
account: &ProviderAccount,
|
||||
response_id: &str,
|
||||
) -> [u8; 32] {
|
||||
let mut hash = Sha256::new();
|
||||
for part in [client_key.as_str(), account.as_str(), response_id] {
|
||||
let identity = crate::credential::oauth_identity_key(account);
|
||||
for part in [client_key.as_str(), &identity, response_id] {
|
||||
hash.update(part.len().to_le_bytes());
|
||||
hash.update(part.as_bytes());
|
||||
}
|
||||
|
||||
@@ -888,15 +888,24 @@ impl CodexProvider {
|
||||
}
|
||||
let provider_kind = ProviderKind::new(PROVIDER_NAME)
|
||||
.map_err(|_| provider_error(ProviderErrorKind::Protocol, UpstreamSendState::NotSent))?;
|
||||
let oauth_identity = matches!(
|
||||
lease.authentication(),
|
||||
crate::credential::CodexRuntimeAuthentication::OAuth(_)
|
||||
)
|
||||
.then(|| crate::credential::oauth_identity_key(lease.account()));
|
||||
let native_owner = context.continuation().and_then(ContinuationBinding::pinned);
|
||||
let state_owner_cross_account = native_owner
|
||||
.is_some_and(|owner| !owner.matches(&provider_kind, lease.account_id()))
|
||||
|| context
|
||||
.account_state_owner()
|
||||
.is_some_and(|owner| !owner.matches(&provider_kind, lease.account_id()))
|
||||
|| previous_session
|
||||
.as_ref()
|
||||
.is_some_and(|state| state.account_id != lease.account_id().as_str());
|
||||
|| previous_session.as_ref().is_some_and(|state| {
|
||||
state.account_id != lease.account_id().as_str()
|
||||
|| state
|
||||
.oauth_identity
|
||||
.as_ref()
|
||||
.is_some_and(|identity| Some(identity) != oauth_identity.as_ref())
|
||||
});
|
||||
let has_explicit_state_owner = context.account_state_owner().is_some()
|
||||
|| previous_session.is_some()
|
||||
|| native_owner.is_some();
|
||||
@@ -970,11 +979,7 @@ impl CodexProvider {
|
||||
.flatten()
|
||||
.and_then(|response_id| {
|
||||
self.guardian_routing
|
||||
.get(
|
||||
context.client_api_key_ref(),
|
||||
lease.account_id(),
|
||||
response_id,
|
||||
)
|
||||
.get(context.client_api_key_ref(), lease.account(), response_id)
|
||||
.map(|affinity| (response_id.to_owned(), affinity))
|
||||
});
|
||||
let client = self.client_for_request(&context)?;
|
||||
@@ -1133,6 +1138,7 @@ impl CodexProvider {
|
||||
let session_capture =
|
||||
(!continuation_requested || previous_session.is_some()).then(|| OpenAiSessionCapture {
|
||||
account_id: lease.account_id().as_str().to_owned(),
|
||||
oauth_identity,
|
||||
credential_revision: matches!(
|
||||
lease.authentication(),
|
||||
crate::credential::CodexRuntimeAuthentication::ApiKey(_)
|
||||
|
||||
@@ -885,6 +885,10 @@ impl CodexBackendClient {
|
||||
client
|
||||
.egress_key
|
||||
.push_str(&format!(":revision:{}", account.revision().get()));
|
||||
} else {
|
||||
client
|
||||
.egress_key
|
||||
.push_str(&crate::credential::oauth_identity_key(account));
|
||||
}
|
||||
client.websocket_origin_key = format!(
|
||||
"{}:{}",
|
||||
|
||||
@@ -476,6 +476,89 @@ async fn repeated_reauthorization_preserves_surface_id_with_a_cleared_name() {
|
||||
assert_eq!(surface_ids[0], surface_ids[1]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reauthorization_binds_the_selected_slot_to_the_new_oauth_account() {
|
||||
use secrecy::ExposeSecret;
|
||||
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
let account_id = ProviderAccountId::new("acct_reauth").unwrap();
|
||||
store
|
||||
.seed_oauth_credential(ImportCodexOAuthCredential {
|
||||
account_id: account_id.to_string(),
|
||||
name: "local label".into(),
|
||||
secret: secret("original-access"),
|
||||
verified_account: account_profile("workspace-a"),
|
||||
next_refresh_at: None,
|
||||
enabled: true,
|
||||
})
|
||||
.await;
|
||||
let pending = Arc::new(PendingStore::default());
|
||||
let service = CodexOAuthAdminService::new(
|
||||
pending.clone(),
|
||||
Arc::new(Exchanger {
|
||||
id_token: id_token(serde_json::json!({
|
||||
"email": "b@example.com",
|
||||
"https://api.openai.com/auth": {
|
||||
"chatgpt_user_id": "user-b",
|
||||
"chatgpt_account_id": "workspace-b",
|
||||
"chatgpt_plan_type": "plus"
|
||||
}
|
||||
})),
|
||||
}),
|
||||
store,
|
||||
CodexCredentialAdmin,
|
||||
profile(),
|
||||
);
|
||||
let started = service
|
||||
.start_authorization(StartCodexOAuthAuthorization {
|
||||
mutation: reauthorization_mutation(account_id.clone(), "reauth-request"),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let state = pending
|
||||
.pending
|
||||
.lock()
|
||||
.unwrap()
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.state
|
||||
.clone();
|
||||
let completed = service
|
||||
.complete_authorization(CompleteCodexOAuthAuthorization {
|
||||
owner_ref: "test-owner".into(),
|
||||
flow_id: started.flow_id,
|
||||
callback_url: SecretString::from(format!(
|
||||
"http://127.0.0.1:1455/auth/callback?code=browser&state={}",
|
||||
state.expose_secret()
|
||||
)),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let CompletedCodexOAuthCredential::Reauthorize(rotation) = completed.credential else {
|
||||
panic!("expected reauthorization")
|
||||
};
|
||||
let identity = rotation.replacement_identity.unwrap();
|
||||
assert_eq!(identity.upstream_user_id(), "user-b");
|
||||
assert_eq!(identity.upstream_account_id(), Some("workspace-b"));
|
||||
assert_eq!(rotation.profile.account_id, account_id);
|
||||
assert_eq!(rotation.profile.name, "local label");
|
||||
assert_eq!(rotation.profile.email.as_deref(), Some("b@example.com"));
|
||||
assert_eq!(rotation.profile.plan_type.as_deref(), Some("plus"));
|
||||
let credential = rotation.credential.into_parts();
|
||||
assert!(!credential.preserve_profile);
|
||||
let runtime = CodexCredentialCodec::decode(&credential.credential).unwrap();
|
||||
assert!(runtime.principal.is_none());
|
||||
assert_eq!(
|
||||
runtime
|
||||
.authentication
|
||||
.oauth()
|
||||
.unwrap()
|
||||
.access_token
|
||||
.expose_secret(),
|
||||
"access-token-from-upstream"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn first_exchange_preserves_optional_local_name_and_upstream_email() {
|
||||
for name in ["", "My account"] {
|
||||
|
||||
@@ -289,6 +289,21 @@ async fn exact_continuation_reuses_owning_socket_after_connection_profile_update
|
||||
profile.clone(),
|
||||
)
|
||||
.with_websocket_pool(Arc::new(CodexWebSocketPool::new(Duration::from_mins(1))));
|
||||
let account = |user: &str| {
|
||||
use gateway_core::account::{CredentialRevision, ProviderAccount, ProviderAccountId};
|
||||
ProviderAccount::new(
|
||||
ProviderAccountId::new("acct_shared_slot").unwrap(),
|
||||
gateway_core::routing::ProviderKind::new("openai").unwrap(),
|
||||
String::new(),
|
||||
Some(user.to_owned()),
|
||||
"oauth".to_owned(),
|
||||
CredentialRevision::new(1).unwrap(),
|
||||
None,
|
||||
)
|
||||
.with_profile(None, Some("chatgpt-account".into()), None)
|
||||
};
|
||||
let replacement = backend.for_account(&account("user-b")).unwrap();
|
||||
let backend = backend.for_account(&account("user-a")).unwrap();
|
||||
let first_request = pooled_websocket_request("wire-profile-continuation");
|
||||
let first = backend
|
||||
.create_response(
|
||||
@@ -309,6 +324,16 @@ async fn exact_continuation_reuses_owning_socket_after_connection_profile_update
|
||||
let mut continuation = first_request;
|
||||
continuation.set_previous_response_id(Some("resp_connection_local".to_owned()));
|
||||
continuation.previous_response_scope = Some(PreviousResponseScope::ConnectionLocal);
|
||||
assert!(
|
||||
replacement
|
||||
.create_response(
|
||||
&continuation,
|
||||
request_context("req_reauthorized", Some("chatgpt-account"))
|
||||
)
|
||||
.await
|
||||
.is_err(),
|
||||
"同一工作区的另一个用户不能续用旧账号的连接"
|
||||
);
|
||||
let changed_installation = backend
|
||||
.create_response(
|
||||
&continuation,
|
||||
|
||||
+4
-2
@@ -1598,8 +1598,10 @@ OpenAI 授权码交换与令牌刷新请求的连接超时为 30 秒,单次上
|
||||
失效后需取得新 PAT 再导入
|
||||
- 首次 OAuth 保留回调 `state`、PKCE 与官方 token exchange,并持久化 `idToken`、`accessToken`、
|
||||
`refreshToken`。刷新响应中的三个 token 字段均按官方语义独立轮换:返回新值时替换,省略时分别保留
|
||||
现值。重新授权也保留这些回调保护,但只轮换目标账号的 token。回调地址只承载 `code`/`state`,
|
||||
不以 host/path 形式作为拒绝条件
|
||||
现值。回调地址只承载 `code`/`state`,不以 host/path 形式作为拒绝条件
|
||||
- OpenAI 重新授权允许登录另一个上游账号。保留本地账号 ID、名称和调度设置,按新授权更新令牌、
|
||||
上游用户与工作区 ID、邮箱和套餐。换号时清除旧账号的额度快照和耗尽状态,新额度由后台重新查询。
|
||||
无法识别新用户或新身份已绑定其他记录时,原账号保持不变
|
||||
- OAuth 账号文件导入和 OAuth complete(包括重新授权)在 credential 提交后后台尝试一次额度观测,不等待
|
||||
观测完成才返回成功。观测失败只记录告警,不回滚已提交的账号;手工或后台 RT 刷新只更新 token,
|
||||
不隐式等同于手工额度刷新,也不更新既有账号资料或 OAuth principal。xAI 导入与 OAuth complete
|
||||
|
||||
@@ -505,6 +505,7 @@ OpenAI Provider 按 Client Key、所选 OAuth 账号和父响应 ID 关联 WS
|
||||
缺失或失效时按普通上游路由执行。transport 负责 Cookie 有效期和同 origin、同端点路径约束,
|
||||
连接池按快照身份隔离审查连接;复用父连接不会重新计算 Cookie 有效期,Cookie 到期不强制关闭已建立的审查连接
|
||||
|
||||
OpenAI OAuth 的连接、续接状态、Guardian 路由 Cookie 和头像缓存按本地账号、上游用户与工作区隔离,同号令牌续期不改变归属。
|
||||
OpenAI Provider 统一处理 Responses 的账号归属与 turn state 隔离,HTTP 与 WS 消费同一出站请求。
|
||||
全量请求缺少本地归属记录时保留客户端回传的路由 token,明确换号或可信续接状态不属于同一轮时清除;
|
||||
turn state 不参与选号,也不授权其他账号状态或 response ID 的复用
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
version: 3.43.0
|
||||
published:
|
||||
'3':
|
||||
stable: 3.42.1
|
||||
rc: 3.42.1
|
||||
beta: 3.42.1
|
||||
alpha: 3.42.1
|
||||
stable: 3.43.0
|
||||
rc: 3.43.0
|
||||
beta: 3.43.0
|
||||
alpha: 3.43.0
|
||||
|
||||
Reference in New Issue
Block a user