diff --git a/backend/crates/providers/openai/src/credential/admin.rs b/backend/crates/providers/openai/src/credential/admin.rs index 0822711d6..fb0f815e9 100644 --- a/backend/crates/providers/openai/src/credential/admin.rs +++ b/backend/crates/providers/openai/src/credential/admin.rs @@ -490,6 +490,22 @@ impl CodexCredentialAdmin { pub fn prepare_rotation( &self, input: RotateManagedCodexCredential, + ) -> Result { + self.prepare_oauth_rotation(input, false) + } + + /// 完整 OAuth 授权允许认证主体投影更新,但不允许目标账号或用户发生变化。 + pub(crate) fn prepare_reauthorization( + &self, + input: RotateManagedCodexCredential, + ) -> Result { + self.prepare_oauth_rotation(input, true) + } + + fn prepare_oauth_rotation( + &self, + input: RotateManagedCodexCredential, + replace_principal: bool, ) -> Result { 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 diff --git a/backend/crates/providers/openai/src/credential/identity.rs b/backend/crates/providers/openai/src/credential/identity.rs index 33455bac6..178c7f173 100644 --- a/backend/crates/providers/openai/src/credential/identity.rs +++ b/backend/crates/providers/openai/src/credential/identity.rs @@ -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 { + 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 { + 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 { diff --git a/backend/crates/providers/openai/src/credential/oauth.rs b/backend/crates/providers/openai/src/credential/oauth.rs index 59086f0f6..0e227d9a8 100644 --- a/backend/crates/providers/openai/src/credential/oauth.rs +++ b/backend/crates/providers/openai/src/credential/oauth.rs @@ -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 { 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, diff --git a/backend/crates/providers/openai/tests/credential/admin.rs b/backend/crates/providers/openai/tests/credential/admin.rs index 10d6ff27c..970338b01 100644 --- a/backend/crates/providers/openai/tests/credential/admin.rs +++ b/backend/crates/providers/openai/tests/credential/admin.rs @@ -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 diff --git a/backend/crates/providers/openai/tests/credential/identity.rs b/backend/crates/providers/openai/tests/credential/identity.rs index 813188cd0..75b1d3ba4 100644 --- a/backend/crates/providers/openai/tests/credential/identity.rs +++ b/backend/crates/providers/openai/tests/credential/identity.rs @@ -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(); diff --git a/backend/crates/providers/openai/tests/credential/oauth.rs b/backend/crates/providers/openai/tests/credential/oauth.rs index 7e78884e5..670bf8a0c 100644 --- a/backend/crates/providers/openai/tests/credential/oauth.rs +++ b/backend/crates/providers/openai/tests/credential/oauth.rs @@ -625,6 +625,79 @@ impl CodexAccountIdentityVerifier for RebindingVerifier { } } +struct RotatedPrincipalVerifier; + +#[async_trait] +impl CodexAccountIdentityVerifier for RotatedPrincipalVerifier { + async fn verify( + &self, + _: &CodexOAuthSecret, + _: &CodexIdentityExpectation, + ) -> Result { + Err(CodexIdentityVerificationError::Rejected) + } + + async fn verify_authorization( + &self, + _: &CodexOAuthSecret, + _: &SecretString, + _: &SecretString, + _: &CodexIdentityExpectation, + ) -> Result { + 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());