Compare commits

...

4 Commits

Author SHA1 Message Date
shijie-openai
16e07428ae Load cloud requirements for agent identity 2026-04-26 13:29:25 -07:00
shijie-openai
0391a4bb75 codex: fix CI failure on PR #19635 2026-04-26 11:52:25 -07:00
shijie-openai
656649cc9f Fix agent identity runtime auth flow 2026-04-26 11:25:49 -07:00
Edward Frazer
c5d5e7c57d feat: load agent identity from jwt env 2026-04-24 18:45:25 -07:00
14 changed files with 617 additions and 61 deletions

1
codex-rs/Cargo.lock generated
View File

@@ -1758,6 +1758,7 @@ dependencies = [
"codex-protocol",
"crypto_box",
"ed25519-dalek",
"jsonwebtoken",
"pretty_assertions",
"rand 0.9.3",
"reqwest",

View File

@@ -19,6 +19,7 @@ chrono = { workspace = true }
codex-protocol = { workspace = true }
crypto_box = { workspace = true }
ed25519-dalek = { workspace = true }
jsonwebtoken = { workspace = true }
rand = { workspace = true }
reqwest = { workspace = true, features = ["json"] }
serde = { workspace = true, features = ["derive"] }

View File

@@ -8,6 +8,7 @@ use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use chrono::SecondsFormat;
use chrono::Utc;
use codex_protocol::account::PlanType as AccountPlanType;
use codex_protocol::protocol::SessionSource;
use crypto_box::SecretKey as Curve25519SecretKey;
use ed25519_dalek::Signer as _;
@@ -15,10 +16,14 @@ use ed25519_dalek::SigningKey;
use ed25519_dalek::VerifyingKey;
use ed25519_dalek::pkcs8::DecodePrivateKey;
use ed25519_dalek::pkcs8::EncodePrivateKey;
use jsonwebtoken::Algorithm;
use jsonwebtoken::DecodingKey;
use jsonwebtoken::Validation;
use rand::TryRngCore;
use rand::rngs::OsRng;
use serde::Deserialize;
use serde::Serialize;
use serde::de::DeserializeOwned;
use sha2::Digest as _;
use sha2::Sha512;
@@ -50,6 +55,18 @@ pub struct GeneratedAgentKeyMaterial {
pub public_key_ssh: String,
}
/// Claims carried by an Agent Identity JWT.
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
pub struct AgentIdentityJwtClaims {
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,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
struct AgentAssertionEnvelope {
agent_runtime_id: String,
@@ -98,6 +115,43 @@ pub fn authorization_header_for_agent_task(
Ok(format!("AgentAssertion {serialized_assertion}"))
}
pub fn decode_agent_identity_jwt(
jwt: &str,
public_key_base64: Option<&str>,
) -> Result<AgentIdentityJwtClaims> {
let Some(public_key_base64) = public_key_base64 else {
return decode_agent_identity_jwt_payload(jwt);
};
let mut validation = Validation::new(Algorithm::EdDSA);
validation.required_spec_claims.clear();
validation.validate_exp = false;
validation.validate_aud = false;
let public_key = BASE64_STANDARD
.decode(public_key_base64)
.context("agent identity JWT public key is not valid base64")?;
let decoding_key = DecodingKey::from_ed_der(&public_key);
jsonwebtoken::decode::<AgentIdentityJwtClaims>(jwt, &decoding_key, &validation)
.map(|data| data.claims)
.context("failed to decode agent identity JWT")
}
fn decode_agent_identity_jwt_payload<T: DeserializeOwned>(jwt: &str) -> Result<T> {
let mut parts = jwt.split('.');
let (_header_b64, payload_b64, _sig_b64) = match (parts.next(), parts.next(), parts.next()) {
(Some(h), Some(p), Some(s)) if !h.is_empty() && !p.is_empty() && !s.is_empty() => (h, p, s),
_ => anyhow::bail!("invalid agent identity JWT format"),
};
anyhow::ensure!(parts.next().is_none(), "invalid agent identity JWT format");
let payload_bytes = URL_SAFE_NO_PAD
.decode(payload_b64)
.context("agent identity JWT payload is not valid base64url")?;
serde_json::from_slice(&payload_bytes).context("agent identity JWT payload is not valid JSON")
}
pub fn sign_task_registration_payload(
key: AgentIdentityKey<'_>,
timestamp: &str,
@@ -117,19 +171,27 @@ pub async fn register_agent_task(
signature: sign_task_registration_payload(key, &timestamp)?,
timestamp,
};
let url = agent_task_registration_url(chatgpt_base_url, key.agent_runtime_id);
let response = client
.post(agent_task_registration_url(
chatgpt_base_url,
key.agent_runtime_id,
))
.post(url)
.timeout(AGENT_TASK_REGISTRATION_TIMEOUT)
.json(&request)
.send()
.await
.context("failed to register agent task")?
.error_for_status()
.context("failed to register agent task")?
.context("failed to register agent task")?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
let body = if body.len() > 512 {
format!("{}...", body.chars().take(512).collect::<String>())
} else {
body
};
anyhow::bail!("failed to register agent task with status {status}: {body}");
}
let response = response
.json()
.await
.context("failed to decode agent task registration response")?;
@@ -323,6 +385,8 @@ mod tests {
use base64::Engine as _;
use ed25519_dalek::Signature;
use ed25519_dalek::Verifier as _;
use jsonwebtoken::EncodingKey;
use jsonwebtoken::Header;
use pretty_assertions::assert_eq;
use super::*;
@@ -404,6 +468,119 @@ mod tests {
);
}
#[test]
fn decode_agent_identity_jwt_reads_claims() {
let jwt = jwt_with_payload(serde_json::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 claims =
decode_agent_identity_jwt(&jwt, /*public_key_base64*/ None).expect("JWT should decode");
assert_eq!(
claims,
AgentIdentityJwtClaims {
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,
}
);
}
#[test]
fn decode_agent_identity_jwt_verifies_when_public_key_is_present() {
let mut secret_key_bytes = [0u8; 32];
secret_key_bytes[0] = 1;
let signing_key = SigningKey::from_bytes(&secret_key_bytes);
let private_key_pkcs8 = signing_key
.to_pkcs8_der()
.expect("private key should encode");
let public_key_base64 = BASE64_STANDARD.encode(signing_key.verifying_key().as_bytes());
let claims = AgentIdentityJwtClaims {
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,
};
let jwt = jsonwebtoken::encode(
&Header::new(Algorithm::EdDSA),
&serde_json::json!({
"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": "pro",
"chatgpt_account_is_fedramp": claims.chatgpt_account_is_fedramp,
}),
&EncodingKey::from_ed_der(private_key_pkcs8.as_bytes()),
)
.expect("JWT should encode");
let expected_claims = AgentIdentityJwtClaims {
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,
};
assert_eq!(
decode_agent_identity_jwt(&jwt, Some(&public_key_base64)).expect("JWT should verify"),
expected_claims
);
}
#[test]
fn decode_agent_identity_jwt_rejects_wrong_public_key() {
let mut signing_secret_key_bytes = [0u8; 32];
signing_secret_key_bytes[0] = 1;
let signing_key = SigningKey::from_bytes(&signing_secret_key_bytes);
let private_key_pkcs8 = signing_key
.to_pkcs8_der()
.expect("private key should encode");
let mut other_secret_key_bytes = [0u8; 32];
other_secret_key_bytes[0] = 2;
let other_public_key_base64 = BASE64_STANDARD.encode(
SigningKey::from_bytes(&other_secret_key_bytes)
.verifying_key()
.as_bytes(),
);
let jwt = jsonwebtoken::encode(
&Header::new(Algorithm::EdDSA),
&serde_json::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,
}),
&EncodingKey::from_ed_der(private_key_pkcs8.as_bytes()),
)
.expect("JWT should encode");
decode_agent_identity_jwt(&jwt, Some(&other_public_key_base64))
.expect_err("JWT should not verify");
}
#[test]
fn normalize_chatgpt_base_url_strips_codex_before_backend_api() {
assert_eq!(
@@ -411,4 +588,12 @@ mod tests {
"https://chatgpt.com/backend-api"
);
}
fn jwt_with_payload(payload: serde_json::Value) -> String {
let encode = |bytes: &[u8]| URL_SAFE_NO_PAD.encode(bytes);
let header_b64 = encode(br#"{"alg":"none","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}")
}
}

View File

@@ -9,8 +9,10 @@ use codex_utils_cli::CliConfigOverrides;
pub use debug_sandbox::run_command_under_landlock;
pub use debug_sandbox::run_command_under_seatbelt;
pub use debug_sandbox::run_command_under_windows;
pub use login::read_agent_identity_from_stdin;
pub use login::read_api_key_from_stdin;
pub use login::run_login_status;
pub use login::run_login_with_agent_identity;
pub use login::run_login_with_api_key;
pub use login::run_login_with_chatgpt;
pub use login::run_login_with_device_code;

View File

@@ -13,6 +13,7 @@ use codex_core::config::Config;
use codex_login::CLIENT_ID;
use codex_login::CodexAuth;
use codex_login::ServerOptions;
use codex_login::login_with_agent_identity;
use codex_login::login_with_api_key;
use codex_login::logout_with_revoke;
use codex_login::run_device_code_login;
@@ -34,6 +35,8 @@ const CHATGPT_LOGIN_DISABLED_MESSAGE: &str =
"ChatGPT login is disabled. Use API key login instead.";
const API_KEY_LOGIN_DISABLED_MESSAGE: &str =
"API key login is disabled. Use ChatGPT login instead.";
const AGENT_IDENTITY_LOGIN_DISABLED_MESSAGE: &str =
"Agent Identity login is disabled. Use API key login instead.";
const LOGIN_SUCCESS_MESSAGE: &str = "Successfully logged in";
/// Installs a small file-backed tracing layer for direct `codex login` flows.
@@ -187,31 +190,74 @@ pub async fn run_login_with_api_key(
}
}
pub async fn run_login_with_agent_identity(
cli_config_overrides: CliConfigOverrides,
agent_identity: String,
) -> ! {
let config = load_config_or_exit(cli_config_overrides).await;
let _login_log_guard = init_login_file_logging(&config);
tracing::info!("starting agent identity login flow");
if matches!(config.forced_login_method, Some(ForcedLoginMethod::Api)) {
eprintln!("{AGENT_IDENTITY_LOGIN_DISABLED_MESSAGE}");
std::process::exit(1);
}
match login_with_agent_identity(
&config.codex_home,
&agent_identity,
config.cli_auth_credentials_store_mode,
) {
Ok(_) => {
eprintln!("{LOGIN_SUCCESS_MESSAGE}");
std::process::exit(0);
}
Err(e) => {
eprintln!("Error logging in with Agent Identity: {e}");
std::process::exit(1);
}
}
}
pub fn read_api_key_from_stdin() -> String {
read_stdin_secret(
"--with-api-key expects the API key on stdin. Try piping it, e.g. `printenv OPENAI_API_KEY | codex login --with-api-key`.",
"Reading API key from stdin...",
"No API key provided via stdin.",
)
}
pub fn read_agent_identity_from_stdin() -> String {
read_stdin_secret(
"--with-agent-identity expects the Agent Identity token on stdin. Try piping it, e.g. `printenv CODEX_AGENT_IDENTITY | codex login --with-agent-identity`.",
"Reading Agent Identity token from stdin...",
"No Agent Identity token provided via stdin.",
)
}
fn read_stdin_secret(terminal_message: &str, reading_message: &str, empty_message: &str) -> String {
let mut stdin = std::io::stdin();
if stdin.is_terminal() {
eprintln!(
"--with-api-key expects the API key on stdin. Try piping it, e.g. `printenv OPENAI_API_KEY | codex login --with-api-key`."
);
eprintln!("{terminal_message}");
std::process::exit(1);
}
eprintln!("Reading API key from stdin...");
eprintln!("{reading_message}");
let mut buffer = String::new();
if let Err(err) = stdin.read_to_string(&mut buffer) {
eprintln!("Failed to read API key from stdin: {err}");
eprintln!("Failed to read stdin: {err}");
std::process::exit(1);
}
let api_key = buffer.trim().to_string();
if api_key.is_empty() {
eprintln!("No API key provided via stdin.");
let secret = buffer.trim().to_string();
if secret.is_empty() {
eprintln!("{empty_message}");
std::process::exit(1);
}
api_key
secret
}
/// Login using the OAuth device code flow.

View File

@@ -10,8 +10,10 @@ use codex_chatgpt::apply_command::run_apply_command;
use codex_cli::LandlockCommand;
use codex_cli::SeatbeltCommand;
use codex_cli::WindowsCommand;
use codex_cli::read_agent_identity_from_stdin;
use codex_cli::read_api_key_from_stdin;
use codex_cli::run_login_status;
use codex_cli::run_login_with_agent_identity;
use codex_cli::run_login_with_api_key;
use codex_cli::run_login_with_chatgpt;
use codex_cli::run_login_with_device_code;
@@ -366,6 +368,12 @@ struct LoginCommand {
)]
with_api_key: bool,
#[arg(
long = "with-agent-identity",
help = "Read the experimental Agent Identity token from stdin (e.g. `printenv CODEX_AGENT_IDENTITY | codex login --with-agent-identity`)"
)]
with_agent_identity: bool,
#[arg(
long = "api-key",
num_args = 0..=1,
@@ -947,7 +955,12 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
run_login_status(login_cli.config_overrides).await;
}
None => {
if login_cli.use_device_code {
if login_cli.with_api_key && login_cli.with_agent_identity {
eprintln!(
"Choose one login credential source: --with-api-key or --with-agent-identity."
);
std::process::exit(1);
} else if login_cli.use_device_code {
run_login_with_device_code(
login_cli.config_overrides,
login_cli.issuer_base_url,
@@ -962,6 +975,10 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
} else if login_cli.with_api_key {
let api_key = read_api_key_from_stdin();
run_login_with_api_key(login_cli.config_overrides, api_key).await;
} else if login_cli.with_agent_identity {
let agent_identity = read_agent_identity_from_stdin();
run_login_with_agent_identity(login_cli.config_overrides, agent_identity)
.await;
} else {
run_login_with_chatgpt(login_cli.config_overrides).await;
}

View File

@@ -0,0 +1,74 @@
use std::path::Path;
use anyhow::Result;
use predicates::str::contains;
use pretty_assertions::assert_eq;
use serde_json::Value;
use tempfile::TempDir;
const FAKE_AGENT_IDENTITY_JWT: &str = "eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.eyJhZ2VudF9ydW50aW1lX2lkIjoiYWdlbnQtcnVudGltZS1pZCIsImFnZW50X3ByaXZhdGVfa2V5IjoicHJpdmF0ZS1rZXkiLCJhY2NvdW50X2lkIjoiYWNjb3VudC0xMjMiLCJjaGF0Z3B0X3VzZXJfaWQiOiJ1c2VyLWlkIiwiZW1haWwiOiJ1c2VyQGV4YW1wbGUuY29tIiwicGxhbl90eXBlIjoicHJvIiwiY2hhdGdwdF9hY2NvdW50X2lzX2ZlZHJhbXAiOmZhbHNlfQ.c2ln";
fn codex_command(codex_home: &Path) -> Result<assert_cmd::Command> {
let mut cmd = assert_cmd::Command::new(codex_utils_cargo_bin::cargo_bin("codex")?);
cmd.env("CODEX_HOME", codex_home);
Ok(cmd)
}
fn write_file_auth_config(codex_home: &Path) -> Result<()> {
std::fs::write(
codex_home.join("config.toml"),
"cli_auth_credentials_store = \"file\"\n",
)?;
Ok(())
}
fn read_auth_json(codex_home: &Path) -> Result<Value> {
let auth_json = std::fs::read_to_string(codex_home.join("auth.json"))?;
Ok(serde_json::from_str(&auth_json)?)
}
#[test]
fn login_with_api_key_reads_stdin_and_writes_auth_json() -> Result<()> {
let codex_home = TempDir::new()?;
write_file_auth_config(codex_home.path())?;
let mut cmd = codex_command(codex_home.path())?;
cmd.args([
"-c",
"forced_login_method=\"api\"",
"login",
"--with-api-key",
])
.write_stdin("sk-test\n")
.assert()
.success()
.stderr(contains("Successfully logged in"));
let auth = read_auth_json(codex_home.path())?;
assert_eq!(auth["OPENAI_API_KEY"], "sk-test");
assert!(auth.get("tokens").is_none());
assert!(auth.get("agent_identity").is_none());
Ok(())
}
#[test]
fn login_with_agent_identity_reads_stdin_and_writes_auth_json() -> Result<()> {
let codex_home = TempDir::new()?;
write_file_auth_config(codex_home.path())?;
let mut cmd = codex_command(codex_home.path())?;
cmd.args(["login", "--with-agent-identity"])
.write_stdin(format!("{FAKE_AGENT_IDENTITY_JWT}\n"))
.assert()
.success()
.stderr(contains("Successfully logged in"));
let auth = read_auth_json(codex_home.path())?;
assert_eq!(auth["auth_mode"], "agentIdentity");
assert_eq!(auth["agent_identity"], FAKE_AGENT_IDENTITY_JWT);
assert!(auth["OPENAI_API_KEY"].is_null());
assert!(auth.get("tokens").is_none());
Ok(())
}

View File

@@ -179,6 +179,14 @@ fn auth_identity(auth: &CodexAuth) -> (Option<String>, Option<String>) {
(auth.get_chatgpt_user_id(), auth.get_account_id())
}
fn cloud_requirements_eligible_auth(auth: &CodexAuth) -> bool {
let Some(plan_type) = auth.account_plan_type() else {
return false;
};
auth.uses_codex_backend()
&& (plan_type.is_business_like() || matches!(plan_type, PlanType::Enterprise))
}
fn cache_payload_bytes(payload: &CloudRequirementsCacheSignedPayload) -> Option<Vec<u8>> {
serde_json::to_vec(&payload).ok()
}
@@ -329,12 +337,7 @@ impl CloudRequirementsService {
let Some(auth) = self.auth_manager.auth().await else {
return Ok(None);
};
let Some(plan_type) = auth.account_plan_type() else {
return Ok(None);
};
if !auth.uses_codex_backend()
|| !(plan_type.is_business_like() || matches!(plan_type, PlanType::Enterprise))
{
if !cloud_requirements_eligible_auth(&auth) {
return Ok(None);
}
let (chatgpt_user_id, account_id) = auth_identity(&auth);
@@ -549,12 +552,7 @@ impl CloudRequirementsService {
let Some(auth) = self.auth_manager.auth().await else {
return false;
};
let Some(plan_type) = auth.account_plan_type() else {
return false;
};
if !auth.uses_codex_backend()
|| !(plan_type.is_business_like() || matches!(plan_type, PlanType::Enterprise))
{
if !cloud_requirements_eligible_auth(&auth) {
return false;
}
@@ -1004,6 +1002,24 @@ mod tests {
auth_manager_with_plan_and_identity(plan_type, Some("user-12345"), Some("account-12345"))
}
fn agent_identity_auth_with_plan(plan_type: PlanType) -> CodexAuth {
let encode = |bytes: &[u8]| URL_SAFE_NO_PAD.encode(bytes);
let header_b64 = encode(br#"{"alg":"EdDSA","typ":"JWT"}"#);
let payload = json!({
"agent_runtime_id": "agent-runtime-123",
"agent_private_key": "private-key",
"account_id": "account-12345",
"chatgpt_user_id": "user-12345",
"email": "user@example.com",
"plan_type": plan_type,
"chatgpt_account_is_fedramp": false,
});
let payload_b64 = encode(&serde_json::to_vec(&payload).expect("payload"));
let signature_b64 = encode(b"sig");
let agent_identity = format!("{header_b64}.{payload_b64}.{signature_b64}");
CodexAuth::from_agent_identity_jwt(&agent_identity).expect("agent identity auth")
}
fn parse_for_fetch(contents: Option<&str>) -> Option<ConfigRequirementsToml> {
contents.and_then(|contents| parse_cloud_requirements(contents).ok().flatten())
}
@@ -1184,6 +1200,13 @@ mod tests {
);
}
#[test]
fn cloud_requirements_eligible_auth_allows_agent_identity_business_plan() {
let auth = agent_identity_auth_with_plan(PlanType::Business);
assert!(cloud_requirements_eligible_auth(&auth));
}
#[tokio::test]
async fn fetch_cloud_requirements_allows_business_like_usage_based_plan() {
let codex_home = tempdir().expect("tempdir");

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(|_| ())

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

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()? {

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")
}

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

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;