mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-08 02:17:46 +08:00
feat(codex): stabilize identity across retries
This commit is contained in:
@@ -17,6 +17,35 @@ use super::claude_code::{
|
||||
CLAUDE_CODE_PROVIDER_TYPE, CLAUDE_CODE_REDIRECT_URI, CLAUDE_CODE_TOKEN_URL,
|
||||
};
|
||||
|
||||
const CODEX_IDENTITY_FINGERPRINT_FIELD: &str = "codex_identity_fingerprint";
|
||||
const CODEX_IDENTITY_FINGERPRINT_VERSION: &str = "codex-persisted-fingerprint:v1";
|
||||
|
||||
pub fn derive_codex_identity_fingerprint(
|
||||
account_id: Option<&str>,
|
||||
account_user_id: Option<&str>,
|
||||
user_id: Option<&str>,
|
||||
email: Option<&str>,
|
||||
) -> Option<String> {
|
||||
let account = normalized_codex_identity_value(account_id);
|
||||
let member = normalized_codex_identity_value(account_user_id)
|
||||
.or_else(|| normalized_codex_identity_value(user_id))
|
||||
.or_else(|| normalized_codex_identity_value(email))?;
|
||||
|
||||
let mut digest = Sha256::new();
|
||||
digest.update(CODEX_IDENTITY_FINGERPRINT_VERSION.as_bytes());
|
||||
digest.update([0]);
|
||||
digest.update(account.as_deref().unwrap_or("").as_bytes());
|
||||
digest.update([0]);
|
||||
digest.update(member.as_bytes());
|
||||
let digest = digest.finalize();
|
||||
let mut encoded = String::with_capacity(digest.len() * 2);
|
||||
for byte in digest {
|
||||
use std::fmt::Write as _;
|
||||
let _ = write!(&mut encoded, "{byte:02x}");
|
||||
}
|
||||
Some(format!("{CODEX_IDENTITY_FINGERPRINT_VERSION}:{encoded}"))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct GenericProviderOAuthTemplate {
|
||||
pub provider_type: &'static str,
|
||||
@@ -287,6 +316,7 @@ impl GenericProviderOAuthAdapter {
|
||||
auth_config.insert("scope".to_string(), json!(scope));
|
||||
}
|
||||
enrich_generic_identity(self.template.provider_type, &mut auth_config, &payload);
|
||||
ensure_codex_identity_fingerprint(self.template.provider_type, &mut auth_config);
|
||||
Ok(ProviderOAuthTokenSet {
|
||||
token_set,
|
||||
auth_config: Value::Object(auth_config),
|
||||
@@ -383,6 +413,16 @@ impl ProviderOAuthAdapter for GenericProviderOAuthAdapter {
|
||||
let mut refreshed = self
|
||||
.exchange_grant(executor, ctx, "refresh_token", refresh_token, None, None)
|
||||
.await?;
|
||||
let existing_codex_identity_fingerprint = self
|
||||
.template
|
||||
.provider_type
|
||||
.eq_ignore_ascii_case("codex")
|
||||
.then(|| {
|
||||
codex_identity_fingerprint_value(&account.auth_config).or_else(|| {
|
||||
derive_codex_identity_fingerprint_from_auth_config(&account.auth_config)
|
||||
})
|
||||
})
|
||||
.flatten();
|
||||
|
||||
// Refresh responses often omit stable account metadata, and some providers
|
||||
// do not rotate refresh_token on every refresh. Preserve the stored config
|
||||
@@ -398,6 +438,13 @@ impl ProviderOAuthAdapter for GenericProviderOAuthAdapter {
|
||||
refreshed.token_set.refresh_token = Some(refresh_token.to_string());
|
||||
merged.insert("refresh_token".to_string(), json!(refresh_token));
|
||||
}
|
||||
if let Some(fingerprint) = existing_codex_identity_fingerprint {
|
||||
merged.insert(
|
||||
CODEX_IDENTITY_FINGERPRINT_FIELD.to_string(),
|
||||
Value::String(fingerprint),
|
||||
);
|
||||
}
|
||||
ensure_codex_identity_fingerprint(self.template.provider_type, &mut merged);
|
||||
refreshed.auth_config = Value::Object(merged);
|
||||
}
|
||||
Ok(refreshed)
|
||||
@@ -411,6 +458,11 @@ impl ProviderOAuthAdapter for GenericProviderOAuthAdapter {
|
||||
}
|
||||
|
||||
fn account_fingerprint(&self, account: &ProviderOAuthAccount) -> Option<String> {
|
||||
if self.template.provider_type.eq_ignore_ascii_case("codex") {
|
||||
return codex_identity_fingerprint_value(&account.auth_config).or_else(|| {
|
||||
derive_codex_identity_fingerprint_from_auth_config(&account.auth_config)
|
||||
});
|
||||
}
|
||||
let refresh_token = account
|
||||
.auth_config
|
||||
.get("refresh_token")
|
||||
@@ -472,6 +524,89 @@ fn secret_fingerprint(value: &str) -> String {
|
||||
fingerprint
|
||||
}
|
||||
|
||||
fn codex_identity_fingerprint_value(auth_config: &Value) -> Option<String> {
|
||||
[
|
||||
CODEX_IDENTITY_FINGERPRINT_FIELD,
|
||||
"codex-identity-fingerprint",
|
||||
"codexIdentityFingerprint",
|
||||
]
|
||||
.iter()
|
||||
.find_map(|field| auth_config.get(*field).and_then(Value::as_str))
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn codex_identity_claim(auth_config: &Value, fields: &[&str]) -> Option<String> {
|
||||
fields
|
||||
.iter()
|
||||
.find_map(|field| auth_config.get(*field).and_then(Value::as_str))
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_ascii_lowercase)
|
||||
}
|
||||
|
||||
fn normalized_codex_identity_value(value: Option<&str>) -> Option<String> {
|
||||
value
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_ascii_lowercase)
|
||||
}
|
||||
|
||||
fn derive_codex_identity_fingerprint_from_auth_config(auth_config: &Value) -> Option<String> {
|
||||
let account = codex_identity_claim(
|
||||
auth_config,
|
||||
&[
|
||||
"account_id",
|
||||
"accountId",
|
||||
"chatgpt_account_id",
|
||||
"chatgptAccountId",
|
||||
],
|
||||
);
|
||||
let account_user = codex_identity_claim(
|
||||
auth_config,
|
||||
&[
|
||||
"account_user_id",
|
||||
"accountUserId",
|
||||
"chatgpt_account_user_id",
|
||||
"chatgptAccountUserId",
|
||||
],
|
||||
);
|
||||
let user = codex_identity_claim(
|
||||
auth_config,
|
||||
&["user_id", "userId", "chatgpt_user_id", "chatgptUserId"],
|
||||
);
|
||||
let email = codex_identity_claim(
|
||||
auth_config,
|
||||
&["email", "email_address", "emailAddress", "outlook_email"],
|
||||
);
|
||||
|
||||
derive_codex_identity_fingerprint(
|
||||
account.as_deref(),
|
||||
account_user.as_deref(),
|
||||
user.as_deref(),
|
||||
email.as_deref(),
|
||||
)
|
||||
}
|
||||
|
||||
fn ensure_codex_identity_fingerprint(
|
||||
provider_type: &str,
|
||||
auth_config: &mut serde_json::Map<String, Value>,
|
||||
) {
|
||||
if !provider_type.eq_ignore_ascii_case("codex") {
|
||||
return;
|
||||
}
|
||||
let auth_config_value = Value::Object(auth_config.clone());
|
||||
let fingerprint = codex_identity_fingerprint_value(&auth_config_value)
|
||||
.or_else(|| derive_codex_identity_fingerprint_from_auth_config(&auth_config_value));
|
||||
if let Some(fingerprint) = fingerprint {
|
||||
auth_config.insert(
|
||||
CODEX_IDENTITY_FINGERPRINT_FIELD.to_string(),
|
||||
Value::String(fingerprint),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn enrich_generic_identity(
|
||||
provider_type: &str,
|
||||
auth_config: &mut serde_json::Map<String, Value>,
|
||||
@@ -480,12 +615,21 @@ fn enrich_generic_identity(
|
||||
if let Some(object) = token_payload.as_object() {
|
||||
for field in [
|
||||
"email",
|
||||
"email_address",
|
||||
"emailAddress",
|
||||
"outlook_email",
|
||||
"account_id",
|
||||
"accountId",
|
||||
"account_user_id",
|
||||
"accountUserId",
|
||||
"plan_type",
|
||||
"user_id",
|
||||
"userId",
|
||||
"account_name",
|
||||
"is_fedramp",
|
||||
CODEX_IDENTITY_FINGERPRINT_FIELD,
|
||||
"codex-identity-fingerprint",
|
||||
"codexIdentityFingerprint",
|
||||
] {
|
||||
if !auth_config.contains_key(field) {
|
||||
if let Some(value) = object.get(field).cloned() {
|
||||
@@ -565,8 +709,11 @@ fn enrich_generic_identity(
|
||||
.get("https://api.openai.com/profile")
|
||||
.and_then(Value::as_object)
|
||||
{
|
||||
if let Some(value) = profile.get("email").cloned() {
|
||||
auth_config.entry("email".to_string()).or_insert(value);
|
||||
for field in ["email", "email_address", "emailAddress", "outlook_email"] {
|
||||
if let Some(value) = profile.get(field).cloned() {
|
||||
auth_config.entry("email".to_string()).or_insert(value);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -653,13 +800,16 @@ fn decode_jwt_claims(token: &str) -> Option<serde_json::Map<String, Value>> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{enrich_generic_identity, template_for_provider_type, GenericProviderOAuthAdapter};
|
||||
use super::{
|
||||
derive_codex_identity_fingerprint, enrich_generic_identity, template_for_provider_type,
|
||||
GenericProviderOAuthAdapter, CODEX_IDENTITY_FINGERPRINT_FIELD,
|
||||
};
|
||||
use crate::network::{OAuthHttpExecutor, OAuthHttpRequest, OAuthHttpResponse};
|
||||
use crate::provider::ProviderOAuthAdapter;
|
||||
use crate::provider::{ProviderOAuthAccount, ProviderOAuthTransportContext};
|
||||
use async_trait::async_trait;
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||||
use serde_json::json;
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
@@ -678,6 +828,13 @@ mod tests {
|
||||
assert!(adapter.capabilities().supports_refresh_token_import);
|
||||
}
|
||||
|
||||
fn encoded_jwt(claims: &Value) -> String {
|
||||
format!(
|
||||
"header.{}.signature",
|
||||
URL_SAFE_NO_PAD.encode(serde_json::to_vec(claims).expect("claims should encode"))
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_identity_extracts_fedramp_workspace_claim() {
|
||||
let claims = json!({
|
||||
@@ -686,10 +843,7 @@ mod tests {
|
||||
"chatgpt_account_is_fedramp": true
|
||||
}
|
||||
});
|
||||
let token = format!(
|
||||
"header.{}.signature",
|
||||
URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims).expect("claims should encode"))
|
||||
);
|
||||
let token = encoded_jwt(&claims);
|
||||
let mut auth_config = serde_json::Map::new();
|
||||
|
||||
enrich_generic_identity("codex", &mut auth_config, &json!({"access_token": token}));
|
||||
@@ -698,9 +852,74 @@ mod tests {
|
||||
assert_eq!(auth_config.get("is_fedramp"), Some(&json!(true)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_persisted_fingerprint_is_member_scoped_and_token_independent() {
|
||||
let adapter = GenericProviderOAuthAdapter::for_provider_type("codex")
|
||||
.expect("codex adapter should exist");
|
||||
let claims = json!({
|
||||
"sub": "global-user-1",
|
||||
"https://api.openai.com/auth": {
|
||||
"chatgpt_account_id": "workspace-1",
|
||||
"chatgpt_account_user_id": "member-1"
|
||||
}
|
||||
});
|
||||
let rotated_claims = json!({
|
||||
"sub": "global-user-1",
|
||||
"iat": 12345,
|
||||
"https://api.openai.com/auth": {
|
||||
"chatgpt_account_id": "WORKSPACE-1",
|
||||
"chatgpt_account_user_id": "MEMBER-1"
|
||||
}
|
||||
});
|
||||
let other_member_claims = json!({
|
||||
"sub": "global-user-2",
|
||||
"https://api.openai.com/auth": {
|
||||
"chatgpt_account_id": "workspace-1",
|
||||
"chatgpt_account_user_id": "member-2"
|
||||
}
|
||||
});
|
||||
|
||||
let first = adapter
|
||||
.token_set_from_payload(json!({"access_token": encoded_jwt(&claims)}))
|
||||
.expect("first token should parse");
|
||||
let rotated = adapter
|
||||
.token_set_from_payload(json!({"access_token": encoded_jwt(&rotated_claims)}))
|
||||
.expect("rotated token should parse");
|
||||
let other_member = adapter
|
||||
.token_set_from_payload(json!({"access_token": encoded_jwt(&other_member_claims)}))
|
||||
.expect("other member token should parse");
|
||||
|
||||
let first_fingerprint = first.auth_config[CODEX_IDENTITY_FINGERPRINT_FIELD]
|
||||
.as_str()
|
||||
.expect("persisted fingerprint")
|
||||
.to_string();
|
||||
assert!(first_fingerprint.starts_with("codex-persisted-fingerprint:v1:"));
|
||||
assert_eq!(
|
||||
rotated.auth_config[CODEX_IDENTITY_FINGERPRINT_FIELD].as_str(),
|
||||
Some(first_fingerprint.as_str())
|
||||
);
|
||||
assert_ne!(
|
||||
other_member.auth_config[CODEX_IDENTITY_FINGERPRINT_FIELD].as_str(),
|
||||
Some(first_fingerprint.as_str())
|
||||
);
|
||||
|
||||
let account = ProviderOAuthAccount {
|
||||
provider_type: "codex".to_string(),
|
||||
access_token: "unrelated-rotated-token".to_string(),
|
||||
auth_config: first.auth_config,
|
||||
expires_at_unix_secs: None,
|
||||
identity: BTreeMap::new(),
|
||||
};
|
||||
assert_eq!(
|
||||
adapter.account_fingerprint(&account).as_deref(),
|
||||
Some(first_fingerprint.as_str())
|
||||
);
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct StaticExecutor {
|
||||
seen_request: Arc<Mutex<Option<OAuthHttpRequest>>>,
|
||||
response_payload: Value,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -712,11 +931,7 @@ mod tests {
|
||||
*self.seen_request.lock().expect("mutex should lock") = Some(request);
|
||||
Ok(OAuthHttpResponse {
|
||||
status_code: 200,
|
||||
body_text: json!({
|
||||
"access_token": "new-access-token",
|
||||
"expires_in": 3600
|
||||
})
|
||||
.to_string(),
|
||||
body_text: self.response_payload.to_string(),
|
||||
json_body: None,
|
||||
})
|
||||
}
|
||||
@@ -725,12 +940,29 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn refresh_preserves_existing_metadata_when_refresh_token_is_not_rotated() {
|
||||
let seen_request = Arc::new(Mutex::new(None));
|
||||
let refreshed_token = encoded_jwt(&json!({
|
||||
"https://api.openai.com/auth": {
|
||||
"chatgpt_account_id": "acct-123",
|
||||
"chatgpt_account_user_id": "replacement-member"
|
||||
}
|
||||
}));
|
||||
let executor = StaticExecutor {
|
||||
seen_request: Arc::clone(&seen_request),
|
||||
response_payload: json!({
|
||||
"access_token": refreshed_token,
|
||||
"expires_in": 3600
|
||||
}),
|
||||
};
|
||||
let adapter = GenericProviderOAuthAdapter::for_provider_type("codex")
|
||||
.expect("codex adapter should exist")
|
||||
.with_token_url_override("https://auth.example.test/token");
|
||||
let expected_legacy_fingerprint = derive_codex_identity_fingerprint(
|
||||
Some("acct-123"),
|
||||
Some("original-member"),
|
||||
None,
|
||||
Some("[email protected]"),
|
||||
)
|
||||
.expect("legacy identity should produce a fingerprint");
|
||||
let ctx = ProviderOAuthTransportContext {
|
||||
provider_id: "provider-1".to_string(),
|
||||
provider_type: "codex".to_string(),
|
||||
@@ -752,6 +984,7 @@ mod tests {
|
||||
"refresh_token": "old-refresh-token",
|
||||
"email": "[email protected]",
|
||||
"account_id": "acct-123",
|
||||
"account_user_id": "original-member",
|
||||
"updated_at": 1
|
||||
}),
|
||||
expires_at_unix_secs: Some(1),
|
||||
@@ -763,7 +996,7 @@ mod tests {
|
||||
.await
|
||||
.expect("refresh should succeed");
|
||||
|
||||
assert_eq!(refreshed.token_set.access_token, "new-access-token");
|
||||
assert_eq!(refreshed.token_set.access_token, refreshed_token);
|
||||
assert_eq!(
|
||||
refreshed.token_set.refresh_token.as_deref(),
|
||||
Some("old-refresh-token")
|
||||
@@ -771,6 +1004,10 @@ mod tests {
|
||||
assert_eq!(refreshed.auth_config["email"], "[email protected]");
|
||||
assert_eq!(refreshed.auth_config["account_id"], "acct-123");
|
||||
assert_eq!(refreshed.auth_config["refresh_token"], "old-refresh-token");
|
||||
assert_eq!(
|
||||
refreshed.auth_config[CODEX_IDENTITY_FINGERPRINT_FIELD].as_str(),
|
||||
Some(expected_legacy_fingerprint.as_str())
|
||||
);
|
||||
|
||||
let seen = seen_request
|
||||
.lock()
|
||||
|
||||
@@ -13,7 +13,8 @@ pub use claude_code::{
|
||||
};
|
||||
pub use codex::CodexProviderOAuthAdapter;
|
||||
pub use generic::{
|
||||
GenericProviderOAuthAdapter, GenericProviderOAuthTemplate, GENERIC_PROVIDER_OAUTH_TEMPLATES,
|
||||
derive_codex_identity_fingerprint, GenericProviderOAuthAdapter, GenericProviderOAuthTemplate,
|
||||
GENERIC_PROVIDER_OAUTH_TEMPLATES,
|
||||
};
|
||||
pub use kiro::{
|
||||
generate_kiro_machine_id, normalize_kiro_machine_id, KiroAuthConfig, KiroProviderOAuthAdapter,
|
||||
|
||||
Reference in New Issue
Block a user