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

+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();