mirror of
https://github.com/openai/codex.git
synced 2026-11-13 14:46:14 +00:00
feat: add explicit AgentIdentity auth mode (#18785)
## Summary This PR adds `CodexAuth::AgentIdentity` as an explicit auth mode. An AgentIdentity auth record is a standalone `auth.json` mode. When `AuthManager::auth().await` loads that mode, it registers one process-scoped task and stores it in runtime-only state on the auth value. Header creation stays synchronous after that because the task is initialized before callers receive the auth object. This PR also removes the old feature flag path. AgentIdentity is selected by explicit auth mode, not by a hidden flag or lazy mutation of ChatGPT auth records. Reference old stack: https://github.com/openai/codex/pull/17387/changes ## Design Decisions - AgentIdentity is a real auth enum variant because it can be the only credential in `auth.json`. - The process task is ephemeral runtime state. It is not serialized and is not stored in rollout/session data. - Account/user metadata needed by existing Codex backend checks lives on the AgentIdentity record for now. - `is_chatgpt_auth()` remains token-specific. - `uses_codex_backend()` is the broader predicate for ChatGPT-token auth and AgentIdentity auth. ## Stack 1. https://github.com/openai/codex/pull/18757: full revert 2. https://github.com/openai/codex/pull/18871: isolated Agent Identity crate 3. This PR: explicit AgentIdentity auth mode and startup task allocation 4. https://github.com/openai/codex/pull/18811: migrate Codex backend auth callsites through AuthProvider 5. https://github.com/openai/codex/pull/18904: accept AgentIdentity JWTs and load `CODEX_AGENT_IDENTITY` ## Testing Tests: targeted Rust checks, cargo-shear, Bazel lock check, and CI.
This commit is contained in:
1 parent
0fef35dc3a
commit
69c8913e24
37 files changed
+450
-62
No files matched your search
@@ -0,0 +1,84 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use codex_agent_identity::AgentIdentityKey;
|
||||
use codex_agent_identity::normalize_chatgpt_base_url;
|
||||
use codex_agent_identity::register_agent_task;
|
||||
use codex_protocol::account::PlanType as AccountPlanType;
|
||||
use tokio::sync::OnceCell;
|
||||
|
||||
use crate::default_client::build_reqwest_client;
|
||||
|
||||
use super::storage::AgentIdentityAuthRecord;
|
||||
|
||||
const DEFAULT_CHATGPT_BACKEND_BASE_URL: &str = "https://chatgpt.com/backend-api";
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct AgentIdentityAuth {
|
||||
record: AgentIdentityAuthRecord,
|
||||
process_task_id: Arc<OnceCell<String>>,
|
||||
}
|
||||
|
||||
impl Clone for AgentIdentityAuth {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
record: self.record.clone(),
|
||||
process_task_id: Arc::clone(&self.process_task_id),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AgentIdentityAuth {
|
||||
pub fn new(record: AgentIdentityAuthRecord) -> Self {
|
||||
Self {
|
||||
record,
|
||||
process_task_id: Arc::new(OnceCell::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn record(&self) -> &AgentIdentityAuthRecord {
|
||||
&self.record
|
||||
}
|
||||
|
||||
pub async fn ensure_runtime(&self, chatgpt_base_url: Option<String>) -> std::io::Result<()> {
|
||||
self.process_task_id
|
||||
.get_or_try_init(|| async {
|
||||
let base_url = normalize_chatgpt_base_url(
|
||||
chatgpt_base_url
|
||||
.as_deref()
|
||||
.unwrap_or(DEFAULT_CHATGPT_BACKEND_BASE_URL),
|
||||
);
|
||||
register_agent_task(&build_reqwest_client(), &base_url, self.key())
|
||||
.await
|
||||
.map_err(std::io::Error::other)
|
||||
})
|
||||
.await
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
pub fn account_id(&self) -> &str {
|
||||
&self.record.account_id
|
||||
}
|
||||
|
||||
pub fn chatgpt_user_id(&self) -> &str {
|
||||
&self.record.chatgpt_user_id
|
||||
}
|
||||
|
||||
pub fn email(&self) -> &str {
|
||||
&self.record.email
|
||||
}
|
||||
|
||||
pub fn plan_type(&self) -> AccountPlanType {
|
||||
self.record.plan_type
|
||||
}
|
||||
|
||||
pub fn is_fedramp_account(&self) -> bool {
|
||||
self.record.chatgpt_account_is_fedramp
|
||||
}
|
||||
|
||||
fn key(&self) -> AgentIdentityKey<'_> {
|
||||
AgentIdentityKey {
|
||||
agent_runtime_id: &self.record.agent_runtime_id,
|
||||
private_key_pkcs8_base64: &self.record.agent_private_key,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -136,6 +136,7 @@ async fn pro_account_with_no_api_key_uses_chatgpt_auth() {
|
||||
account_id: None,
|
||||
}),
|
||||
last_refresh: Some(last_refresh),
|
||||
agent_identity: None,
|
||||
},
|
||||
auth_dot_json
|
||||
);
|
||||
@@ -173,6 +174,7 @@ fn logout_removes_auth_file() -> Result<(), std::io::Error> {
|
||||
openai_api_key: Some("sk-test-key".to_string()),
|
||||
tokens: None,
|
||||
last_refresh: None,
|
||||
agent_identity: None,
|
||||
};
|
||||
super::save_auth(dir.path(), &auth_dot_json, AuthCredentialsStoreMode::File)?;
|
||||
let auth_file = get_auth_file(dir.path());
|
||||
@@ -189,6 +191,7 @@ fn unauthorized_recovery_reports_mode_and_step_names() {
|
||||
dir.path().to_path_buf(),
|
||||
/*enable_codex_api_key_env*/ false,
|
||||
AuthCredentialsStoreMode::File,
|
||||
/*chatgpt_base_url*/ None,
|
||||
);
|
||||
let managed = UnauthorizedRecovery {
|
||||
manager: Arc::clone(&manager),
|
||||
|
||||
@@ -23,6 +23,8 @@ use codex_protocol::config_types::ModelProviderAuthInfo;
|
||||
|
||||
use super::external_bearer::BearerTokenRefresher;
|
||||
use super::revoke::revoke_auth_tokens;
|
||||
pub use crate::auth::agent_identity::AgentIdentityAuth;
|
||||
pub use crate::auth::storage::AgentIdentityAuthRecord;
|
||||
pub use crate::auth::storage::AuthDotJson;
|
||||
use crate::auth::storage::AuthStorageBackend;
|
||||
use crate::auth::storage::create_auth_storage;
|
||||
@@ -47,6 +49,13 @@ pub enum CodexAuth {
|
||||
ApiKey(ApiKeyAuth),
|
||||
Chatgpt(ChatgptAuth),
|
||||
ChatgptAuthTokens(ChatgptAuthTokens),
|
||||
AgentIdentity(AgentIdentityAuth),
|
||||
}
|
||||
|
||||
impl PartialEq for CodexAuth {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.api_auth_mode() == other.api_auth_mode()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -71,12 +80,6 @@ struct ChatgptAuthState {
|
||||
client: CodexHttpClient,
|
||||
}
|
||||
|
||||
impl PartialEq for CodexAuth {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.api_auth_mode() == other.api_auth_mode()
|
||||
}
|
||||
}
|
||||
|
||||
const TOKEN_REFRESH_INTERVAL: i64 = 8;
|
||||
|
||||
const REFRESH_TOKEN_EXPIRED_MESSAGE: &str = "Your access token could not be refreshed because your refresh token has expired. Please log out and sign in again.";
|
||||
@@ -203,6 +206,14 @@ impl CodexAuth {
|
||||
};
|
||||
return Ok(Self::from_api_key(api_key));
|
||||
}
|
||||
if auth_mode == ApiAuthMode::AgentIdentity {
|
||||
let Some(record) = auth_dot_json.agent_identity else {
|
||||
return Err(std::io::Error::other(
|
||||
"agent identity auth is missing an agent identity record.",
|
||||
));
|
||||
};
|
||||
return Ok(Self::AgentIdentity(AgentIdentityAuth::new(record)));
|
||||
}
|
||||
|
||||
let storage_mode = auth_dot_json.storage_mode(auth_credentials_store_mode);
|
||||
let state = ChatgptAuthState {
|
||||
@@ -219,6 +230,7 @@ impl CodexAuth {
|
||||
Ok(Self::ChatgptAuthTokens(ChatgptAuthTokens { state }))
|
||||
}
|
||||
ApiAuthMode::ApiKey => unreachable!("api key mode is handled above"),
|
||||
ApiAuthMode::AgentIdentity => unreachable!("agent identity mode is handled above"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,6 +249,7 @@ impl CodexAuth {
|
||||
match self {
|
||||
Self::ApiKey(_) => AuthMode::ApiKey,
|
||||
Self::Chatgpt(_) | Self::ChatgptAuthTokens(_) => AuthMode::Chatgpt,
|
||||
Self::AgentIdentity(_) => AuthMode::AgentIdentity,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,6 +258,7 @@ impl CodexAuth {
|
||||
Self::ApiKey(_) => ApiAuthMode::ApiKey,
|
||||
Self::Chatgpt(_) => ApiAuthMode::Chatgpt,
|
||||
Self::ChatgptAuthTokens(_) => ApiAuthMode::ChatgptAuthTokens,
|
||||
Self::AgentIdentity(_) => ApiAuthMode::AgentIdentity,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,7 +267,14 @@ impl CodexAuth {
|
||||
}
|
||||
|
||||
pub fn is_chatgpt_auth(&self) -> bool {
|
||||
self.auth_mode() == AuthMode::Chatgpt
|
||||
matches!(self, Self::Chatgpt(_) | Self::ChatgptAuthTokens(_))
|
||||
}
|
||||
|
||||
pub fn uses_codex_backend(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::Chatgpt(_) | Self::ChatgptAuthTokens(_) | Self::AgentIdentity(_)
|
||||
)
|
||||
}
|
||||
|
||||
pub fn is_external_chatgpt_tokens(&self) -> bool {
|
||||
@@ -264,11 +285,11 @@ impl CodexAuth {
|
||||
pub fn api_key(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::ApiKey(auth) => Some(auth.api_key.as_str()),
|
||||
Self::Chatgpt(_) | Self::ChatgptAuthTokens(_) => None,
|
||||
Self::Chatgpt(_) | Self::ChatgptAuthTokens(_) | Self::AgentIdentity(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `Err` if `is_chatgpt_auth()` is false.
|
||||
/// Returns `Err` if token-backed ChatGPT auth is unavailable.
|
||||
pub fn get_token_data(&self) -> Result<TokenData, std::io::Error> {
|
||||
let auth_dot_json: Option<AuthDotJson> = self.get_current_auth_json();
|
||||
match auth_dot_json {
|
||||
@@ -289,38 +310,66 @@ impl CodexAuth {
|
||||
let access_token = self.get_token_data()?.access_token;
|
||||
Ok(access_token)
|
||||
}
|
||||
Self::AgentIdentity(_) => Err(std::io::Error::other(
|
||||
"agent identity auth does not expose a bearer token",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `None` if `is_chatgpt_auth()` is false.
|
||||
pub async fn initialize_runtime(
|
||||
&self,
|
||||
chatgpt_base_url: Option<String>,
|
||||
) -> std::io::Result<()> {
|
||||
match self {
|
||||
Self::AgentIdentity(auth) => auth.ensure_runtime(chatgpt_base_url).await,
|
||||
Self::ApiKey(_) | Self::Chatgpt(_) | Self::ChatgptAuthTokens(_) => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `None` if Codex backend auth does not expose an account id.
|
||||
pub fn get_account_id(&self) -> Option<String> {
|
||||
self.get_current_token_data().and_then(|t| t.account_id)
|
||||
match self {
|
||||
Self::AgentIdentity(auth) => Some(auth.account_id().to_string()),
|
||||
_ => self.get_current_token_data().and_then(|t| t.account_id),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns false if `is_chatgpt_auth()` is false or the token omits the FedRAMP claim.
|
||||
/// Returns false if Codex backend auth omits the FedRAMP claim.
|
||||
pub fn is_fedramp_account(&self) -> bool {
|
||||
self.get_current_token_data()
|
||||
.is_some_and(|t| t.id_token.is_fedramp_account())
|
||||
match self {
|
||||
Self::AgentIdentity(auth) => auth.is_fedramp_account(),
|
||||
_ => self
|
||||
.get_current_token_data()
|
||||
.is_some_and(|t| t.id_token.is_fedramp_account()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `None` if `is_chatgpt_auth()` is false.
|
||||
/// Returns `None` if Codex backend auth does not expose an account email.
|
||||
pub fn get_account_email(&self) -> Option<String> {
|
||||
self.get_current_token_data().and_then(|t| t.id_token.email)
|
||||
match self {
|
||||
Self::AgentIdentity(auth) => Some(auth.email().to_string()),
|
||||
_ => self.get_current_token_data().and_then(|t| t.id_token.email),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `None` if `is_chatgpt_auth()` is false.
|
||||
/// Returns `None` if Codex backend auth does not expose a ChatGPT user id.
|
||||
pub fn get_chatgpt_user_id(&self) -> Option<String> {
|
||||
self.get_current_token_data()
|
||||
.and_then(|t| t.id_token.chatgpt_user_id)
|
||||
match self {
|
||||
Self::AgentIdentity(auth) => Some(auth.chatgpt_user_id().to_string()),
|
||||
_ => self
|
||||
.get_current_token_data()
|
||||
.and_then(|t| t.id_token.chatgpt_user_id),
|
||||
}
|
||||
}
|
||||
|
||||
/// Account-facing plan classification derived from the current token.
|
||||
/// Account-facing plan classification derived from the current auth.
|
||||
/// Returns a high-level `AccountPlanType` (e.g., Free/Plus/Pro/Team/…)
|
||||
/// mapped from the ID token's internal plan value. Prefer this when you
|
||||
/// need to make UI or product decisions based on the user's subscription.
|
||||
/// When ChatGPT auth is active but the token omits the plan claim, report
|
||||
/// `Unknown` instead of treating the account as invalid.
|
||||
/// for UI or product decisions based on the user's subscription.
|
||||
pub fn account_plan_type(&self) -> Option<AccountPlanType> {
|
||||
if let Self::AgentIdentity(auth) = self {
|
||||
return Some(auth.plan_type());
|
||||
}
|
||||
|
||||
let map_known = |kp: &InternalKnownPlan| match kp {
|
||||
InternalKnownPlan::Free => AccountPlanType::Free,
|
||||
InternalKnownPlan::Go => AccountPlanType::Go,
|
||||
@@ -348,18 +397,18 @@ impl CodexAuth {
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns `None` if `is_chatgpt_auth()` is false.
|
||||
/// Returns `None` if token-backed ChatGPT auth is unavailable.
|
||||
fn get_current_auth_json(&self) -> Option<AuthDotJson> {
|
||||
let state = match self {
|
||||
Self::Chatgpt(auth) => &auth.state,
|
||||
Self::ChatgptAuthTokens(auth) => &auth.state,
|
||||
Self::ApiKey(_) => return None,
|
||||
Self::ApiKey(_) | Self::AgentIdentity(_) => return None,
|
||||
};
|
||||
#[expect(clippy::unwrap_used)]
|
||||
state.auth_dot_json.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
/// Returns `None` if `is_chatgpt_auth()` is false.
|
||||
/// Returns `None` if token-backed ChatGPT auth is unavailable.
|
||||
fn get_current_token_data(&self) -> Option<TokenData> {
|
||||
self.get_current_auth_json().and_then(|t| t.tokens)
|
||||
}
|
||||
@@ -376,6 +425,7 @@ impl CodexAuth {
|
||||
account_id: Some("account_id".to_string()),
|
||||
}),
|
||||
last_refresh: Some(Utc::now()),
|
||||
agent_identity: None,
|
||||
};
|
||||
|
||||
let client = create_client();
|
||||
@@ -452,6 +502,7 @@ pub async fn logout_with_revoke(
|
||||
codex_home.to_path_buf(),
|
||||
/*enable_codex_api_key_env*/ false,
|
||||
auth_credentials_store_mode,
|
||||
/*chatgpt_base_url*/ None,
|
||||
)
|
||||
.logout_with_revoke()
|
||||
.await
|
||||
@@ -468,6 +519,7 @@ pub fn login_with_api_key(
|
||||
openai_api_key: Some(api_key.to_string()),
|
||||
tokens: None,
|
||||
last_refresh: None,
|
||||
agent_identity: None,
|
||||
};
|
||||
save_auth(codex_home, &auth_dot_json, auth_credentials_store_mode)
|
||||
}
|
||||
@@ -536,9 +588,11 @@ pub fn enforce_login_restrictions(config: &AuthConfig) -> std::io::Result<()> {
|
||||
let method_violation = match (required_method, auth.auth_mode()) {
|
||||
(ForcedLoginMethod::Api, AuthMode::ApiKey) => None,
|
||||
(ForcedLoginMethod::Chatgpt, AuthMode::Chatgpt)
|
||||
| (ForcedLoginMethod::Chatgpt, AuthMode::ChatgptAuthTokens) => None,
|
||||
| (ForcedLoginMethod::Chatgpt, AuthMode::ChatgptAuthTokens)
|
||||
| (ForcedLoginMethod::Chatgpt, AuthMode::AgentIdentity) => None,
|
||||
(ForcedLoginMethod::Api, AuthMode::Chatgpt)
|
||||
| (ForcedLoginMethod::Api, AuthMode::ChatgptAuthTokens) => Some(
|
||||
| (ForcedLoginMethod::Api, AuthMode::ChatgptAuthTokens)
|
||||
| (ForcedLoginMethod::Api, AuthMode::AgentIdentity) => Some(
|
||||
"API key login is required, but ChatGPT is currently being used. Logging out."
|
||||
.to_string(),
|
||||
),
|
||||
@@ -558,26 +612,27 @@ pub fn enforce_login_restrictions(config: &AuthConfig) -> std::io::Result<()> {
|
||||
}
|
||||
|
||||
if let Some(expected_account_id) = config.forced_chatgpt_workspace_id.as_deref() {
|
||||
if !auth.is_chatgpt_auth() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let token_data = match auth.get_token_data() {
|
||||
Ok(data) => data,
|
||||
Err(err) => {
|
||||
return logout_with_message(
|
||||
&config.codex_home,
|
||||
format!(
|
||||
"Failed to load ChatGPT credentials while enforcing workspace restrictions: {err}. Logging out."
|
||||
),
|
||||
config.auth_credentials_store_mode,
|
||||
);
|
||||
// workspace is the external identifier for account id.
|
||||
let chatgpt_account_id = match auth {
|
||||
CodexAuth::ApiKey(_) => return Ok(()),
|
||||
CodexAuth::AgentIdentity(_) => auth.get_account_id(),
|
||||
CodexAuth::Chatgpt(_) | CodexAuth::ChatgptAuthTokens(_) => {
|
||||
let token_data = match auth.get_token_data() {
|
||||
Ok(data) => data,
|
||||
Err(err) => {
|
||||
return logout_with_message(
|
||||
&config.codex_home,
|
||||
format!(
|
||||
"Failed to load ChatGPT credentials while enforcing workspace restrictions: {err}. Logging out."
|
||||
),
|
||||
config.auth_credentials_store_mode,
|
||||
);
|
||||
}
|
||||
};
|
||||
token_data.id_token.chatgpt_account_id
|
||||
}
|
||||
};
|
||||
|
||||
// workspace is the external identifier for account id.
|
||||
let chatgpt_account_id = token_data.id_token.chatgpt_account_id.as_deref();
|
||||
if chatgpt_account_id != Some(expected_account_id) {
|
||||
if chatgpt_account_id.as_deref() != Some(expected_account_id) {
|
||||
let message = match chatgpt_account_id {
|
||||
Some(actual) => format!(
|
||||
"Login is restricted to workspace {expected_account_id}, but current credentials belong to {actual}. Logging out."
|
||||
@@ -841,6 +896,7 @@ impl AuthDotJson {
|
||||
openai_api_key: None,
|
||||
tokens: Some(tokens),
|
||||
last_refresh: Some(Utc::now()),
|
||||
agent_identity: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1134,6 +1190,7 @@ pub struct AuthManager {
|
||||
enable_codex_api_key_env: bool,
|
||||
auth_credentials_store_mode: AuthCredentialsStoreMode,
|
||||
forced_chatgpt_workspace_id: RwLock<Option<String>>,
|
||||
chatgpt_base_url: Option<String>,
|
||||
refresh_lock: Semaphore,
|
||||
external_auth: RwLock<Option<Arc<dyn ExternalAuth>>>,
|
||||
}
|
||||
@@ -1153,6 +1210,9 @@ pub trait AuthManagerConfig {
|
||||
|
||||
/// Returns the workspace ID that ChatGPT auth should be restricted to, if any.
|
||||
fn forced_chatgpt_workspace_id(&self) -> Option<String>;
|
||||
|
||||
/// Returns the ChatGPT backend base URL used for first-party backend authorization.
|
||||
fn chatgpt_base_url(&self) -> String;
|
||||
}
|
||||
|
||||
impl Debug for AuthManager {
|
||||
@@ -1169,6 +1229,7 @@ impl Debug for AuthManager {
|
||||
"forced_chatgpt_workspace_id",
|
||||
&self.forced_chatgpt_workspace_id,
|
||||
)
|
||||
.field("chatgpt_base_url", &self.chatgpt_base_url)
|
||||
.field("has_external_auth", &self.has_external_auth())
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
@@ -1183,6 +1244,7 @@ impl AuthManager {
|
||||
codex_home: PathBuf,
|
||||
enable_codex_api_key_env: bool,
|
||||
auth_credentials_store_mode: AuthCredentialsStoreMode,
|
||||
chatgpt_base_url: Option<String>,
|
||||
) -> Self {
|
||||
let managed_auth = load_auth(
|
||||
&codex_home,
|
||||
@@ -1200,6 +1262,7 @@ impl AuthManager {
|
||||
enable_codex_api_key_env,
|
||||
auth_credentials_store_mode,
|
||||
forced_chatgpt_workspace_id: RwLock::new(None),
|
||||
chatgpt_base_url,
|
||||
refresh_lock: Semaphore::new(/*permits*/ 1),
|
||||
external_auth: RwLock::new(None),
|
||||
}
|
||||
@@ -1218,6 +1281,7 @@ impl AuthManager {
|
||||
enable_codex_api_key_env: false,
|
||||
auth_credentials_store_mode: AuthCredentialsStoreMode::File,
|
||||
forced_chatgpt_workspace_id: RwLock::new(None),
|
||||
chatgpt_base_url: None,
|
||||
refresh_lock: Semaphore::new(/*permits*/ 1),
|
||||
external_auth: RwLock::new(None),
|
||||
})
|
||||
@@ -1235,6 +1299,7 @@ impl AuthManager {
|
||||
enable_codex_api_key_env: false,
|
||||
auth_credentials_store_mode: AuthCredentialsStoreMode::File,
|
||||
forced_chatgpt_workspace_id: RwLock::new(None),
|
||||
chatgpt_base_url: None,
|
||||
refresh_lock: Semaphore::new(/*permits*/ 1),
|
||||
external_auth: RwLock::new(None),
|
||||
})
|
||||
@@ -1250,6 +1315,7 @@ impl AuthManager {
|
||||
enable_codex_api_key_env: false,
|
||||
auth_credentials_store_mode: AuthCredentialsStoreMode::File,
|
||||
forced_chatgpt_workspace_id: RwLock::new(None),
|
||||
chatgpt_base_url: None,
|
||||
refresh_lock: Semaphore::new(/*permits*/ 1),
|
||||
external_auth: RwLock::new(Some(
|
||||
Arc::new(BearerTokenRefresher::new(config)) as Arc<dyn ExternalAuth>
|
||||
@@ -1287,7 +1353,12 @@ impl AuthManager {
|
||||
tracing::error!("Failed to refresh token: {}", err);
|
||||
return Some(auth);
|
||||
}
|
||||
self.auth_cached()
|
||||
let auth = self.auth_cached()?;
|
||||
if let Err(err) = auth.initialize_runtime(self.chatgpt_base_url.clone()).await {
|
||||
tracing::error!("Failed to initialize auth runtime: {err}");
|
||||
return None;
|
||||
}
|
||||
Some(auth)
|
||||
}
|
||||
|
||||
/// Force a reload of the auth information from auth.json. Returns
|
||||
@@ -1339,6 +1410,12 @@ impl AuthManager {
|
||||
| (ApiAuthMode::ChatgptAuthTokens, ApiAuthMode::ChatgptAuthTokens) => {
|
||||
a.get_current_auth_json() == b.get_current_auth_json()
|
||||
}
|
||||
(ApiAuthMode::AgentIdentity, ApiAuthMode::AgentIdentity) => match (a, b) {
|
||||
(CodexAuth::AgentIdentity(a), CodexAuth::AgentIdentity(b)) => {
|
||||
a.record() == b.record()
|
||||
}
|
||||
_ => false,
|
||||
},
|
||||
_ => false,
|
||||
},
|
||||
_ => false,
|
||||
@@ -1412,7 +1489,9 @@ impl AuthManager {
|
||||
}
|
||||
|
||||
pub fn set_forced_chatgpt_workspace_id(&self, workspace_id: Option<String>) {
|
||||
if let Ok(mut guard) = self.forced_chatgpt_workspace_id.write() {
|
||||
if let Ok(mut guard) = self.forced_chatgpt_workspace_id.write()
|
||||
&& *guard != workspace_id
|
||||
{
|
||||
*guard = workspace_id;
|
||||
}
|
||||
}
|
||||
@@ -1443,11 +1522,13 @@ impl AuthManager {
|
||||
codex_home: PathBuf,
|
||||
enable_codex_api_key_env: bool,
|
||||
auth_credentials_store_mode: AuthCredentialsStoreMode,
|
||||
chatgpt_base_url: Option<String>,
|
||||
) -> Arc<Self> {
|
||||
Arc::new(Self::new(
|
||||
codex_home,
|
||||
enable_codex_api_key_env,
|
||||
auth_credentials_store_mode,
|
||||
chatgpt_base_url,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -1460,6 +1541,7 @@ impl AuthManager {
|
||||
config.codex_home(),
|
||||
enable_codex_api_key_env,
|
||||
config.cli_auth_credentials_store_mode(),
|
||||
Some(config.chatgpt_base_url()),
|
||||
);
|
||||
auth_manager.set_forced_chatgpt_workspace_id(config.forced_chatgpt_workspace_id());
|
||||
auth_manager
|
||||
@@ -1581,7 +1663,7 @@ impl AuthManager {
|
||||
self.refresh_and_persist_chatgpt_token(&chatgpt_auth, token_data.refresh_token)
|
||||
.await
|
||||
}
|
||||
CodexAuth::ApiKey(_) => Ok(()),
|
||||
CodexAuth::ApiKey(_) | CodexAuth::AgentIdentity(_) => Ok(()),
|
||||
};
|
||||
if let Err(RefreshTokenError::Permanent(error)) = &result {
|
||||
self.record_permanent_refresh_failure_if_unchanged(&attempted_auth, error);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
mod agent_identity;
|
||||
pub mod default_client;
|
||||
pub mod error;
|
||||
mod storage;
|
||||
|
||||
@@ -23,6 +23,7 @@ use codex_app_server_protocol::AuthMode;
|
||||
use codex_config::types::AuthCredentialsStoreMode;
|
||||
use codex_keyring_store::DefaultKeyringStore;
|
||||
use codex_keyring_store::KeyringStore;
|
||||
use codex_protocol::account::PlanType as AccountPlanType;
|
||||
use once_cell::sync::Lazy;
|
||||
|
||||
/// Expected structure for $CODEX_HOME/auth.json.
|
||||
@@ -39,6 +40,20 @@ pub struct AuthDotJson {
|
||||
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub last_refresh: Option<DateTime<Utc>>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub agent_identity: Option<AgentIdentityAuthRecord>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Eq)]
|
||||
pub struct AgentIdentityAuthRecord {
|
||||
pub agent_runtime_id: String,
|
||||
pub agent_private_key: String,
|
||||
pub account_id: String,
|
||||
pub chatgpt_user_id: String,
|
||||
pub email: String,
|
||||
pub plan_type: AccountPlanType,
|
||||
pub chatgpt_account_is_fedramp: bool,
|
||||
}
|
||||
|
||||
pub(super) fn get_auth_file(codex_home: &Path) -> PathBuf {
|
||||
|
||||
@@ -7,6 +7,7 @@ use serde_json::json;
|
||||
use tempfile::tempdir;
|
||||
|
||||
use codex_keyring_store::tests::MockKeyringStore;
|
||||
use codex_protocol::account::PlanType as AccountPlanType;
|
||||
use keyring::Error as KeyringError;
|
||||
|
||||
#[tokio::test]
|
||||
@@ -18,6 +19,7 @@ async fn file_storage_load_returns_auth_dot_json() -> anyhow::Result<()> {
|
||||
openai_api_key: Some("test-key".to_string()),
|
||||
tokens: None,
|
||||
last_refresh: Some(Utc::now()),
|
||||
agent_identity: None,
|
||||
};
|
||||
|
||||
storage
|
||||
@@ -38,6 +40,7 @@ async fn file_storage_save_persists_auth_dot_json() -> anyhow::Result<()> {
|
||||
openai_api_key: Some("test-key".to_string()),
|
||||
tokens: None,
|
||||
last_refresh: Some(Utc::now()),
|
||||
agent_identity: None,
|
||||
};
|
||||
|
||||
let file = get_auth_file(codex_home.path());
|
||||
@@ -52,6 +55,33 @@ async fn file_storage_save_persists_auth_dot_json() -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn file_storage_round_trips_agent_identity_auth() -> anyhow::Result<()> {
|
||||
let codex_home = tempdir()?;
|
||||
let storage = FileAuthStorage::new(codex_home.path().to_path_buf());
|
||||
let auth_dot_json = AuthDotJson {
|
||||
auth_mode: Some(AuthMode::AgentIdentity),
|
||||
openai_api_key: None,
|
||||
tokens: None,
|
||||
last_refresh: None,
|
||||
agent_identity: Some(AgentIdentityAuthRecord {
|
||||
agent_runtime_id: "agent-runtime-id".to_string(),
|
||||
agent_private_key: "private-key".to_string(),
|
||||
account_id: "account-id".to_string(),
|
||||
chatgpt_user_id: "user-id".to_string(),
|
||||
email: "user@example.com".to_string(),
|
||||
plan_type: AccountPlanType::Pro,
|
||||
chatgpt_account_is_fedramp: false,
|
||||
}),
|
||||
};
|
||||
|
||||
storage.save(&auth_dot_json)?;
|
||||
|
||||
let loaded = storage.load()?;
|
||||
assert_eq!(Some(auth_dot_json), loaded);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_storage_delete_removes_auth_file() -> anyhow::Result<()> {
|
||||
let dir = tempdir()?;
|
||||
@@ -60,6 +90,7 @@ fn file_storage_delete_removes_auth_file() -> anyhow::Result<()> {
|
||||
openai_api_key: Some("sk-test-key".to_string()),
|
||||
tokens: None,
|
||||
last_refresh: None,
|
||||
agent_identity: None,
|
||||
};
|
||||
let storage = create_auth_storage(dir.path().to_path_buf(), AuthCredentialsStoreMode::File);
|
||||
storage.save(&auth_dot_json)?;
|
||||
@@ -83,6 +114,7 @@ fn ephemeral_storage_save_load_delete_is_in_memory_only() -> anyhow::Result<()>
|
||||
openai_api_key: Some("sk-ephemeral".to_string()),
|
||||
tokens: None,
|
||||
last_refresh: Some(Utc::now()),
|
||||
agent_identity: None,
|
||||
};
|
||||
|
||||
storage.save(&auth_dot_json)?;
|
||||
@@ -181,6 +213,7 @@ fn auth_with_prefix(prefix: &str) -> AuthDotJson {
|
||||
account_id: Some(format!("{prefix}-account-id")),
|
||||
}),
|
||||
last_refresh: None,
|
||||
agent_identity: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,6 +230,7 @@ fn keyring_auth_storage_load_returns_deserialized_auth() -> anyhow::Result<()> {
|
||||
openai_api_key: Some("sk-test".to_string()),
|
||||
tokens: None,
|
||||
last_refresh: None,
|
||||
agent_identity: None,
|
||||
};
|
||||
seed_keyring_with_auth(
|
||||
&mock_keyring,
|
||||
@@ -239,6 +273,7 @@ fn keyring_auth_storage_save_persists_and_removes_fallback_file() -> anyhow::Res
|
||||
account_id: Some("account".to_string()),
|
||||
}),
|
||||
last_refresh: Some(Utc::now()),
|
||||
agent_identity: None,
|
||||
};
|
||||
|
||||
storage.save(&auth)?;
|
||||
|
||||
@@ -781,6 +781,7 @@ pub(crate) async fn persist_tokens_async(
|
||||
openai_api_key: api_key,
|
||||
tokens: Some(tokens),
|
||||
last_refresh: Some(Utc::now()),
|
||||
agent_identity: None,
|
||||
};
|
||||
save_auth(&codex_home, &auth, auth_credentials_store_mode)
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user