fix(openai): align desktop oauth identity

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-08-14 17:43:07 +08:00
co-authored by Codex
parent ee51a07de4
commit 482e192e2d
8 changed files with 387 additions and 55 deletions
@@ -1011,6 +1011,7 @@ struct PendingDocument {
state: String,
nonce: String,
code_verifier: String,
installation_id: String,
reauthorization_account_id: Option<String>,
mutation: PendingMutationDocument,
}
@@ -1071,6 +1072,10 @@ fn encode_pending(pending: &CodexPendingAuthorization) -> Map<String, Value> {
"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)?,
})
@@ -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<DateTime<Utc>>,
@@ -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", &"<pseudonymous>")
.field("secret", &"<redacted>")
.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,
@@ -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<ProviderAccountId>,
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<String>,
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", &"<redacted>")
.field("nonce", &"<redacted>")
.field("code_verifier", &"<redacted>")
.field("installation_id", &"<pseudonymous>")
.field("reauthorization", &self.reauthorization)
.field("mutation", &self.mutation)
.finish()
@@ -350,6 +365,7 @@ pub struct CodexOAuthAdminService {
store: Arc<dyn ProviderAccountStore>,
credentials: CodexCredentialAdmin,
oauth_client_id: String,
profile: CodexWireProfileState,
}
struct CodexOAuthAuthorizationCommitGuard {
@@ -399,6 +415,7 @@ impl CodexOAuthAdminService {
exchanger: Arc<dyn AuthorizationCodeExchanger>,
store: Arc<dyn ProviderAccountStore>,
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<CodexOAuthAuthorizationStarted, CodexOAuthAdminError> {
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(&current)?;
(
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(&current)? != 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<ProviderAccountId>,
installation_id: String,
) -> Result<CodexOAuthAuthorizationStarted, CodexOAuthAdminError> {
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<String, CodexOAuthAdminError> {
let runtime = CodexCredentialCodec::decode(&current.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<String, CodexOAuthAdminError> {
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<String, CodexOAuthAdminError> {
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 {
@@ -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<CodexCookie>,
) -> Result<PlaintextCredential, CodexCredentialDataError> {
Self::encode_oauth(secret, None, cookies)
Self::encode_oauth(secret, None, installation_id, cookies)
}
fn encode_oauth(
secret: &CodexOAuthSecret,
principal: Option<CodexCredentialPrincipal>,
installation_id: String,
cookies: Vec<CodexCookie>,
) -> Result<PlaintextCredential, CodexCredentialDataError> {
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
@@ -176,6 +176,7 @@ pub async fn initialize(
exchanger,
Arc::clone(&accounts),
CodexCredentialAdmin,
profile.clone(),
)
.with_oauth_client_id(config.oauth_client_id()),
);
+23 -5
View File
@@ -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")
@@ -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<CompletedCodexOAuthCredential, CodexOAuthAdminError> {
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<CompletedCodexOAuthCredential, Cod
})
.await
.expect("start OAuth authorization");
let state = Url::parse(&started.authorization_url)
.expect("authorization URL")
let outer = Url::parse(&started.authorization_url).expect("authorization URL");
let inner_url = outer
.query_pairs()
.find_map(|(key, value)| (key == "state").then(|| value.into_owned()))
.expect("state parameter");
.find_map(|(key, value)| (key == "authorize_url").then(|| value.into_owned()))
.expect("inner authorization URL");
let inner = Url::parse(&inner_url).expect("authorization URL");
let parameters = inner.query_pairs().into_owned().collect::<BTreeMap<_, _>>();
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<CompletedCodexOAuthCredential, Cod
)),
})
.await?;
Ok(completed.credential)
Ok((completed.credential, installation_id, surface_stable_id))
}
#[tokio::test]
@@ -171,6 +210,7 @@ async fn authorize_url_matches_the_official_desktop_parameter_contract() {
}),
Arc::new(MemoryAccountStore::default()),
CodexCredentialAdmin,
profile(),
);
let started = service
.start_authorization(StartCodexOAuthAuthorization {
@@ -179,7 +219,43 @@ async fn authorize_url_matches_the_official_desktop_parameter_contract() {
.await
.expect("start OAuth authorization");
let url = Url::parse(&started.authorization_url).expect("authorization URL");
let ordered_parameters = url.query_pairs().into_owned().collect::<Vec<_>>();
let outer_parameters = url.query_pairs().into_owned().collect::<Vec<_>>();
assert_eq!(
outer_parameters
.iter()
.map(|(name, _)| name.as_str())
.collect::<Vec<_>>(),
vec![
"authorize_url",
"codex_streamlined_login",
"no_universal_links"
]
);
let outer_parameters = outer_parameters.into_iter().collect::<BTreeMap<_, _>>();
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::<Vec<_>>();
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::<BTreeMap<_, _>>();
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
+10 -1
View File
@@ -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。