diff --git a/backend/crates/providers/openai/src/admin.rs b/backend/crates/providers/openai/src/admin.rs index 2eb3949be..15267f53a 100644 --- a/backend/crates/providers/openai/src/admin.rs +++ b/backend/crates/providers/openai/src/admin.rs @@ -1011,6 +1011,7 @@ struct PendingDocument { state: String, nonce: String, code_verifier: String, + installation_id: String, reauthorization_account_id: Option, mutation: PendingMutationDocument, } @@ -1071,6 +1072,10 @@ fn encode_pending(pending: &CodexPendingAuthorization) -> Map { "code_verifier".to_owned(), Value::String(pending.code_verifier().expose_secret().to_owned()), ); + document.insert( + "installation_id".to_owned(), + Value::String(pending.installation_id().to_owned()), + ); document.insert( "reauthorization_account_id".to_owned(), pending @@ -1161,6 +1166,7 @@ fn decode_pending( state: SecretString::from(document.state), nonce: SecretString::from(document.nonce), code_verifier: SecretString::from(document.code_verifier), + installation_id: document.installation_id, reauthorization_account_id: document.reauthorization_account_id, mutation: decode_mutation(document.mutation)?, }) diff --git a/backend/crates/providers/openai/src/credential/admin.rs b/backend/crates/providers/openai/src/credential/admin.rs index 565a9eb2e..32020dfa5 100644 --- a/backend/crates/providers/openai/src/credential/admin.rs +++ b/backend/crates/providers/openai/src/credential/admin.rs @@ -55,6 +55,7 @@ pub struct ImportCodexOAuthCredential { pub(crate) struct UnresolvedCodexOAuthCredential { pub(crate) account_id: String, pub(crate) name: String, + pub(crate) installation_id: String, pub(crate) secret: CodexOAuthSecret, pub(crate) metadata: CodexOAuthMetadata, pub(crate) access_token_expires_at: Option>, @@ -68,6 +69,7 @@ impl fmt::Debug for UnresolvedCodexOAuthCredential { .debug_struct("UnresolvedCodexOAuthCredential") .field("account_id", &self.account_id) .field("name", &self.name) + .field("installation_id", &"") .field("secret", &"") .field("metadata", &self.metadata) .field("access_token_expires_at", &self.access_token_expires_at) @@ -473,8 +475,12 @@ impl CodexCredentialAdmin { chatgpt_account_id: upstream_account_id, } = input.metadata; let has_upstream_user_id = upstream_user_id.is_some(); - let credential = CodexCredentialCodec::encode_unresolved(&input.secret, Vec::new()) - .map_err(|_| CodexCredentialAdminError::InvalidCredential)?; + let credential = CodexCredentialCodec::encode_unresolved( + &input.secret, + input.installation_id, + Vec::new(), + ) + .map_err(|_| CodexCredentialAdminError::InvalidCredential)?; let account = ProviderAccount::new( account_id, provider, @@ -917,6 +923,7 @@ impl CodexCredentialAdminService { .or_else(|| metadata.email.clone()) .filter(|name| !name.trim().is_empty()) .unwrap_or_else(|| "Codex OAuth".to_owned()), + installation_id: uuid::Uuid::new_v4().to_string(), secret, metadata, access_token_expires_at, diff --git a/backend/crates/providers/openai/src/credential/oauth.rs b/backend/crates/providers/openai/src/credential/oauth.rs index 0f48317ac..23372e8b0 100644 --- a/backend/crates/providers/openai/src/credential/oauth.rs +++ b/backend/crates/providers/openai/src/credential/oauth.rs @@ -36,11 +36,16 @@ use super::token_client::{ OFFICIAL_CODEX_OAUTH_CLIENT_ID, OFFICIAL_CODEX_REDIRECT_URI, }; use super::types::parse_chatgpt_jwt_claims; +use crate::transport::profile::CodexWireProfileState; const AUTHORIZATION_ENDPOINT: &str = "https://auth.openai.com/oauth/authorize"; +const DESKTOP_AUTH_ENDPOINT: &str = "https://chatgpt.com/codex/desktop-auth"; const AUTHORIZATION_SCOPE: &str = "openid profile email offline_access api.connectors.read api.connectors.invoke"; const AUTHORIZATION_ORIGINATOR: &str = "Codex Desktop"; +const STREAMLINED_LOGIN_QUERY: &str = "codex_streamlined_login"; +const NO_UNIVERSAL_LINKS_QUERY: &str = "no_universal_links"; +const SURFACE_STABLE_ID_DOMAIN: &[u8] = b"openai/codex-desktop/surface-stable-id/v1\0"; const AUTHORIZATION_TTL: TimeDelta = TimeDelta::minutes(10); const AUTHORIZATION_CLAIM_TTL: Duration = Duration::from_secs(90); const MAX_CALLBACK_BYTES: usize = 16 * 1024; @@ -139,6 +144,7 @@ pub struct CodexPendingAuthorization { state: SecretString, nonce: SecretString, code_verifier: SecretString, + installation_id: String, reauthorization: Option, mutation: PendingAuthorizationMutation, } @@ -152,6 +158,7 @@ pub struct StoredCodexPendingAuthorization { pub state: SecretString, pub nonce: SecretString, pub code_verifier: SecretString, + pub installation_id: String, pub reauthorization_account_id: Option, pub mutation: PendingAuthorizationMutation, } @@ -174,6 +181,7 @@ impl CodexPendingAuthorization { state: input.state, nonce: input.nonce, code_verifier: input.code_verifier, + installation_id: input.installation_id, reauthorization, mutation: input.mutation, }; @@ -185,6 +193,7 @@ impl CodexPendingAuthorization { || !valid_secret(pending.state.expose_secret()) || !valid_secret(pending.nonce.expose_secret()) || !valid_secret(pending.code_verifier.expose_secret()) + || !valid_installation_id(&pending.installation_id) || !pending_mutation_matches(&pending) { return Err(CodexOAuthPendingStoreError::InvalidValue); @@ -232,6 +241,11 @@ impl CodexPendingAuthorization { &self.code_verifier } + #[must_use] + pub fn installation_id(&self) -> &str { + &self.installation_id + } + #[must_use] pub const fn reauthorization(&self) -> Option<&ProviderAccountId> { self.reauthorization.as_ref() @@ -255,6 +269,7 @@ impl fmt::Debug for CodexPendingAuthorization { .field("state", &"") .field("nonce", &"") .field("code_verifier", &"") + .field("installation_id", &"") .field("reauthorization", &self.reauthorization) .field("mutation", &self.mutation) .finish() @@ -350,6 +365,7 @@ pub struct CodexOAuthAdminService { store: Arc, credentials: CodexCredentialAdmin, oauth_client_id: String, + profile: CodexWireProfileState, } struct CodexOAuthAuthorizationCommitGuard { @@ -399,6 +415,7 @@ impl CodexOAuthAdminService { exchanger: Arc, store: Arc, credentials: CodexCredentialAdmin, + profile: CodexWireProfileState, ) -> Self { Self { pending, @@ -406,6 +423,7 @@ impl CodexOAuthAdminService { store, credentials, oauth_client_id: OFFICIAL_CODEX_OAUTH_CLIENT_ID.to_owned(), + profile, } } @@ -422,8 +440,10 @@ impl CodexOAuthAdmin for CodexOAuthAdminService { &self, command: StartCodexOAuthAuthorization, ) -> Result { - let (name, reauthorization) = match command.mutation.target() { - AuthorizationMutationTarget::Create { name } => (name.clone(), None), + let (name, reauthorization, installation_id) = match command.mutation.target() { + AuthorizationMutationTarget::Create { name } => { + (name.clone(), None, Uuid::new_v4().to_string()) + } AuthorizationMutationTarget::Reauthorize { account_id } => { let current = self .store @@ -435,10 +455,15 @@ impl CodexOAuthAdmin for CodexOAuthAdminService { { return Err(CodexOAuthAdminError::NotFound); } - (current.account.name().to_owned(), Some(account_id.clone())) + let installation_id = current_installation_id(¤t)?; + ( + current.account.name().to_owned(), + Some(account_id.clone()), + installation_id, + ) } }; - self.start_pending(command.mutation, name, reauthorization) + self.start_pending(command.mutation, name, reauthorization, installation_id) .await } @@ -490,6 +515,9 @@ impl CodexOAuthAdminService { .load_current_credential(target) .await .map_err(map_store_error)?; + if current_installation_id(¤t)? != pending.installation_id() { + return Err(CodexOAuthAdminError::Conflict); + } Some(current) } else { None @@ -527,6 +555,7 @@ impl CodexOAuthAdminService { .prepare_unresolved_oauth(UnresolvedCodexOAuthCredential { account_id, name: pending.name, + installation_id: pending.installation_id, secret, metadata, access_token_expires_at, @@ -546,6 +575,7 @@ impl CodexOAuthAdminService { mutation: PendingAuthorizationMutation, name: String, reauthorization: Option, + installation_id: String, ) -> Result { if !valid_text(&name) { return Err(CodexOAuthAdminError::InvalidInput); @@ -564,17 +594,21 @@ impl CodexOAuthAdminService { state: SecretString::from(random_secret()?), nonce: SecretString::from(random_secret()?), code_verifier: SecretString::from(random_secret()?), + installation_id, reauthorization_account_id: reauthorization.as_ref().map(ToString::to_string), mutation, }) .map_err(map_pending_error)?; + let profile = self.profile.snapshot(); + let authorization_url = + authorization_url(&pending, &self.oauth_client_id, &profile.desktop_version)?; self.pending .create(&pending) .await .map_err(map_pending_error)?; Ok(CodexOAuthAuthorizationStarted { flow_id: pending.flow_id.clone(), - authorization_url: authorization_url(&pending, &self.oauth_client_id)?, + authorization_url, expires_at, }) } @@ -690,31 +724,70 @@ fn current_oauth_refresh_token( .and_then(|oauth| oauth.refresh_token.clone())) } +fn current_installation_id(current: &LoadedCredential) -> Result { + let runtime = CodexCredentialCodec::decode(¤t.credential) + .map_err(|_| CodexOAuthAdminError::Credential)?; + if runtime.authentication.oauth().is_none() { + return Err(CodexOAuthAdminError::Credential); + } + Ok(runtime.installation_id) +} + +fn derive_surface_stable_id(installation_id: &str) -> Result { + let installation_id = + Uuid::parse_str(installation_id).map_err(|_| CodexOAuthAdminError::Credential)?; + let mut digest = Sha256::new(); + digest.update(SURFACE_STABLE_ID_DOMAIN); + digest.update(installation_id.as_bytes()); + let digest = digest.finalize(); + let mut bytes = [0_u8; 16]; + bytes.copy_from_slice(&digest[..16]); + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + Ok(Uuid::from_bytes(bytes).hyphenated().to_string()) +} + fn authorization_url( pending: &CodexPendingAuthorization, oauth_client_id: &str, + desktop_version: &str, ) -> Result { - let mut url = + if !valid_text(desktop_version) { + return Err(CodexOAuthAdminError::InvalidInput); + } + let mut inner = Url::parse(AUTHORIZATION_ENDPOINT).map_err(|_| CodexOAuthAdminError::InvalidInput)?; let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest( pending.code_verifier.expose_secret().as_bytes(), )); - let query = [ - ("response_type", "code"), - ("client_id", oauth_client_id), - ("redirect_uri", OFFICIAL_CODEX_REDIRECT_URI), - ("scope", AUTHORIZATION_SCOPE), - ("code_challenge", challenge.as_str()), - ("code_challenge_method", "S256"), - ("id_token_add_organizations", "true"), - ("codex_cli_simplified_flow", "true"), - ("state", pending.state.expose_secret()), - ("originator", AUTHORIZATION_ORIGINATOR), - ] - .map(|(name, value)| format!("{name}={}", urlencoding::encode(value))) - .join("&"); - url.set_query(Some(&query)); - Ok(url.into()) + let stable_id = derive_surface_stable_id(pending.installation_id())?; + { + let mut query = inner.query_pairs_mut(); + query + .append_pair("response_type", "code") + .append_pair("client_id", oauth_client_id) + .append_pair("redirect_uri", OFFICIAL_CODEX_REDIRECT_URI) + .append_pair("scope", AUTHORIZATION_SCOPE) + .append_pair("code_challenge", challenge.as_str()) + .append_pair("code_challenge_method", "S256") + .append_pair("id_token_add_organizations", "true") + .append_pair("codex_cli_simplified_flow", "true") + .append_pair("state", pending.state.expose_secret()) + .append_pair("originator", AUTHORIZATION_ORIGINATOR) + .append_pair("codex_app_version", desktop_version) + .append_pair("source_surface_stable_id", &stable_id) + .append_pair("codex_origin_stable_id", &stable_id) + .append_pair(STREAMLINED_LOGIN_QUERY, "true"); + } + + let mut outer = + Url::parse(DESKTOP_AUTH_ENDPOINT).map_err(|_| CodexOAuthAdminError::InvalidInput)?; + outer + .query_pairs_mut() + .append_pair("authorize_url", inner.as_str()) + .append_pair(STREAMLINED_LOGIN_QUERY, "true") + .append_pair(NO_UNIVERSAL_LINKS_QUERY, "1"); + Ok(outer.into()) } fn callback_parts(value: &str) -> Result<(SecretString, SecretString), CodexOAuthAdminError> { @@ -766,6 +839,12 @@ fn valid_secret(value: &str) -> bool { valid_text(value) && value.len() >= 16 } +fn valid_installation_id(value: &str) -> bool { + Uuid::parse_str(value) + .ok() + .is_some_and(|uuid| uuid.get_version_num() == 4) +} + pub(crate) fn oauth_owner_ref(owner: &AuthorizationOwner) -> String { let mut digest = Sha256::new(); match owner { diff --git a/backend/crates/providers/openai/src/credential/security.rs b/backend/crates/providers/openai/src/credential/security.rs index 9805f1dd7..4f9b28389 100644 --- a/backend/crates/providers/openai/src/credential/security.rs +++ b/backend/crates/providers/openai/src/credential/security.rs @@ -108,6 +108,7 @@ impl CodexCredentialCodec { oauth_subject: account.oauth_subject.clone(), poid: account.poid.clone(), }), + uuid::Uuid::new_v4().to_string(), cookies, ) } @@ -115,20 +116,22 @@ impl CodexCredentialCodec { /// 为尚未解析资料的 OAuth 账号编码凭据。 pub(crate) fn encode_unresolved( secret: &CodexOAuthSecret, + installation_id: String, cookies: Vec, ) -> Result { - Self::encode_oauth(secret, None, cookies) + Self::encode_oauth(secret, None, installation_id, cookies) } fn encode_oauth( secret: &CodexOAuthSecret, principal: Option, + installation_id: String, cookies: Vec, ) -> Result { Self::encode_complete(CodexCredentialData::OAuth(CodexOAuthCredentialData { schema_version: CODEX_CREDENTIAL_SCHEMA_VERSION, principal, - installation_id: uuid::Uuid::new_v4().to_string(), + installation_id, access_token: secret.access_token.expose_secret().to_owned(), refresh_token: secret .refresh_token diff --git a/backend/crates/providers/openai/src/lib.rs b/backend/crates/providers/openai/src/lib.rs index 2d65dd2a2..ce53c64e6 100644 --- a/backend/crates/providers/openai/src/lib.rs +++ b/backend/crates/providers/openai/src/lib.rs @@ -176,6 +176,7 @@ pub async fn initialize( exchanger, Arc::clone(&accounts), CodexCredentialAdmin, + profile.clone(), ) .with_oauth_client_id(config.oauth_client_id()), ); diff --git a/backend/crates/providers/openai/tests/admin.rs b/backend/crates/providers/openai/tests/admin.rs index 688897230..c7977cbf9 100644 --- a/backend/crates/providers/openai/tests/admin.rs +++ b/backend/crates/providers/openai/tests/admin.rs @@ -33,7 +33,7 @@ use gateway_core::provider_ports::{ use gateway_core::routing::{ProviderKind, UpstreamModelId}; use gateway_core::task::{WorkerContribution, WorkerKind, WorkerRunnable}; use provider_openai::config::{CodexWireProfileConfig, OpenAiConfig}; -use provider_openai::credential::ImportCodexOAuthCredential; +use provider_openai::credential::{CodexCredentialCodec, ImportCodexOAuthCredential}; use provider_openai::transport::profile::APPCAST_POLL_INTERVAL; use secrecy::SecretString; use serde_json::{Map, Value, json}; @@ -233,6 +233,14 @@ async fn openai_admin_provider_persists_the_full_pending_envelope_and_binds_owne .get("reauthorization_credential_revision") .is_none() ); + assert!( + payload + .expose_to_provider() + .get("installation_id") + .and_then(Value::as_str) + .and_then(|value| uuid::Uuid::parse_str(value).ok()) + .is_some_and(|value| value.get_version_num() == 4) + ); assert_eq!( mutation.get("schema_version").and_then(Value::as_u64), Some(3) @@ -268,7 +276,7 @@ async fn openai_admin_provider_persists_the_full_pending_envelope_and_binds_owne } #[tokio::test] -async fn openai_reauthorization_pending_payload_contains_only_stable_account_identity() { +async fn openai_reauthorization_pending_payload_reuses_the_account_installation_id() { let accounts = Arc::new(MemoryAccountStore::default()); accounts .seed_oauth_credential(ImportCodexOAuthCredential { @@ -280,6 +288,14 @@ async fn openai_reauthorization_pending_payload_contains_only_stable_account_ide enabled: true, }) .await; + let account_id = ProviderAccountId::new("acct_pending_reauth").expect("account id"); + let existing = accounts + .load_current_credential(&account_id) + .await + .expect("seeded credential"); + let expected_installation_id = CodexCredentialCodec::decode(&existing.credential) + .expect("decode seeded credential") + .installation_id; let pending = Arc::new(TestOAuthPending::default()); let config = valid_config(); let bundle = provider_openai::initialize( @@ -297,9 +313,7 @@ async fn openai_reauthorization_pending_payload_contains_only_stable_account_ide .admin_provider() .start_authorization(PendingAuthorizationMutation::new( ProviderKind::new("openai").expect("provider"), - AuthorizationMutationTarget::Reauthorize { - account_id: ProviderAccountId::new("acct_pending_reauth").expect("account id"), - }, + AuthorizationMutationTarget::Reauthorize { account_id }, AuthorizationOwnerBinding::from_context(&context), )) .await @@ -326,6 +340,10 @@ async fn openai_reauthorization_pending_payload_contains_only_stable_account_ide .and_then(Value::as_str), Some("acct_pending_reauth") ); + assert_eq!( + document.get("installation_id").and_then(Value::as_str), + Some(expected_installation_id.as_str()) + ); assert!( document .get("reauthorization_credential_revision") diff --git a/backend/crates/providers/openai/tests/credential/oauth.rs b/backend/crates/providers/openai/tests/credential/oauth.rs index bfb50beed..f3bb75c23 100644 --- a/backend/crates/providers/openai/tests/credential/oauth.rs +++ b/backend/crates/providers/openai/tests/credential/oauth.rs @@ -8,22 +8,26 @@ use gateway_admin::model::provider_credentials::{ AuthorizationMutationTarget, AuthorizationOwnerBinding, PendingAuthorizationMutation, }; use gateway_admin::model::{MutationActor, MutationContext}; -use gateway_core::engine::credential::AccountStatus; +use gateway_core::engine::credential::{AccountStatus, ProviderAccountId}; use gateway_core::routing::ProviderKind; +use provider_openai::OpenAiConfig; use provider_openai::credential::token_client::{ AuthorizationCodeExchangeError, AuthorizationCodeExchanger, AuthorizationCodeGrant, AuthorizationTokenSet, }; use provider_openai::credential::{ - CodexCredentialAdmin, CodexOAuthAdmin, CodexOAuthAdminError, CodexOAuthAdminService, - CodexOAuthPendingClaimOutcome, CodexOAuthPendingStore, CodexOAuthPendingStoreError, - CodexOAuthSecret, CompleteCodexOAuthAuthorization, CompletedCodexOAuthCredential, - StartCodexOAuthAuthorization, StoredCodexPendingAuthorization, + CodexCredentialAdmin, CodexCredentialCodec, CodexOAuthAdmin, CodexOAuthAdminError, + CodexOAuthAdminService, CodexOAuthPendingClaimOutcome, CodexOAuthPendingStore, + CodexOAuthPendingStoreError, CodexOAuthSecret, CompleteCodexOAuthAuthorization, + CompletedCodexOAuthCredential, ImportCodexOAuthCredential, StartCodexOAuthAuthorization, + StoredCodexPendingAuthorization, }; +use provider_openai::transport::profile::CodexWireProfileState; use secrecy::SecretString; use url::Url; +use uuid::Uuid; -use crate::support::MemoryAccountStore; +use crate::support::{MemoryAccountStore, profile as account_profile, secret}; #[derive(Default)] struct PendingStore { @@ -49,6 +53,7 @@ impl CodexOAuthPendingStore for PendingStore { state: pending.state().clone(), nonce: pending.nonce().clone(), code_verifier: pending.code_verifier().clone(), + installation_id: pending.installation_id().to_owned(), reauthorization_account_id: pending.reauthorization().map(ToString::to_string), mutation: pending.mutation().clone(), }); @@ -126,18 +131,37 @@ fn mutation() -> PendingAuthorizationMutation { ) } +fn reauthorization_mutation( + account_id: ProviderAccountId, + request_id: &str, +) -> PendingAuthorizationMutation { + let context = MutationContext { + actor: MutationActor::System, + request_id: request_id.to_owned(), + }; + PendingAuthorizationMutation::new( + ProviderKind::new("openai").expect("provider"), + AuthorizationMutationTarget::Reauthorize { account_id }, + AuthorizationOwnerBinding::from_context(&context), + ) +} + fn id_token(payload: serde_json::Value) -> String { let payload = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).expect("payload JSON")); // 官方逻辑只读取 payload;header/signature 不参与本地 metadata 解析。 format!("unverified-header.{payload}.unverified-signature") } -async fn complete(id_token: String) -> Result { +async fn complete( + id_token: String, +) -> Result<(CompletedCodexOAuthCredential, String, String), CodexOAuthAdminError> { + let pending_store = Arc::new(PendingStore::default()); let service = CodexOAuthAdminService::new( - Arc::new(PendingStore::default()), + pending_store.clone(), Arc::new(Exchanger { id_token }), Arc::new(MemoryAccountStore::default()), CodexCredentialAdmin, + profile(), ); let started = service .start_authorization(StartCodexOAuthAuthorization { @@ -145,11 +169,26 @@ async fn complete(id_token: String) -> Result>(); + let state = parameters.get("state").expect("state parameter"); + let surface_stable_id = parameters + .get("source_surface_stable_id") + .expect("surface stable ID") + .to_owned(); + let installation_id = pending_store + .pending + .lock() + .expect("pending lock") + .as_ref() + .expect("stored pending authorization") + .installation_id + .clone(); let completed = service .complete_authorization(CompleteCodexOAuthAuthorization { owner_ref: "test-owner".to_owned(), @@ -159,7 +198,7 @@ async fn complete(id_token: String) -> Result>(); + let outer_parameters = url.query_pairs().into_owned().collect::>(); + assert_eq!( + outer_parameters + .iter() + .map(|(name, _)| name.as_str()) + .collect::>(), + vec![ + "authorize_url", + "codex_streamlined_login", + "no_universal_links" + ] + ); + let outer_parameters = outer_parameters.into_iter().collect::>(); + + assert_eq!(url.scheme(), "https"); + assert_eq!(url.host_str(), Some("chatgpt.com")); + assert_eq!(url.path(), "/codex/desktop-auth"); + assert_eq!(outer_parameters.len(), 3); + assert_eq!( + outer_parameters + .get("codex_streamlined_login") + .map(String::as_str), + Some("true") + ); + assert_eq!( + outer_parameters + .get("no_universal_links") + .map(String::as_str), + Some("1") + ); + let inner = Url::parse( + outer_parameters + .get("authorize_url") + .expect("authorize_url parameter"), + ) + .expect("inner authorization URL"); + let ordered_parameters = inner.query_pairs().into_owned().collect::>(); assert_eq!( ordered_parameters .iter() @@ -196,19 +272,27 @@ async fn authorize_url_matches_the_official_desktop_parameter_contract() { "codex_cli_simplified_flow", "state", "originator", + "codex_app_version", + "source_surface_stable_id", + "codex_origin_stable_id", + "codex_streamlined_login", ] ); let parameters = ordered_parameters.into_iter().collect::>(); - assert_eq!(url.scheme(), "https"); - assert_eq!(url.host_str(), Some("auth.openai.com")); - assert_eq!(url.path(), "/oauth/authorize"); - assert_eq!(parameters.len(), 10); + assert_eq!(inner.host_str(), Some("auth.openai.com")); + assert_eq!(inner.path(), "/oauth/authorize"); + assert_eq!(parameters.len(), 14); assert!( - url.query() - .is_some_and(|query| query.contains("scope=openid%20profile%20email%20offline_access")) + inner + .query() + .is_some_and(|query| query.contains("scope=openid+profile+email+offline_access")) + ); + assert!( + !inner + .query() + .is_some_and(|query| query.contains("scope=openid%20profile")) ); - assert!(!url.query().is_some_and(|query| query.contains('+'))); assert_eq!( parameters.get("response_type").map(String::as_str), Some("code") @@ -225,6 +309,25 @@ async fn authorize_url_matches_the_official_desktop_parameter_contract() { parameters.get("originator").map(String::as_str), Some("Codex Desktop") ); + assert_eq!( + parameters.get("codex_app_version").map(String::as_str), + Some("26.803.81509") + ); + let surface_stable_id = parameters + .get("source_surface_stable_id") + .expect("surface stable ID"); + let parsed_surface_id = Uuid::parse_str(surface_stable_id).expect("UUID surface stable ID"); + assert_eq!(parsed_surface_id.get_version_num(), 4); + assert_eq!( + parameters.get("codex_origin_stable_id"), + Some(surface_stable_id) + ); + assert_eq!( + parameters + .get("codex_streamlined_login") + .map(String::as_str), + Some("true") + ); assert_eq!( parameters.get("code_challenge_method").map(String::as_str), Some("S256") @@ -259,9 +362,115 @@ async fn authorize_url_matches_the_official_desktop_parameter_contract() { assert!(!parameters.contains_key("nonce")); } +fn profile() -> CodexWireProfileState { + OpenAiConfig::default().wire_profile_state() +} + +#[tokio::test] +async fn separate_new_authorizations_use_distinct_surface_stable_ids() { + let mut ids = Vec::new(); + for _ in 0..2 { + let service = CodexOAuthAdminService::new( + Arc::new(PendingStore::default()), + Arc::new(Exchanger { + id_token: "unused".to_owned(), + }), + Arc::new(MemoryAccountStore::default()), + CodexCredentialAdmin, + profile(), + ); + let started = service + .start_authorization(StartCodexOAuthAuthorization { + mutation: mutation(), + }) + .await + .expect("start OAuth authorization"); + let outer = Url::parse(&started.authorization_url).expect("outer authorization URL"); + let inner = outer + .query_pairs() + .find_map(|(key, value)| (key == "authorize_url").then(|| value.into_owned())) + .and_then(|value| Url::parse(&value).ok()) + .expect("inner authorization URL"); + let surface_stable_id = inner + .query_pairs() + .find_map(|(key, value)| { + (key == "source_surface_stable_id").then(|| value.into_owned()) + }) + .expect("surface stable ID"); + ids.push(surface_stable_id); + } + assert_ne!(ids[0], ids[1]); +} + +#[tokio::test] +async fn repeated_reauthorization_derives_the_same_surface_id_for_one_account() { + let store = Arc::new(MemoryAccountStore::default()); + store + .seed_oauth_credential(ImportCodexOAuthCredential { + account_id: "acct_surface_reauth".to_owned(), + name: "surface reauthorization".to_owned(), + secret: secret("surface-reauth-access"), + verified_account: account_profile("chatgpt-surface-reauth"), + next_refresh_at: None, + enabled: true, + }) + .await; + let account_id = ProviderAccountId::new("acct_surface_reauth").expect("account id"); + let mut surface_ids = Vec::new(); + for request_id in ["surface-reauth-first", "surface-reauth-second"] { + let service = CodexOAuthAdminService::new( + Arc::new(PendingStore::default()), + Arc::new(Exchanger { + id_token: "unused".to_owned(), + }), + store.clone(), + CodexCredentialAdmin, + profile(), + ); + let started = service + .start_authorization(StartCodexOAuthAuthorization { + mutation: reauthorization_mutation(account_id.clone(), request_id), + }) + .await + .expect("start OAuth reauthorization"); + let outer = Url::parse(&started.authorization_url).expect("outer authorization URL"); + let inner = outer + .query_pairs() + .find_map(|(key, value)| (key == "authorize_url").then(|| value.into_owned())) + .and_then(|value| Url::parse(&value).ok()) + .expect("inner authorization URL"); + surface_ids.push( + inner + .query_pairs() + .find_map(|(key, value)| { + (key == "source_surface_stable_id").then(|| value.into_owned()) + }) + .expect("surface stable ID"), + ); + } + + assert_eq!(surface_ids[0], surface_ids[1]); +} + +#[tokio::test] +async fn first_exchange_persists_the_installation_id_without_reusing_it_as_the_surface_id() { + let (credential, pending_installation_id, surface_stable_id) = complete(id_token( + serde_json::json!({ "email": "identity@example.com" }), + )) + .await + .expect("completed OAuth exchange"); + let CompletedCodexOAuthCredential::Create(account) = credential else { + panic!("expected account creation"); + }; + let runtime = CodexCredentialCodec::decode(&account.credential).expect("stored credential"); + + assert_eq!(runtime.installation_id, pending_installation_id); + assert_ne!(runtime.installation_id, surface_stable_id); +} + #[tokio::test] async fn first_exchange_uses_official_id_token_claim_mapping_without_signature_validation() { - let credential = complete(id_token(serde_json::json!({ + let (credential, _, _) = complete(id_token(serde_json::json!({ "email": "top@example.com", "https://api.openai.com/profile": { "email": "fallback@example.com" }, "https://api.openai.com/auth": { @@ -296,7 +505,7 @@ async fn first_exchange_uses_official_id_token_claim_mapping_without_signature_v #[tokio::test] async fn first_exchange_permits_missing_identity_claims_but_requires_a_parseable_id_token() { - let credential = complete(id_token(serde_json::json!({ + let (credential, _, _) = complete(id_token(serde_json::json!({ "https://api.openai.com/profile": { "email": "profile@example.com" } }))) .await diff --git a/docs/architecture.md b/docs/architecture.md index c94369997..c853b7cbd 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -148,7 +148,16 @@ OpenAI OAuth 导入先取得 access token(仅有 refresh token 时先交换) 由 `accessToken` 补齐。解析器只读取 `email`、`https://api.openai.com/profile` 与 `https://api.openai.com/auth` 中的官方 claims,不调用 `whoami`,也不使用导入文档顶层的 `userId/accountId` 补身份。两个令牌都没有用户身份 claims 时,账号以未验证错误状态入库。首次 OAuth -在服务端 `state`、PKCE 与官方 token exchange 成功后解析并保存同一 token set。重新授权的目标绑定 +返回与当前 Codex Desktop 一致的两层登录地址:内层使用 `auth.openai.com/oauth/authorize` 的 +官方参数顺序,外层使用 `https://chatgpt.com/codex/desktop-auth` 并开启 +`codex_streamlined_login`。`codex_app_version` 每次从共享 Desktop 画像快照读取; +首次授权在 10 分钟 pending flow 中生成账号级 `installation_id`,换码成功后把同一个值写入 credential。 +`source_surface_stable_id` 与 `codex_origin_stable_id` 由该 ID 经过带版本域的 SHA-256 确定性派生,并映射为 +UUIDv4;重新授权读取既有 `installation_id`,因此得到相同的 surface ID。系统不额外持久化 surface ID, +也不再从全局 HMAC 身份锚点派生;它不等同于 DeviceCheck attestation。为了对齐 macOS Desktop +交给外部浏览器的最终 `chatgpt.com` URL,Provider 同样携带 `no_universal_links=1` 浏览器路由参数。 +服务端随后仍以 `state`、PKCE +与官方 token exchange 成功后解析并保存同一 token set。重新授权的目标绑定 只保存目标账号 ID,不接受或冻结客户端提供的 credential revision;complete 阶段重新读取目标账号及其当前 revision,并以当前 revision 做最终 CAS。 重新授权和手工或后台 RT refresh 只轮换目标账号的 token,保留既有账号资料与 OAuth principal。