feat: load AgentIdentity from JWT login/env (#18904)

## Summary

This PR lets programmatic AgentIdentity users provide one token through
either stdin login or environment auth.

`codex login --with-agent-identity` reads an Agent Identity JWT from
stdin, validates that it has the required claims, and stores that token
as the `agent_identity` value in `auth.json`. The file format is
token-only; the decoded account and key fields are runtime state, not
hand-authored auth.json fields.

The Agent Identity JWT claim shape and decoder live in
`codex-agent-identity`; `codex-login` only owns env/storage precedence
and conversion into `CodexAuth::AgentIdentity`.

When env auth is enabled, `CODEX_AGENT_IDENTITY` can provide the same
JWT without writing auth state to disk. `CODEX_API_KEY` still wins if
both env vars are set.

Reference old stack: https://github.com/openai/codex/pull/17387/changes
Reference JWT/env stack: https://github.com/openai/codex/pull/18176

## Stack

1. https://github.com/openai/codex/pull/18757: full revert
2. https://github.com/openai/codex/pull/18871: isolated Agent Identity
crate
3. https://github.com/openai/codex/pull/18785: explicit AgentIdentity
auth mode and startup task allocation
4. https://github.com/openai/codex/pull/18811: migrate Codex backend
auth callsites through AuthProvider
5. This PR: accept AgentIdentity JWTs through login/env

## Testing

Tests: targeted login and Agent Identity crate tests, CLI checks, scoped
formatter/linter cleanup, and CI.

---------

Co-authored-by: Shijie Rao <shijie.rao@openai.com>
This commit is contained in:
efrazer-oaiandShijie Rao authored and GitHub committed 2026-04-26 19:49:54 +00:00
1 parent ac2bffa443
commit fed0a8f4fa
14 files changed
+587 -49

No files matched your search

+9 -11
View File
@@ -1,7 +1,6 @@
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;
@@ -10,7 +9,7 @@ use crate::default_client::build_reqwest_client;
use super::storage::AgentIdentityAuthRecord;
const DEFAULT_CHATGPT_BACKEND_BASE_URL: &str = "https://chatgpt.com/backend-api";
const AGENT_IDENTITY_AUTHAPI_BASE_URL: &str = "https://auth.openai.com/api/accounts";
#[derive(Debug)]
pub struct AgentIdentityAuth {
@@ -43,17 +42,16 @@ impl AgentIdentityAuth {
self.process_task_id.get().map(String::as_str)
}
pub async fn ensure_runtime(&self, chatgpt_base_url: Option<String>) -> std::io::Result<()> {
pub async fn ensure_runtime(&self) -> 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)
register_agent_task(
&build_reqwest_client(),
AGENT_IDENTITY_AUTHAPI_BASE_URL,
self.key(),
)
.await
.map_err(std::io::Error::other)
})
.await
.map(|_| ())
+120 -5
View File
@@ -78,6 +78,44 @@ fn login_with_api_key_overwrites_existing_auth_json() {
assert!(auth.tokens.is_none(), "tokens should be cleared");
}
#[test]
fn login_with_agent_identity_writes_only_token() {
let dir = tempdir().unwrap();
let auth_path = dir.path().join("auth.json");
let record = agent_identity_record("account-123");
let agent_identity = fake_agent_identity_jwt(&record).expect("fake agent identity");
super::login_with_agent_identity(dir.path(), &agent_identity, AuthCredentialsStoreMode::File)
.expect("login_with_agent_identity should succeed");
let storage = FileAuthStorage::new(dir.path().to_path_buf());
let auth = storage
.try_read_auth_json(&auth_path)
.expect("auth.json should parse");
assert_eq!(auth.auth_mode, Some(AuthMode::AgentIdentity));
assert_eq!(
auth.agent_identity.as_deref(),
Some(agent_identity.as_str())
);
assert!(auth.tokens.is_none(), "tokens should be cleared");
assert!(auth.openai_api_key.is_none(), "API key should be cleared");
}
#[test]
fn login_with_agent_identity_rejects_invalid_jwt() {
let dir = tempdir().unwrap();
let err =
super::login_with_agent_identity(dir.path(), "not-a-jwt", AuthCredentialsStoreMode::File)
.expect_err("invalid Agent Identity token should fail");
assert_eq!(err.kind(), std::io::ErrorKind::Other);
assert!(
!get_auth_file(dir.path()).exists(),
"invalid Agent Identity token should not write auth.json"
);
}
#[test]
fn missing_auth_json_returns_none() {
let dir = tempdir().unwrap();
@@ -87,7 +125,7 @@ fn missing_auth_json_returns_none() {
}
#[tokio::test]
#[serial(codex_api_key)]
#[serial(codex_auth_env)]
async fn pro_account_with_no_api_key_uses_chatgpt_auth() {
let codex_home = tempdir().unwrap();
let fake_jwt = write_auth_file(
@@ -143,7 +181,7 @@ async fn pro_account_with_no_api_key_uses_chatgpt_auth() {
}
#[tokio::test]
#[serial(codex_api_key)]
#[serial(codex_auth_env)]
async fn loads_api_key_from_auth_json() {
let dir = tempdir().unwrap();
let auth_file = dir.path().join("auth.json");
@@ -581,7 +619,54 @@ impl Drop for EnvVarGuard {
}
}
#[test]
#[serial(codex_auth_env)]
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 auth = super::load_auth(
codex_home.path(),
/*enable_codex_api_key_env*/ false,
AuthCredentialsStoreMode::File,
)
.expect("env auth should load")
.expect("env auth should be present");
let CodexAuth::AgentIdentity(agent_identity) = auth else {
panic!("env auth should load as agent identity");
};
assert_eq!(agent_identity.record(), &expected_record);
assert!(
!get_auth_file(codex_home.path()).exists(),
"env auth should not write auth.json"
);
}
#[test]
#[serial(codex_auth_env)]
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("env auth should load")
.expect("env auth should be present");
assert_eq!(auth.api_key(), Some("sk-env"));
}
#[tokio::test]
#[serial(codex_auth_env)]
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)
@@ -604,7 +689,7 @@ async fn enforce_login_restrictions_logs_out_for_method_mismatch() {
}
#[tokio::test]
#[serial(codex_api_key)]
#[serial(codex_auth_env)]
async fn enforce_login_restrictions_logs_out_for_workspace_mismatch() {
let codex_home = tempdir().unwrap();
let _jwt = write_auth_file(
@@ -634,7 +719,7 @@ async fn enforce_login_restrictions_logs_out_for_workspace_mismatch() {
}
#[tokio::test]
#[serial(codex_api_key)]
#[serial(codex_auth_env)]
async fn enforce_login_restrictions_allows_matching_workspace() {
let codex_home = tempdir().unwrap();
let _jwt = write_auth_file(
@@ -662,6 +747,7 @@ async fn enforce_login_restrictions_allows_matching_workspace() {
}
#[tokio::test]
#[serial(codex_auth_env)]
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();
@@ -683,7 +769,7 @@ async fn enforce_login_restrictions_allows_api_key_if_login_method_not_set_but_f
}
#[tokio::test]
#[serial(codex_api_key)]
#[serial(codex_auth_env)]
async fn enforce_login_restrictions_blocks_env_api_key_when_chatgpt_required() {
let _guard = EnvVarGuard::set(CODEX_API_KEY_ENV_VAR, "sk-env");
let codex_home = tempdir().unwrap();
@@ -703,6 +789,35 @@ async fn enforce_login_restrictions_blocks_env_api_key_when_chatgpt_required() {
);
}
fn agent_identity_record(account_id: &str) -> AgentIdentityAuthRecord {
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,
}
}
fn fake_agent_identity_jwt(record: &AgentIdentityAuthRecord) -> std::io::Result<String> {
let encode = |bytes: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes);
let header_b64 = encode(br#"{"alg":"EdDSA","typ":"JWT"}"#);
let payload = json!({
"agent_runtime_id": record.agent_runtime_id,
"agent_private_key": record.agent_private_key,
"account_id": record.account_id,
"chatgpt_user_id": record.chatgpt_user_id,
"email": record.email,
"plan_type": record.plan_type,
"chatgpt_account_is_fedramp": record.chatgpt_account_is_fedramp,
});
let payload_b64 = encode(&serde_json::to_vec(&payload)?);
let signature_b64 = encode(b"sig");
Ok(format!("{header_b64}.{payload_b64}.{signature_b64}"))
}
#[test]
fn plan_type_maps_known_plan() {
let codex_home = tempdir().unwrap();
+39 -5
View File
@@ -207,12 +207,12 @@ impl CodexAuth {
return Ok(Self::from_api_key(api_key));
}
if auth_mode == ApiAuthMode::AgentIdentity {
let Some(record) = auth_dot_json.agent_identity else {
let Some(agent_identity) = auth_dot_json.agent_identity else {
return Err(std::io::Error::other(
"agent identity auth is missing an agent identity record.",
"agent identity auth is missing an agent identity token.",
));
};
return Ok(Self::AgentIdentity(AgentIdentityAuth::new(record)));
return Self::from_agent_identity_jwt(&agent_identity);
}
let storage_mode = auth_dot_json.storage_mode(auth_credentials_store_mode);
@@ -245,6 +245,11 @@ impl CodexAuth {
)
}
pub fn from_agent_identity_jwt(jwt: &str) -> std::io::Result<Self> {
let record = AgentIdentityAuthRecord::from_agent_identity_jwt(jwt)?;
Ok(Self::AgentIdentity(AgentIdentityAuth::new(record)))
}
pub fn auth_mode(&self) -> AuthMode {
match self {
Self::ApiKey(_) => AuthMode::ApiKey,
@@ -318,10 +323,10 @@ impl CodexAuth {
pub async fn initialize_runtime(
&self,
chatgpt_base_url: Option<String>,
_chatgpt_base_url: Option<String>,
) -> std::io::Result<()> {
match self {
Self::AgentIdentity(auth) => auth.ensure_runtime(chatgpt_base_url).await,
Self::AgentIdentity(auth) => auth.ensure_runtime().await,
Self::ApiKey(_) | Self::Chatgpt(_) | Self::ChatgptAuthTokens(_) => Ok(()),
}
}
@@ -474,6 +479,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)
@@ -489,6 +495,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(
@@ -529,6 +542,23 @@ pub fn login_with_api_key(
save_auth(codex_home, &auth_dot_json, auth_credentials_store_mode)
}
/// Writes an `auth.json` that contains only the Agent Identity token.
pub fn login_with_agent_identity(
codex_home: &Path,
agent_identity: &str,
auth_credentials_store_mode: AuthCredentialsStoreMode,
) -> std::io::Result<()> {
AgentIdentityAuthRecord::from_agent_identity_jwt(agent_identity)?;
let auth_dot_json = AuthDotJson {
auth_mode: Some(ApiAuthMode::AgentIdentity),
openai_api_key: None,
tokens: None,
last_refresh: None,
agent_identity: Some(agent_identity.to_string()),
};
save_auth(codex_home, &auth_dot_json, auth_credentials_store_mode)
}
/// Writes an in-memory auth payload for externally managed ChatGPT tokens.
pub fn login_with_chatgpt_auth_tokens(
codex_home: &Path,
@@ -714,6 +744,10 @@ fn load_auth(
return Ok(None);
}
if let Some(agent_identity) = read_codex_agent_identity_from_env() {
return CodexAuth::from_agent_identity_jwt(&agent_identity).map(Some);
}
// Fall back to the configured persistent store (file/keyring/auto) for managed auth.
let storage = create_auth_storage(codex_home.to_path_buf(), auth_credentials_store_mode);
let auth_dot_json = match storage.load()? {
+19 -1
View File
@@ -19,6 +19,7 @@ use std::sync::Mutex;
use tracing::warn;
use crate::token_data::TokenData;
use codex_agent_identity::decode_agent_identity_jwt;
use codex_app_server_protocol::AuthMode;
use codex_config::types::AuthCredentialsStoreMode;
use codex_keyring_store::DefaultKeyringStore;
@@ -42,7 +43,7 @@ pub struct AuthDotJson {
pub last_refresh: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_identity: Option<AgentIdentityAuthRecord>,
pub agent_identity: Option<String>,
}
#[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Eq)]
@@ -56,6 +57,23 @@ pub struct AgentIdentityAuthRecord {
pub chatgpt_account_is_fedramp: bool,
}
impl AgentIdentityAuthRecord {
pub(crate) fn from_agent_identity_jwt(jwt: &str) -> std::io::Result<Self> {
let claims = decode_agent_identity_jwt(jwt, /*public_key_base64*/ None)
.map_err(std::io::Error::other)?;
Ok(Self {
agent_runtime_id: claims.agent_runtime_id,
agent_private_key: claims.agent_private_key,
account_id: claims.account_id,
chatgpt_user_id: claims.chatgpt_user_id,
email: claims.email,
plan_type: claims.plan_type,
chatgpt_account_is_fedramp: claims.chatgpt_account_is_fedramp,
})
}
}
pub(super) fn get_auth_file(codex_home: &Path) -> PathBuf {
codex_home.join("auth.json")
}
+49 -10
View File
@@ -7,7 +7,6 @@ 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]
@@ -59,20 +58,21 @@ async fn file_storage_save_persists_auth_dot_json() -> anyhow::Result<()> {
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 agent_identity = jwt_with_payload(json!({
"agent_runtime_id": "agent-runtime-id",
"agent_private_key": "private-key",
"account_id": "account-id",
"chatgpt_user_id": "user-id",
"email": "user@example.com",
"plan_type": "pro",
"chatgpt_account_is_fedramp": false,
}));
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,
}),
agent_identity: Some(agent_identity),
};
storage.save(&auth_dot_json)?;
@@ -82,6 +82,37 @@ async fn file_storage_round_trips_agent_identity_auth() -> anyhow::Result<()> {
Ok(())
}
#[tokio::test]
async fn file_storage_loads_agent_identity_as_jwt() -> anyhow::Result<()> {
let codex_home = tempdir()?;
let storage = FileAuthStorage::new(codex_home.path().to_path_buf());
let agent_identity_jwt = jwt_with_payload(json!({
"agent_runtime_id": "agent-runtime-id",
"agent_private_key": "private-key",
"account_id": "account-id",
"chatgpt_user_id": "user-id",
"email": "user@example.com",
"plan_type": "pro",
"chatgpt_account_is_fedramp": false,
}));
let auth_file = get_auth_file(codex_home.path());
std::fs::write(
&auth_file,
serde_json::to_string_pretty(&json!({
"auth_mode": "agentIdentity",
"agent_identity": agent_identity_jwt,
}))?,
)?;
let loaded = storage.load()?;
assert_eq!(
loaded.expect("auth should load").agent_identity.as_deref(),
Some(agent_identity_jwt.as_str())
);
Ok(())
}
#[test]
fn file_storage_delete_removes_auth_file() -> anyhow::Result<()> {
let dir = tempdir()?;
@@ -217,6 +248,14 @@ fn auth_with_prefix(prefix: &str) -> AuthDotJson {
}
}
fn jwt_with_payload(payload: serde_json::Value) -> String {
let encode = |bytes: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes);
let header_b64 = encode(br#"{"alg":"EdDSA","typ":"JWT"}"#);
let payload_b64 = encode(&serde_json::to_vec(&payload).expect("payload should serialize"));
let signature_b64 = encode(b"sig");
format!("{header_b64}.{payload_b64}.{signature_b64}")
}
#[test]
fn keyring_auth_storage_load_returns_deserialized_auth() -> anyhow::Result<()> {
let codex_home = tempdir()?;
+3
View File
@@ -22,6 +22,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;
@@ -37,9 +38,11 @@ pub use auth::UnauthorizedRecovery;
pub use auth::default_client;
pub use auth::enforce_login_restrictions;
pub use auth::load_auth_dot_json;
pub use auth::login_with_agent_identity;
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;