fix(openai): bind reauthorization to stable account identity

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
zyycn
2026-07-29 00:45:43 +08:00
co-authored by Codex
parent cc42e78334
commit 92ae7df115
6 changed files with 315 additions and 8 deletions
@@ -490,6 +490,22 @@ impl CodexCredentialAdmin {
pub fn prepare_rotation(
&self,
input: RotateManagedCodexCredential,
) -> Result<PreparedCodexCredentialRotation, CodexCredentialAdminError> {
self.prepare_oauth_rotation(input, false)
}
/// 完整 OAuth 授权允许认证主体投影更新,但不允许目标账号或用户发生变化。
pub(crate) fn prepare_reauthorization(
&self,
input: RotateManagedCodexCredential,
) -> Result<PreparedCodexCredentialRotation, CodexCredentialAdminError> {
self.prepare_oauth_rotation(input, true)
}
fn prepare_oauth_rotation(
&self,
input: RotateManagedCodexCredential,
replace_principal: bool,
) -> Result<PreparedCodexCredentialRotation, CodexCredentialAdminError> {
let access_token_expires_at =
required_time(input.verified_account.access_token_expires_at)?;
@@ -503,11 +519,22 @@ impl CodexCredentialAdmin {
|| input.current.account.upstream_account_id()
!= Some(input.verified_account.chatgpt_account_id.as_str())
|| input.current.account.upstream_user_id() != input.verified_account.chatgpt_user_id
|| oauth.principal.oauth_subject != input.verified_account.oauth_subject
|| oauth.principal.poid != input.verified_account.poid
|| (!replace_principal
&& (oauth.principal.oauth_subject != input.verified_account.oauth_subject
|| oauth.principal.poid != input.verified_account.poid))
{
return Err(CodexCredentialAdminError::IdentityMismatch);
}
if replace_principal {
oauth
.principal
.oauth_subject
.clone_from(&input.verified_account.oauth_subject);
oauth
.principal
.poid
.clone_from(&input.verified_account.poid);
}
oauth.access_token = input.secret.access_token.expose_secret().to_owned();
oauth.refresh_token = input
.secret
@@ -198,6 +198,27 @@ impl CodexIdentityExpectation {
})
}
/// 重新授权只绑定公共稳定账号事实;新 OAuth subject/poid 在 OIDC
/// 验证后随凭据一起更新。
pub fn reauthorization(
chatgpt_account_id: String,
chatgpt_user_id: String,
installation_id: String,
) -> Result<Self, CodexIdentityVerificationError> {
if !valid_identity(&chatgpt_account_id)
|| !valid_identity(&chatgpt_user_id)
|| !valid_installation_id(&installation_id)
{
return Err(CodexIdentityVerificationError::Rejected);
}
Ok(Self {
chatgpt_account_id: Some(chatgpt_account_id),
chatgpt_user_id: Some(chatgpt_user_id),
installation_id: Some(installation_id),
..Self::default()
})
}
#[must_use]
pub fn chatgpt_account_id(&self) -> Option<&str> {
self.chatgpt_account_id.as_deref()
@@ -647,7 +668,22 @@ impl CodexAccountIdentityVerifier for CodexAccountIdentityService {
.signed
.verify_authorization(secret, id_token, expected_nonce)
.await?;
self.complete(secret, signed, expectation).await
let fallback = signed_profile(signed.clone(), expectation).ok();
match self.accounts.fetch(secret, expectation).await {
Ok(account) => match complete_profile(signed, account, expectation) {
Ok(profile) => Ok(CodexIdentityVerification::Complete(profile)),
Err(_) => fallback
.map(CodexIdentityVerification::Complete)
.ok_or(CodexIdentityVerificationError::Rejected),
},
Err(CodexIdentityVerificationError::Rejected) => fallback
.map(CodexIdentityVerification::Complete)
.ok_or(CodexIdentityVerificationError::Rejected),
Err(CodexIdentityVerificationError::Unavailable) => Ok(match fallback {
Some(profile) => CodexIdentityVerification::Complete(profile),
None => CodexIdentityVerification::SignedOnly(signed),
}),
}
}
}
@@ -885,6 +921,30 @@ fn complete_profile(
})
}
fn signed_profile(
signed: CodexSignedIdentity,
expectation: &CodexIdentityExpectation,
) -> Result<CodexAccountProfile, CodexIdentityVerificationError> {
let profile = signed.into_import_profile()?;
if expectation
.oauth_subject
.as_deref()
.is_some_and(|expected| expected != profile.oauth_subject)
|| matches!(&expectation.poid, PoidExpectation::Exact(expected) if expected != &profile.poid)
|| expectation
.chatgpt_account_id
.as_deref()
.is_some_and(|expected| expected != profile.chatgpt_account_id)
|| expectation
.chatgpt_user_id
.as_deref()
.is_some_and(|expected| expected != profile.chatgpt_user_id)
{
return Err(CodexIdentityVerificationError::Rejected);
}
Ok(profile)
}
fn authenticated_account(
value: &Value,
) -> Result<CodexAuthenticatedAccount, CodexIdentityVerificationError> {
@@ -527,7 +527,7 @@ impl CodexOAuthAdminService {
.map_err(map_admin_error)?;
CompletedCodexOAuthCredential::Reauthorize(
self.credentials
.prepare_rotation(RotateManagedCodexCredential {
.prepare_reauthorization(RotateManagedCodexCredential {
current,
secret,
verified_account: profile,
@@ -684,14 +684,14 @@ fn oauth_identity_expectation(
) -> Result<CodexIdentityExpectation, CodexOAuthAdminError> {
let runtime = CodexCredentialCodec::decode(&current.credential)
.map_err(|_| CodexOAuthAdminError::Credential)?;
if runtime.authentication.oauth().is_none() {
return Err(CodexOAuthAdminError::Credential);
}
let account_id = current
.account
.upstream_account_id()
.ok_or(CodexOAuthAdminError::Credential)?;
let principal = runtime.principal.ok_or(CodexOAuthAdminError::Credential)?;
CodexIdentityExpectation::current(
principal.oauth_subject,
principal.poid,
CodexIdentityExpectation::reauthorization(
account_id.to_owned(),
current.account.upstream_user_id().to_owned(),
runtime.installation_id,
@@ -119,6 +119,26 @@ fn prepare_rotation_rejects_account_rebinding() {
assert_eq!(error, CodexCredentialAdminError::IdentityMismatch);
}
#[test]
fn prepare_rotation_rejects_principal_rebinding_without_full_authorization() {
let mut rebound = profile("chatgpt-acct_rotate");
rebound.oauth_subject = "different-subject".to_owned();
rebound.poid = None;
let error = CodexCredentialAdmin
.prepare_rotation(RotateManagedCodexCredential {
current: LoadedCredential {
account: codex_account("acct_rotate"),
credential: encoded_credential("chatgpt-acct_rotate", "old-access"),
},
secret: secret("new-access"),
verified_account: rebound,
next_refresh_at: Some(chrono::Utc::now() + chrono::Duration::minutes(30)),
})
.expect_err("non-authorization rotation cannot replace the principal");
assert_eq!(error, CodexCredentialAdminError::IdentityMismatch);
}
#[test]
fn prepared_commands_debug_never_prints_tokens() {
let prepared = CodexCredentialAdmin
@@ -313,6 +313,133 @@ async fn import_identity_keeps_signed_workspace_when_usage_returns_a_different_w
assert_eq!(profile.chatgpt_user_id, "user-signed");
}
#[tokio::test]
async fn authorization_identity_uses_signed_workspace_when_usage_returns_default_workspace() {
let (signed, _) = verifier();
let service = CodexAccountIdentityService::new(
Arc::new(signed),
Arc::new(StaticAccountSource {
result: Ok(CodexAuthenticatedAccount {
chatgpt_account_id: "account-default".to_owned(),
chatgpt_user_id: "user-default".to_owned(),
email: None,
plan_type: None,
}),
}),
);
let nonce = SecretString::from("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA");
let profile = service
.verify_authorization(
&secret(
&claims(get_current_timestamp() + 3_600),
Some("refresh-token"),
),
&SecretString::from(token(&id_token_claims(
nonce.expose_secret(),
"subject-signed",
))),
&nonce,
&CodexIdentityExpectation::reauthorization(
"account-signed".to_owned(),
"user-signed".to_owned(),
"00000000-0000-4000-8000-000000000001".to_owned(),
)
.expect("reauthorization expectation"),
)
.await
.expect("signed authorization identity")
.into_complete()
.expect("complete signed profile");
assert_eq!(profile.chatgpt_account_id, "account-signed");
assert_eq!(profile.chatgpt_user_id, "user-signed");
}
#[tokio::test]
async fn authorization_identity_accepts_free_token_without_signed_account_id() {
let (signed, _) = verifier();
let service = CodexAccountIdentityService::new(
Arc::new(signed),
Arc::new(StaticAccountSource {
result: Ok(CodexAuthenticatedAccount {
chatgpt_account_id: "account-signed".to_owned(),
chatgpt_user_id: "user-signed".to_owned(),
email: Some("usage@example.invalid".to_owned()),
plan_type: Some("free".to_owned()),
}),
}),
);
let mut free_claims = claims(get_current_timestamp() + 3_600);
free_claims["https://api.openai.com/auth"]
.as_object_mut()
.expect("auth claims")
.remove("chatgpt_account_id");
free_claims["https://api.openai.com/auth"]["poid"] = json!("new-free-poid");
let nonce = SecretString::from("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA");
let profile = service
.verify_authorization(
&secret(&free_claims, Some("refresh-token")),
&SecretString::from(token(&id_token_claims(
nonce.expose_secret(),
"subject-signed",
))),
&nonce,
&CodexIdentityExpectation::reauthorization(
"account-signed".to_owned(),
"user-signed".to_owned(),
"00000000-0000-4000-8000-000000000001".to_owned(),
)
.expect("reauthorization expectation"),
)
.await
.expect("verified Free authorization identity")
.into_complete()
.expect("complete Free profile");
assert_eq!(profile.chatgpt_account_id, "account-signed");
assert_eq!(profile.chatgpt_user_id, "user-signed");
assert_eq!(profile.poid.as_deref(), Some("new-free-poid"));
}
#[tokio::test]
async fn authorization_identity_rejects_signed_workspace_rebinding() {
let (signed, _) = verifier();
let service = CodexAccountIdentityService::new(
Arc::new(signed),
Arc::new(StaticAccountSource {
result: Ok(CodexAuthenticatedAccount {
chatgpt_account_id: "account-default".to_owned(),
chatgpt_user_id: "user-default".to_owned(),
email: None,
plan_type: None,
}),
}),
);
let nonce = SecretString::from("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA");
let error = service
.verify_authorization(
&secret(
&claims(get_current_timestamp() + 3_600),
Some("refresh-token"),
),
&SecretString::from(token(&id_token_claims(
nonce.expose_secret(),
"subject-signed",
))),
&nonce,
&CodexIdentityExpectation::reauthorization(
"account-target".to_owned(),
"user-target".to_owned(),
"00000000-0000-4000-8000-000000000001".to_owned(),
)
.expect("reauthorization expectation"),
)
.await
.expect_err("signed identity cannot rebind the target account");
assert_eq!(error, CodexIdentityVerificationError::Rejected);
}
#[tokio::test]
async fn account_identity_keeps_usage_unavailable_out_of_ready_profile() {
let (signed, _) = verifier();
@@ -625,6 +625,79 @@ impl CodexAccountIdentityVerifier for RebindingVerifier {
}
}
struct RotatedPrincipalVerifier;
#[async_trait]
impl CodexAccountIdentityVerifier for RotatedPrincipalVerifier {
async fn verify(
&self,
_: &CodexOAuthSecret,
_: &CodexIdentityExpectation,
) -> Result<CodexIdentityVerification, CodexIdentityVerificationError> {
Err(CodexIdentityVerificationError::Rejected)
}
async fn verify_authorization(
&self,
_: &CodexOAuthSecret,
_: &SecretString,
_: &SecretString,
_: &CodexIdentityExpectation,
) -> Result<CodexIdentityVerification, CodexIdentityVerificationError> {
let mut verified = profile("chatgpt-oauth");
verified.oauth_subject = "rotated-oauth-subject".to_owned();
verified.poid = None;
Ok(CodexIdentityVerification::Complete(verified))
}
}
#[tokio::test]
async fn reauthorization_updates_changed_free_principal_for_same_account() {
let store = Arc::new(MemoryAccountStore::default());
store
.seed_oauth_credential(ImportCodexOAuthCredential {
account_id: "acct_oauth_principal".to_owned(),
name: "reauthorize".to_owned(),
secret: secret("old-oauth-access"),
verified_account: profile("chatgpt-oauth"),
next_refresh_at: Some(chrono::Utc::now() + chrono::Duration::minutes(30)),
enabled: true,
})
.await;
let service = service_with_store(store, Arc::new(RotatedPrincipalVerifier));
let started = service
.start_authorization(StartCodexOAuthAuthorization {
mutation: reauthorization_mutation("request-rotated-principal", "acct_oauth_principal"),
})
.await
.expect("start reauthorization");
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 reauthorization");
let CompletedCodexOAuthCredential::Reauthorize(prepared) = prepared.credential else {
panic!("expected prepared reauthorization");
};
let runtime =
provider_openai::credential::CodexCredentialCodec::decode(prepared.credential.credential())
.expect("prepared credential");
let principal = runtime.principal.expect("OAuth principal");
assert_eq!(principal.oauth_subject, "rotated-oauth-subject");
assert_eq!(principal.poid, None);
}
#[tokio::test]
async fn reauthorization_rejects_identity_rebinding() {
let store = Arc::new(MemoryAccountStore::default());