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