Compare commits

...

3 Commits

Author SHA1 Message Date
Edward Frazer
ae616594c8 fix: load agent identity from env 2026-04-20 10:01:23 -07:00
Edward Frazer
5512954961 fix: enable agent identity for tokenless auth 2026-04-20 10:01:17 -07:00
Edward Frazer
2e0af353bc fix: accept JWT agent identity auth input 2026-04-20 10:01:14 -07:00
10 changed files with 736 additions and 21 deletions

View File

@@ -91,7 +91,7 @@ struct AgentIdentityBinding {
binding_id: String,
chatgpt_account_id: String,
chatgpt_user_id: Option<String>,
access_token: String,
access_token: Option<String>,
}
struct GeneratedAgentKeyMaterial {
@@ -116,10 +116,15 @@ impl AgentIdentityManager {
pub(crate) fn is_enabled(&self) -> bool {
self.feature_enabled
|| self
.auth_manager
.auth_cached()
.as_ref()
.is_some_and(CodexAuth::is_agent_identity_only)
}
pub(crate) async fn ensure_registered_identity(&self) -> Result<Option<StoredAgentIdentity>> {
if !self.feature_enabled {
if !self.is_enabled() {
return Ok(None);
}
@@ -154,7 +159,7 @@ impl AgentIdentityManager {
}
pub(crate) async fn task_matches_current_identity(&self, task: &RegisteredAgentTask) -> bool {
if !self.feature_enabled {
if !self.is_enabled() {
return false;
}
@@ -244,11 +249,15 @@ impl AgentIdentityManager {
target_url: &str,
) -> Result<String> {
let url = agent_identity_biscuit_url(&self.chatgpt_base_url);
let access_token = binding
.access_token
.as_deref()
.context("ChatGPT access token is unavailable")?;
let request_id = agent_identity_request_id()?;
let client = create_client();
let response = client
.get(&url)
.bearer_auth(&binding.access_token)
.bearer_auth(access_token)
.header("X-Request-Id", request_id.clone())
.header("X-Original-Method", target_method)
.header("X-Original-Url", target_url)
@@ -455,6 +464,16 @@ impl AgentIdentityBinding {
return None;
}
if auth.is_agent_identity_only() {
let record = auth.agent_identity_record()?;
return Some(Self {
binding_id: format!("chatgpt-account-{}", record.workspace_id),
chatgpt_account_id: record.workspace_id,
chatgpt_user_id: record.chatgpt_user_id,
access_token: None,
});
}
let token_data = auth.get_token_data().ok()?;
let resolved_account_id =
forced_workspace_id
@@ -471,7 +490,7 @@ impl AgentIdentityBinding {
.id_token
.chatgpt_user_id
.filter(|value| !value.is_empty()),
access_token: token_data.access_token,
access_token: Some(token_data.access_token),
})
}
}
@@ -582,6 +601,28 @@ mod tests {
assert_eq!(manager.ensure_registered_identity().await.unwrap(), None);
}
#[tokio::test]
async fn ensure_registered_identity_uses_agent_identity_only_auth_when_feature_is_disabled() {
let auth = make_agent_identity_only_auth("account-123", Some("user-123"), "agent-123");
let auth_manager = AuthManager::from_auth_for_testing(auth);
let manager = AgentIdentityManager::new_for_tests(
auth_manager,
/*feature_enabled*/ false,
"https://chatgpt.com/backend-api/".to_string(),
SessionSource::Cli,
);
let stored = manager
.ensure_registered_identity()
.await
.unwrap()
.expect("agent identity-only auth should load the stored identity");
assert_eq!(stored.agent_runtime_id, "agent-123");
assert_eq!(stored.chatgpt_account_id, "account-123");
assert_eq!(stored.chatgpt_user_id.as_deref(), Some("user-123"));
}
#[tokio::test]
async fn ensure_registered_identity_skips_for_api_key_auth() {
let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test-key"));
@@ -830,6 +871,33 @@ mod tests {
.expect("auth")
}
fn make_agent_identity_only_auth(
account_id: &str,
user_id: Option<&str>,
agent_runtime_id: &str,
) -> CodexAuth {
let tempdir = tempfile::tempdir().expect("tempdir");
let key_material = generate_agent_key_material().expect("key material");
let auth_json = AuthDotJson {
auth_mode: Some(ApiAuthMode::Chatgpt),
openai_api_key: None,
tokens: None,
last_refresh: None,
agent_identity: Some(AgentIdentityAuthRecord {
workspace_id: account_id.to_string(),
chatgpt_user_id: user_id.map(ToOwned::to_owned),
agent_runtime_id: agent_runtime_id.to_string(),
agent_private_key: key_material.private_key_pkcs8_base64,
registered_at: Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true),
background_task_id: None,
}),
};
save_auth(tempdir.path(), &auth_json, AuthCredentialsStoreMode::File).expect("save auth");
CodexAuth::from_auth_storage(tempdir.path(), AuthCredentialsStoreMode::File)
.expect("load auth")
.expect("auth")
}
fn fake_id_token(account_id: &str, user_id: Option<&str>) -> String {
let header = URL_SAFE_NO_PAD.encode(r#"{"alg":"none","typ":"JWT"}"#);
let payload = serde_json::json!({

View File

@@ -36,7 +36,7 @@ struct RegisterTaskResponse {
impl AgentIdentityManager {
pub(crate) async fn register_task(&self) -> Result<Option<RegisteredAgentTask>> {
if !self.feature_enabled {
if !self.is_enabled() {
return Ok(None);
}
@@ -65,12 +65,15 @@ impl AgentIdentityManager {
let client = create_client();
let url =
agent_task_registration_url(&self.chatgpt_base_url, &stored_identity.agent_runtime_id);
let human_biscuit = self.mint_human_biscuit(&binding, "POST", &url).await?;
let response = client
let mut request = client
.post(&url)
.header("X-OpenAI-Authorization", human_biscuit)
.json(&request_body)
.timeout(AGENT_TASK_REGISTRATION_TIMEOUT)
.timeout(AGENT_TASK_REGISTRATION_TIMEOUT);
if binding.access_token.is_some() {
let human_biscuit = self.mint_human_biscuit(&binding, "POST", &url).await?;
request = request.header("X-OpenAI-Authorization", human_biscuit);
}
let response = request
.send()
.await
.with_context(|| format!("failed to send agent task registration request to {url}"))?;
@@ -198,6 +201,50 @@ mod tests {
assert_eq!(manager.register_task().await.unwrap(), None);
}
#[tokio::test]
async fn register_task_uses_agent_identity_only_auth_when_feature_is_disabled() {
let server = MockServer::start().await;
let chatgpt_base_url = server.uri();
let auth = make_agent_identity_only_auth("account-123", Some("user-123"), "agent-123");
let auth_manager = AuthManager::from_auth_for_testing(auth.clone());
let manager = AgentIdentityManager::new_for_tests(
auth_manager,
/*feature_enabled*/ false,
chatgpt_base_url,
SessionSource::Cli,
);
let stored_identity = manager
.current_stored_identity()
.await
.expect("stored identity");
let encrypted_task_id =
encrypt_task_id_for_identity(&stored_identity, "task_123").expect("task ciphertext");
Mock::given(method("POST"))
.and(path("/v1/agent/agent-123/task/register"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"encrypted_task_id": encrypted_task_id,
})))
.expect(1)
.mount(&server)
.await;
let task = manager
.register_task()
.await
.unwrap()
.expect("task should be registered");
assert_eq!(
task,
RegisteredAgentTask {
agent_runtime_id: "agent-123".to_string(),
task_id: "task_123".to_string(),
registered_at: task.registered_at.clone(),
}
);
}
#[tokio::test]
async fn register_task_skips_for_api_key_auth() {
let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test-key"));
@@ -452,6 +499,33 @@ mod tests {
.expect("auth")
}
fn make_agent_identity_only_auth(
account_id: &str,
user_id: Option<&str>,
agent_runtime_id: &str,
) -> CodexAuth {
let tempdir = tempfile::tempdir().expect("tempdir");
let key_material = generate_agent_key_material().expect("key material");
let auth_json = AuthDotJson {
auth_mode: Some(ApiAuthMode::Chatgpt),
openai_api_key: None,
tokens: None,
last_refresh: None,
agent_identity: Some(AgentIdentityAuthRecord {
workspace_id: account_id.to_string(),
chatgpt_user_id: user_id.map(ToOwned::to_owned),
agent_runtime_id: agent_runtime_id.to_string(),
agent_private_key: key_material.private_key_pkcs8_base64,
registered_at: Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true),
background_task_id: None,
}),
};
save_auth(tempdir.path(), &auth_json, AuthCredentialsStoreMode::File).expect("save auth");
CodexAuth::from_auth_storage(tempdir.path(), AuthCredentialsStoreMode::File)
.expect("load auth")
.expect("auth")
}
fn fake_id_token(account_id: &str, user_id: Option<&str>) -> String {
let header = URL_SAFE_NO_PAD.encode(r#"{"alg":"none","typ":"JWT"}"#);
let payload = serde_json::json!({

View File

@@ -74,6 +74,10 @@ impl BackgroundAgentTaskAuthMode {
fn is_enabled(self) -> bool {
matches!(self, Self::Enabled)
}
fn is_enabled_for_auth(self, auth: &CodexAuth) -> bool {
self.is_enabled() || auth.is_agent_identity_only()
}
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
@@ -124,7 +128,7 @@ struct AgentIdentityBinding {
binding_id: String,
chatgpt_account_id: String,
chatgpt_user_id: Option<String>,
access_token: String,
access_token: Option<String>,
}
struct GeneratedAgentKeyMaterial {
@@ -166,7 +170,7 @@ impl BackgroundAgentTaskManager {
&self,
auth: &CodexAuth,
) -> Result<Option<String>> {
if !self.auth_mode.is_enabled() {
if !self.auth_mode.is_enabled_for_auth(auth) {
debug!("skipping background agent task auth because agent identity is disabled");
return Ok(None);
}
@@ -314,12 +318,15 @@ impl BackgroundAgentTaskManager {
let client = create_client();
let url =
agent_task_registration_url(&self.chatgpt_base_url, &stored_identity.agent_runtime_id);
let human_biscuit = self.mint_human_biscuit(binding, "POST", &url).await?;
let response = client
let mut request = client
.post(&url)
.header("X-OpenAI-Authorization", human_biscuit)
.json(&request_body)
.timeout(AGENT_TASK_REGISTRATION_TIMEOUT)
.timeout(AGENT_TASK_REGISTRATION_TIMEOUT);
if binding.access_token.is_some() {
let human_biscuit = self.mint_human_biscuit(binding, "POST", &url).await?;
request = request.header("X-OpenAI-Authorization", human_biscuit);
}
let response = request
.send()
.await
.with_context(|| format!("failed to send background agent task request to {url}"))?;
@@ -353,11 +360,15 @@ impl BackgroundAgentTaskManager {
target_url: &str,
) -> Result<String> {
let url = agent_identity_biscuit_url(&self.chatgpt_base_url);
let access_token = binding
.access_token
.as_deref()
.context("ChatGPT access token is unavailable")?;
let request_id = agent_identity_request_id()?;
let client = create_client();
let response = client
.get(&url)
.bearer_auth(&binding.access_token)
.bearer_auth(access_token)
.header("X-Request-Id", request_id.clone())
.header("X-Original-Method", target_method)
.header("X-Original-Url", target_url)
@@ -448,7 +459,7 @@ pub fn cached_background_agent_task_authorization_header_value(
auth: &CodexAuth,
auth_mode: BackgroundAgentTaskAuthMode,
) -> Result<Option<String>> {
if !auth_mode.is_enabled() {
if !auth_mode.is_enabled_for_auth(auth) {
return Ok(None);
}
@@ -551,6 +562,16 @@ impl AgentIdentityBinding {
return None;
}
if auth.is_agent_identity_only() {
let record = auth.agent_identity_record()?;
return Some(Self {
binding_id: format!("chatgpt-account-{}", record.workspace_id),
chatgpt_account_id: record.workspace_id,
chatgpt_user_id: record.chatgpt_user_id,
access_token: None,
});
}
let token_data = auth.get_token_data().ok()?;
let resolved_account_id =
forced_workspace_id
@@ -567,7 +588,7 @@ impl AgentIdentityBinding {
.id_token
.chatgpt_user_id
.filter(|value| !value.is_empty()),
access_token: token_data.access_token,
access_token: Some(token_data.access_token),
})
}
}
@@ -797,6 +818,31 @@ mod tests {
assert_eq!(None, authorization_header_value);
}
#[tokio::test]
async fn disabled_background_agent_task_auth_uses_agent_identity_only_auth() {
let auth = make_agent_identity_only_auth(
"account_id",
/*user_id*/ None,
"agent_123",
Some("task_123"),
);
let auth_manager = AuthManager::from_auth_for_testing(auth.clone());
let manager = BackgroundAgentTaskManager::new_with_auth_mode(
auth_manager,
"https://chatgpt.com/backend-api".to_string(),
SessionSource::Cli,
BackgroundAgentTaskAuthMode::Disabled,
);
let authorization_header_value = manager
.authorization_header_value_for_auth(&auth)
.await
.expect("identity-only auth should not fail")
.expect("identity-only auth should return an agent assertion");
assert!(authorization_header_value.starts_with("AgentAssertion "));
}
#[test]
fn cached_background_agent_task_auth_honors_disabled_mode() {
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
@@ -827,4 +873,36 @@ mod tests {
assert_eq!(None, disabled_authorization_header_value);
assert!(enabled_authorization_header_value.is_some());
}
fn make_agent_identity_only_auth(
account_id: &str,
user_id: Option<&str>,
agent_runtime_id: &str,
background_task_id: Option<&str>,
) -> CodexAuth {
let tempdir = tempfile::tempdir().expect("tempdir");
let key_material = generate_agent_key_material().expect("generate key material");
crate::save_auth(
tempdir.path(),
&crate::AuthDotJson {
auth_mode: Some(codex_app_server_protocol::AuthMode::Chatgpt),
openai_api_key: None,
tokens: None,
last_refresh: None,
agent_identity: Some(AgentIdentityAuthRecord {
workspace_id: account_id.to_string(),
chatgpt_user_id: user_id.map(ToOwned::to_owned),
agent_runtime_id: agent_runtime_id.to_string(),
agent_private_key: key_material.private_key_pkcs8_base64,
registered_at: "2026-04-13T12:00:00Z".to_string(),
background_task_id: background_task_id.map(ToOwned::to_owned),
}),
},
crate::AuthCredentialsStoreMode::File,
)
.expect("save auth");
CodexAuth::from_auth_storage(tempdir.path(), crate::AuthCredentialsStoreMode::File)
.expect("load auth")
.expect("auth")
}
}

View File

@@ -243,6 +243,7 @@ fn dummy_chatgpt_auth_does_not_create_cwd_auth_json_when_identity_is_set() {
agent_runtime_id: "agent_123".to_string(),
agent_private_key: "pkcs8-base64".to_string(),
registered_at: "2026-04-13T12:00:00Z".to_string(),
background_task_id: None,
};
auth.set_agent_identity(record.clone())
@@ -257,6 +258,121 @@ fn dummy_chatgpt_auth_does_not_create_cwd_auth_json_when_identity_is_set() {
}
}
#[test]
fn chatgpt_auth_detects_agent_identity_only() {
let codex_home = tempdir().unwrap();
let record = AgentIdentityAuthRecord {
workspace_id: "account-123".to_string(),
chatgpt_user_id: Some("user-123".to_string()),
agent_runtime_id: "agent_123".to_string(),
agent_private_key: "pkcs8-base64".to_string(),
registered_at: "2026-04-13T12:00:00Z".to_string(),
background_task_id: None,
};
let auth_dot_json = AuthDotJson {
auth_mode: Some(ApiAuthMode::ChatgptAuthTokens),
openai_api_key: None,
tokens: None,
last_refresh: None,
agent_identity: Some(record.clone()),
};
super::save_auth(
codex_home.path(),
&auth_dot_json,
AuthCredentialsStoreMode::File,
)
.expect("save auth file");
let auth = super::load_auth(
codex_home.path(),
/*enable_codex_api_key_env*/ false,
AuthCredentialsStoreMode::File,
)
.expect("load auth")
.expect("auth available");
assert!(auth.is_agent_identity_only());
assert_eq!(auth.agent_identity_record(), Some(record));
}
#[test]
fn chatgpt_auth_with_tokens_is_not_agent_identity_only() {
let codex_home = tempdir().unwrap();
write_auth_file(
AuthFileParams {
openai_api_key: None,
chatgpt_plan_type: Some("pro".to_string()),
chatgpt_account_id: Some("account-123".to_string()),
},
codex_home.path(),
)
.expect("failed to write auth file");
let auth = super::load_auth(
codex_home.path(),
/*enable_codex_api_key_env*/ false,
AuthCredentialsStoreMode::File,
)
.expect("load auth")
.expect("auth available");
auth.set_agent_identity(AgentIdentityAuthRecord {
workspace_id: "account-123".to_string(),
chatgpt_user_id: Some("user-123".to_string()),
agent_runtime_id: "agent_123".to_string(),
agent_private_key: "pkcs8-base64".to_string(),
registered_at: "2026-04-13T12:00:00Z".to_string(),
background_task_id: None,
})
.expect("set agent identity");
assert!(!auth.is_agent_identity_only());
}
#[test]
#[serial(codex_agent_identity)]
#[serial(codex_api_key)]
fn load_auth_reads_agent_identity_from_env() {
let codex_home = tempdir().unwrap();
let expected_record = agent_identity_record("account-123");
let agent_identity = fake_agent_identity_jwt(&expected_record).expect("fake agent identity");
let _agent_guard = EnvVarGuard::set(CODEX_AGENT_IDENTITY_ENV_VAR, &agent_identity);
let _api_key_guard = EnvVarGuard::remove(CODEX_API_KEY_ENV_VAR);
let auth = super::load_auth(
codex_home.path(),
/*enable_codex_api_key_env*/ true,
AuthCredentialsStoreMode::File,
)
.expect("load auth")
.expect("auth available");
assert!(auth.is_agent_identity_only());
assert_eq!(auth.agent_identity_record(), Some(expected_record));
assert!(
!get_auth_file(codex_home.path()).exists(),
"env auth should not write auth.json"
);
}
#[test]
#[serial(codex_agent_identity)]
#[serial(codex_api_key)]
fn load_auth_keeps_codex_api_key_env_precedence() {
let codex_home = tempdir().unwrap();
let record = agent_identity_record("account-123");
let agent_identity = fake_agent_identity_jwt(&record).expect("fake agent identity");
let _agent_guard = EnvVarGuard::set(CODEX_AGENT_IDENTITY_ENV_VAR, &agent_identity);
let _api_key_guard = EnvVarGuard::set(CODEX_API_KEY_ENV_VAR, "sk-env");
let auth = super::load_auth(
codex_home.path(),
/*enable_codex_api_key_env*/ true,
AuthCredentialsStoreMode::File,
)
.expect("load auth")
.expect("auth available");
assert_eq!(auth.auth_mode(), AuthMode::ApiKey);
}
#[test]
fn unauthorized_recovery_reports_mode_and_step_names() {
let dir = tempdir().unwrap();
@@ -670,6 +786,42 @@ fn fake_jwt_for_auth_file_params(params: &AuthFileParams) -> std::io::Result<Str
Ok(format!("{header_b64}.{payload_b64}.{signature_b64}"))
}
fn agent_identity_record(workspace_id: &str) -> AgentIdentityAuthRecord {
AgentIdentityAuthRecord {
workspace_id: workspace_id.to_string(),
chatgpt_user_id: Some("user-123".to_string()),
agent_runtime_id: "agent_123".to_string(),
agent_private_key: "pkcs8-base64".to_string(),
registered_at: "2026-04-13T12:00:00Z".to_string(),
background_task_id: None,
}
}
fn fake_agent_identity_jwt(record: &AgentIdentityAuthRecord) -> std::io::Result<String> {
#[derive(Serialize)]
struct Header {
alg: &'static str,
typ: &'static str,
}
let header = Header {
alg: "none",
typ: "JWT",
};
let payload = serde_json::json!({
"workspace_id": record.workspace_id.clone(),
"chatgpt_user_id": record.chatgpt_user_id.clone(),
"agent_runtime_id": record.agent_runtime_id.clone(),
"agent_private_key": record.agent_private_key.clone(),
"registered_at": record.registered_at.clone(),
});
let b64 = |b: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b);
let header_b64 = b64(&serde_json::to_vec(&header)?);
let payload_b64 = b64(&serde_json::to_vec(&payload)?);
let signature_b64 = b64(b"sig");
Ok(format!("{header_b64}.{payload_b64}.{signature_b64}"))
}
async fn build_config(
codex_home: &Path,
forced_login_method: Option<ForcedLoginMethod>,
@@ -683,6 +835,30 @@ async fn build_config(
}
}
fn write_agent_identity_only_auth_file(
codex_home: &Path,
workspace_id: &str,
) -> std::io::Result<()> {
save_auth(
codex_home,
&AuthDotJson {
auth_mode: Some(AuthMode::Chatgpt),
openai_api_key: None,
tokens: None,
last_refresh: None,
agent_identity: Some(AgentIdentityAuthRecord {
workspace_id: workspace_id.to_string(),
chatgpt_user_id: None,
agent_runtime_id: "agent_123".to_string(),
agent_private_key: "private-key".to_string(),
registered_at: "2026-04-13T12:00:00Z".to_string(),
background_task_id: None,
}),
},
AuthCredentialsStoreMode::File,
)
}
/// Use sparingly.
/// TODO (gpeal): replace this with an injectable env var provider.
#[cfg(test)]
@@ -700,6 +876,14 @@ impl EnvVarGuard {
}
Self { key, original }
}
fn remove(key: &'static str) -> Self {
let original = env::var_os(key);
unsafe {
env::remove_var(key);
}
Self { key, original }
}
}
#[cfg(test)]
@@ -715,6 +899,8 @@ impl Drop for EnvVarGuard {
}
#[tokio::test]
#[serial(codex_agent_identity)]
#[serial(codex_api_key)]
async fn enforce_login_restrictions_logs_out_for_method_mismatch() {
let codex_home = tempdir().unwrap();
login_with_api_key(codex_home.path(), "sk-test", AuthCredentialsStoreMode::File)
@@ -737,6 +923,7 @@ async fn enforce_login_restrictions_logs_out_for_method_mismatch() {
}
#[tokio::test]
#[serial(codex_agent_identity)]
#[serial(codex_api_key)]
async fn enforce_login_restrictions_logs_out_for_workspace_mismatch() {
let codex_home = tempdir().unwrap();
@@ -767,6 +954,7 @@ async fn enforce_login_restrictions_logs_out_for_workspace_mismatch() {
}
#[tokio::test]
#[serial(codex_agent_identity)]
#[serial(codex_api_key)]
async fn enforce_login_restrictions_allows_matching_workspace() {
let codex_home = tempdir().unwrap();
@@ -795,6 +983,97 @@ async fn enforce_login_restrictions_allows_matching_workspace() {
}
#[tokio::test]
#[serial(codex_agent_identity)]
#[serial(codex_api_key)]
async fn enforce_login_restrictions_allows_matching_agent_identity_only_workspace() {
let codex_home = tempdir().unwrap();
write_agent_identity_only_auth_file(codex_home.path(), "org_mine")
.expect("failed to write auth file");
let config = build_config(
codex_home.path(),
/*forced_login_method*/ None,
Some("org_mine".to_string()),
)
.await;
super::enforce_login_restrictions(&config).expect("matching workspace should succeed");
assert!(
codex_home.path().join("auth.json").exists(),
"auth.json should remain when restrictions pass"
);
}
#[tokio::test]
#[serial(codex_agent_identity)]
#[serial(codex_api_key)]
async fn enforce_login_restrictions_logs_out_for_agent_identity_only_workspace_mismatch() {
let codex_home = tempdir().unwrap();
write_agent_identity_only_auth_file(codex_home.path(), "org_another_org")
.expect("failed to write auth file");
let config = build_config(
codex_home.path(),
/*forced_login_method*/ None,
Some("org_mine".to_string()),
)
.await;
let err = super::enforce_login_restrictions(&config)
.expect_err("expected workspace mismatch to error");
assert!(err.to_string().contains("workspace org_mine"));
assert!(
!codex_home.path().join("auth.json").exists(),
"auth.json should be removed on mismatch"
);
}
#[tokio::test]
#[serial(codex_agent_identity)]
#[serial(codex_api_key)]
async fn enforce_login_restrictions_allows_matching_agent_identity_workspace_from_env() {
let codex_home = tempdir().unwrap();
let record = agent_identity_record("org_mine");
let agent_identity = fake_agent_identity_jwt(&record).expect("fake agent identity");
let _agent_guard = EnvVarGuard::set(CODEX_AGENT_IDENTITY_ENV_VAR, &agent_identity);
let _api_key_guard = EnvVarGuard::remove(CODEX_API_KEY_ENV_VAR);
let config = build_config(
codex_home.path(),
Some(ForcedLoginMethod::Chatgpt),
Some("org_mine".to_string()),
)
.await;
super::enforce_login_restrictions(&config).expect("matching workspace should succeed");
}
#[tokio::test]
#[serial(codex_agent_identity)]
#[serial(codex_api_key)]
async fn enforce_login_restrictions_blocks_agent_identity_workspace_mismatch_from_env() {
let codex_home = tempdir().unwrap();
let record = agent_identity_record("org_another_org");
let agent_identity = fake_agent_identity_jwt(&record).expect("fake agent identity");
let _agent_guard = EnvVarGuard::set(CODEX_AGENT_IDENTITY_ENV_VAR, &agent_identity);
let _api_key_guard = EnvVarGuard::remove(CODEX_API_KEY_ENV_VAR);
let config = build_config(
codex_home.path(),
Some(ForcedLoginMethod::Chatgpt),
Some("org_mine".to_string()),
)
.await;
let err =
super::enforce_login_restrictions(&config).expect_err("workspace mismatch should fail");
assert!(err.to_string().contains("workspace org_mine"));
}
#[tokio::test]
#[serial(codex_agent_identity)]
#[serial(codex_api_key)]
async fn enforce_login_restrictions_allows_api_key_if_login_method_not_set_but_forced_chatgpt_workspace_id_is_set()
{
let codex_home = tempdir().unwrap();
@@ -816,6 +1095,7 @@ async fn enforce_login_restrictions_allows_api_key_if_login_method_not_set_but_f
}
#[tokio::test]
#[serial(codex_agent_identity)]
#[serial(codex_api_key)]
async fn enforce_login_restrictions_blocks_env_api_key_when_chatgpt_required() {
let _guard = EnvVarGuard::set(CODEX_API_KEY_ENV_VAR, "sk-env");

View File

@@ -241,6 +241,21 @@ impl CodexAuth {
)
}
pub fn from_agent_identity_jwt(codex_home: &Path, jwt: &str) -> std::io::Result<Self> {
let auth_dot_json = AuthDotJson {
auth_mode: Some(ApiAuthMode::ChatgptAuthTokens),
openai_api_key: None,
tokens: None,
last_refresh: None,
agent_identity: Some(AgentIdentityAuthRecord::from_agent_identity_jwt(jwt)?),
};
Self::from_auth_dot_json(
codex_home,
auth_dot_json,
AuthCredentialsStoreMode::Ephemeral,
)
}
pub fn auth_mode(&self) -> AuthMode {
match self {
Self::ApiKey(_) => AuthMode::ApiKey,
@@ -311,6 +326,17 @@ impl CodexAuth {
.is_some_and(|t| t.id_token.is_fedramp_account())
}
pub fn is_agent_identity_only(&self) -> bool {
self.get_current_auth_json().is_some_and(|auth| {
auth.tokens.is_none() && auth.agent_identity.is_some() && self.is_chatgpt_auth()
})
}
pub fn agent_identity_record(&self) -> Option<AgentIdentityAuthRecord> {
self.get_current_auth_json()
.and_then(|auth| auth.agent_identity)
}
/// Returns `None` if `is_chatgpt_auth()` is false.
pub fn get_account_email(&self) -> Option<String> {
self.get_current_token_data().and_then(|t| t.id_token.email)
@@ -481,6 +507,7 @@ impl ChatgptAuth {
pub const OPENAI_API_KEY_ENV_VAR: &str = "OPENAI_API_KEY";
pub const CODEX_API_KEY_ENV_VAR: &str = "CODEX_API_KEY";
pub const CODEX_AGENT_IDENTITY_ENV_VAR: &str = "CODEX_AGENT_IDENTITY";
pub fn read_openai_api_key_from_env() -> Option<String> {
env::var(OPENAI_API_KEY_ENV_VAR)
@@ -496,6 +523,13 @@ pub fn read_codex_api_key_from_env() -> Option<String> {
.filter(|value| !value.is_empty())
}
pub fn read_codex_agent_identity_from_env() -> Option<String> {
env::var(CODEX_AGENT_IDENTITY_ENV_VAR)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
/// Delete the auth.json file inside `codex_home` if it exists. Returns `Ok(true)`
/// if a file was removed, `Ok(false)` if no auth file was present.
pub fn logout(
@@ -625,6 +659,23 @@ pub fn enforce_login_restrictions(config: &AuthConfig) -> std::io::Result<()> {
return Ok(());
}
if auth.is_agent_identity_only() {
let actual_account_id = auth
.agent_identity_record()
.map(|identity| identity.workspace_id);
if actual_account_id.as_deref() != Some(expected_account_id) {
return logout_with_message(
&config.codex_home,
format!(
"Login is restricted to workspace {expected_account_id}, but current agent identity belongs to {actual_account_id:?}. Logging out."
),
config.auth_credentials_store_mode,
);
}
return Ok(());
}
let token_data = match auth.get_token_data() {
Ok(data) => data,
Err(err) => {
@@ -701,6 +752,10 @@ fn load_auth(
return Ok(Some(CodexAuth::from_api_key(api_key.as_str())));
}
if enable_codex_api_key_env && let Some(agent_identity) = read_codex_agent_identity_from_env() {
return CodexAuth::from_agent_identity_jwt(codex_home, &agent_identity).map(Some);
}
// External ChatGPT auth tokens live in the in-memory (ephemeral) store. Always check this
// first so external auth takes precedence over any persisted credentials.
let ephemeral_storage = create_auth_storage(
@@ -1547,6 +1602,10 @@ impl AuthManager {
return Ok(None);
}
if auth.is_agent_identity_only() {
return Ok(auth.agent_identity_record());
}
let token_data = auth
.get_token_data()
.context("ChatGPT token data is not available")?;

View File

@@ -1,4 +1,5 @@
use chrono::DateTime;
use chrono::SecondsFormat;
use chrono::Utc;
use serde::Deserialize;
use serde::Serialize;
@@ -19,6 +20,7 @@ use std::sync::Mutex;
use tracing::warn;
use crate::token_data::TokenData;
use crate::token_data::decode_jwt_payload;
use codex_app_server_protocol::AuthMode;
use codex_config::types::AuthCredentialsStoreMode;
use codex_keyring_store::DefaultKeyringStore;
@@ -40,7 +42,11 @@ 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")]
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "deserialize_agent_identity"
)]
pub agent_identity: Option<AgentIdentityAuthRecord>,
}
@@ -56,6 +62,68 @@ pub struct AgentIdentityAuthRecord {
pub background_task_id: Option<String>,
}
#[derive(Deserialize)]
#[serde(untagged)]
enum AgentIdentityAuthInput {
Record(AgentIdentityAuthRecord),
Jwt(String),
}
#[derive(Deserialize)]
struct AgentIdentityJwtClaims {
workspace_id: String,
#[serde(default)]
chatgpt_user_id: Option<String>,
agent_runtime_id: String,
agent_private_key: String,
#[serde(default)]
registered_at: Option<String>,
}
impl TryFrom<AgentIdentityAuthInput> for AgentIdentityAuthRecord {
type Error = std::io::Error;
fn try_from(input: AgentIdentityAuthInput) -> std::io::Result<Self> {
match input {
AgentIdentityAuthInput::Record(record) => Ok(record),
AgentIdentityAuthInput::Jwt(jwt) => {
AgentIdentityAuthRecord::from_agent_identity_jwt(&jwt)
}
}
}
}
impl AgentIdentityAuthRecord {
pub(crate) fn from_agent_identity_jwt(jwt: &str) -> std::io::Result<Self> {
let claims: AgentIdentityJwtClaims =
decode_jwt_payload(jwt).map_err(std::io::Error::other)?;
Ok(Self {
workspace_id: claims.workspace_id,
chatgpt_user_id: claims.chatgpt_user_id,
agent_runtime_id: claims.agent_runtime_id,
agent_private_key: claims.agent_private_key,
registered_at: claims
.registered_at
.unwrap_or_else(|| Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true)),
background_task_id: None,
})
}
}
fn deserialize_agent_identity<'de, D>(
deserializer: D,
) -> Result<Option<AgentIdentityAuthRecord>, D::Error>
where
D: serde::Deserializer<'de>,
{
let input = Option::<AgentIdentityAuthInput>::deserialize(deserializer)?;
input
.map(AgentIdentityAuthRecord::try_from)
.transpose()
.map_err(serde::de::Error::custom)
}
pub(super) fn get_auth_file(codex_home: &Path) -> PathBuf {
codex_home.join("auth.json")
}

View File

@@ -79,6 +79,64 @@ async fn file_storage_persists_agent_identity() -> anyhow::Result<()> {
Ok(())
}
#[tokio::test]
async fn file_storage_loads_agent_identity_from_jwt() -> anyhow::Result<()> {
let codex_home = tempdir()?;
let storage = FileAuthStorage::new(codex_home.path().to_path_buf());
let expected_record = AgentIdentityAuthRecord {
workspace_id: "account-123".to_string(),
chatgpt_user_id: None,
agent_runtime_id: "agent_123".to_string(),
agent_private_key: "pkcs8-base64".to_string(),
registered_at: "2026-04-13T12:00:00Z".to_string(),
background_task_id: None,
};
let agent_identity_jwt = jwt_with_payload(json!({
"workspace_id": expected_record.workspace_id,
"agent_runtime_id": expected_record.agent_runtime_id,
"agent_private_key": expected_record.agent_private_key,
"registered_at": expected_record.registered_at,
}));
let auth_file = get_auth_file(codex_home.path());
std::fs::write(
&auth_file,
serde_json::to_string_pretty(&json!({
"auth_mode": "chatgpt",
"agent_identity": agent_identity_jwt,
}))?,
)?;
let loaded = storage.load()?;
assert_eq!(
loaded.expect("auth should load").agent_identity,
Some(expected_record)
);
Ok(())
}
#[tokio::test]
async fn file_storage_rejects_invalid_agent_identity_jwt() -> anyhow::Result<()> {
let codex_home = tempdir()?;
let storage = FileAuthStorage::new(codex_home.path().to_path_buf());
let auth_file = get_auth_file(codex_home.path());
std::fs::write(
&auth_file,
serde_json::to_string_pretty(&json!({
"auth_mode": "chatgpt",
"agent_identity": "not-a-jwt",
}))?,
)?;
let err = storage.load().expect_err("invalid JWT should fail load");
assert!(
err.to_string().contains("invalid ID token format"),
"unexpected error: {err}"
);
Ok(())
}
#[test]
fn file_storage_delete_removes_auth_file() -> anyhow::Result<()> {
let dir = tempdir()?;
@@ -199,6 +257,24 @@ fn id_token_with_prefix(prefix: &str) -> IdTokenInfo {
crate::token_data::parse_chatgpt_jwt_claims(&fake_jwt).expect("fake JWT should parse")
}
fn jwt_with_payload(payload: serde_json::Value) -> String {
#[derive(Serialize)]
struct Header {
alg: &'static str,
typ: &'static str,
}
let header = Header {
alg: "none",
typ: "JWT",
};
let encode = |bytes: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes);
let header_b64 = encode(&serde_json::to_vec(&header).expect("serialize header"));
let payload_b64 = encode(&serde_json::to_vec(&payload).expect("serialize payload"));
let signature_b64 = encode(b"sig");
format!("{header_b64}.{payload_b64}.{signature_b64}")
}
fn auth_with_prefix(prefix: &str) -> AuthDotJson {
AuthDotJson {
auth_mode: Some(AuthMode::ApiKey),

View File

@@ -27,6 +27,7 @@ pub use auth::AuthDotJson;
pub use auth::AuthManager;
pub use auth::AuthManagerConfig;
pub use auth::CLIENT_ID;
pub use auth::CODEX_AGENT_IDENTITY_ENV_VAR;
pub use auth::CODEX_API_KEY_ENV_VAR;
pub use auth::CodexAuth;
pub use auth::ExternalAuth;
@@ -45,6 +46,7 @@ pub use auth::load_auth_dot_json;
pub use auth::login_with_api_key;
pub use auth::logout;
pub use auth::logout_with_revoke;
pub use auth::read_codex_agent_identity_from_env;
pub use auth::read_openai_api_key_from_env;
pub use auth::save_auth;
pub use auth_env_telemetry::AuthEnvTelemetry;

View File

@@ -114,7 +114,7 @@ pub enum IdTokenInfoError {
Json(#[from] serde_json::Error),
}
fn decode_jwt_payload<T: DeserializeOwned>(jwt: &str) -> Result<T, IdTokenInfoError> {
pub(crate) fn decode_jwt_payload<T: DeserializeOwned>(jwt: &str) -> Result<T, IdTokenInfoError> {
// JWT format: header.payload.signature
let mut parts = jwt.split('.');
let (_header_b64, payload_b64, _sig_b64) = match (parts.next(), parts.next(), parts.next()) {

View File

@@ -41,6 +41,16 @@ fn bearer_auth_provider_from_auth(
}
if let Some(auth) = auth {
if auth.is_agent_identity_only() {
return Ok(BearerAuthProvider {
token: None,
account_id: auth
.agent_identity_record()
.map(|record| record.workspace_id),
is_fedramp_account: false,
});
}
let token = auth.get_token()?;
Ok(BearerAuthProvider {
token: Some(token),