mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-07 00:05:49 +00:00
fix(openai): bind reauthorization to stable account identity
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -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(¤t.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());
|
||||
|
||||
Reference in New Issue
Block a user