use super::*; use crate::auth::storage::FileAuthStorage; use crate::auth::storage::get_auth_file; use crate::token_data::IdTokenInfo; use codex_protocol::account::PlanType as AccountPlanType; use codex_protocol::auth::AuthMode; use codex_protocol::auth::KnownPlan as InternalKnownPlan; use codex_protocol::auth::PlanType as InternalPlanType; use codex_protocol::protocol::SessionSource; use anyhow::Context; use base64::Engine; use codex_protocol::config_types::ForcedLoginMethod; use codex_protocol::config_types::ModelProviderAuthInfo; use codex_protocol::shell_environment::OPENAI_FEDERATION_RULE_ID_ENV_VAR; use codex_protocol::shell_environment::OPENAI_IDENTITY_TOKEN_FILE_ENV_VAR; use pretty_assertions::assert_eq; use serde::Serialize; use serde_json::json; use std::sync::Arc; use std::sync::atomic::AtomicUsize; use std::sync::atomic::Ordering; use tempfile::TempDir; use tempfile::tempdir; use wiremock::Mock; use wiremock::MockServer; use wiremock::ResponseTemplate; use wiremock::matchers::body_partial_json; use wiremock::matchers::header; use wiremock::matchers::method; use wiremock::matchers::path; const WORKSPACE_ID_ALLOWED: &str = "123e4567-e89b-42d3-a456-426614174000"; const WORKSPACE_ID_SECOND_ALLOWED: &str = "123e4567-e89b-42d3-a456-426614174001"; const WORKSPACE_ID_DISALLOWED: &str = "123e4567-e89b-42d3-a456-426614174002"; #[test] fn header_auth_exposes_a_valid_chatgpt_account_id() { for (account_id_header, expected_account_id) in [ ( Some(WORKSPACE_ID_ALLOWED.as_bytes()), Some(WORKSPACE_ID_ALLOWED), ), (None, None), (Some(b""), None), (Some(b" account "), None), (Some(&[0xff]), None), ] { let mut headers = http::HeaderMap::new(); if let Some(account_id_header) = account_id_header { headers.insert( "chatgpt-account-id", http::HeaderValue::from_bytes(account_id_header).expect("valid header bytes"), ); } let auth = CodexAuth::Headers(AuthHeaders::new(headers)); assert_eq!(auth.get_account_id().as_deref(), expected_account_id); } } #[tokio::test] async fn cancelled_refresh_marks_only_started_exchanges_as_unsettled() -> anyhow::Result<()> { let home = tempdir()?; write_auth_file( AuthFileParams { openai_api_key: None, chatgpt_plan_type: Some("pro".into()), chatgpt_account_id: Some(WORKSPACE_ID_ALLOWED.into()), }, home.path(), )?; let server = MockServer::start().await; let storage = FileAuthStorage::new(home.path().to_path_buf()); let mut stored = storage.load()?.context("stored credentials")?; let tokens = stored.tokens.as_mut().context("stored tokens")?; tokens.oauth_provenance = Some(AccountOAuthProvenance { issuer: server.uri(), client_id: "codex-client".into(), }); // Workspace switching retains the original ID and refresh tokens. tokens.account_id = Some(WORKSPACE_ID_SECOND_ALLOWED.into()); tokens.access_token = fake_jwt_for_auth_file_params(&AuthFileParams { openai_api_key: None, chatgpt_plan_type: Some("business".into()), chatgpt_account_id: Some(WORKSPACE_ID_SECOND_ALLOWED.into()), })?; storage.save(&stored)?; let manager = Arc::new( AuthManager::new_from_auth_config( build_config( home.path(), /*forced_login_method*/ None, /*forced_chatgpt_workspace_id*/ None, ) .await, /*enable_codex_api_key_env*/ false, /*allowed_auth_modes*/ None, ) .await, ); let initial = manager.auth_cached().context("initial auth")?; // Poll directly while the guard is held, then cancel before any OAuth request. let guard = manager.refresh_lock.acquire().await?; let mut waiting = Box::pin(manager.refresh_token_if_unchanged(&initial)); assert!( waiting .as_mut() .poll(&mut std::task::Context::from_waker( std::task::Waker::noop(), )) .is_pending() ); drop(waiting); assert!(!manager.is_chatgpt_refresh_unsettled(&initial)?); assert!(server.received_requests().await.unwrap().is_empty()); drop(guard); let received = Arc::new(tokio::sync::Notify::new()); let notify = Arc::clone(&received); Mock::given(path("/oauth/token")) .respond_with(move |_: &wiremock::Request| { notify.notify_one(); ResponseTemplate::new(200) .set_delay(Duration::from_secs(60)) .set_body_json(json!({"access_token": "late", "refresh_token": "late-refresh"})) }) .expect(1) .mount(&server) .await; let refreshing = Arc::clone(&manager); let task = tokio::spawn(async move { refreshing.refresh_token_from_authority().await }); received.notified().await; assert!(manager.is_chatgpt_refresh_unsettled(&initial)?); task.abort(); assert!(task.await.unwrap_err().is_cancelled()); assert!(manager.is_chatgpt_refresh_unsettled(&initial)?); // A later known failure cannot resolve an earlier cancelled exchange. server.reset().await; Mock::given(path("/oauth/token")) .respond_with(ResponseTemplate::new(500)) .expect(1) .mount(&server) .await; assert!(manager.refresh_token_from_authority().await.is_err()); assert!(manager.is_chatgpt_refresh_unsettled(&initial)?); // A replacement is usable, and a completed refresh settles its own generation. server.reset().await; Mock::given(path("/oauth/token")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "access_token": "completed", "refresh_token": "completed-refresh", }))) .expect(1) .mount(&server) .await; let tokens = stored.tokens.as_mut().context("replacement tokens")?; tokens.access_token = "replacement".into(); tokens.refresh_token = "replacement-refresh".into(); storage.save(&stored)?; manager.reload().await; let replacement = manager.auth_cached().context("replacement auth")?; assert!(!manager.is_chatgpt_refresh_unsettled(&replacement)?); manager.refresh_token_from_authority().await?; assert!(!manager.is_chatgpt_refresh_unsettled(&replacement)?); assert_eq!( manager .auth_cached() .context("refreshed auth")? .get_token()?, "completed" ); Ok(()) } #[tokio::test] async fn refresh_without_id_token() -> anyhow::Result<()> { let codex_home = tempdir()?; let fake_jwt = write_auth_file( AuthFileParams { openai_api_key: None, chatgpt_plan_type: Some("pro".to_string()), chatgpt_account_id: None, }, codex_home.path(), )?; let storage = create_auth_storage( codex_home.path().to_path_buf(), AuthCredentialsStoreMode::File, AuthKeyringBackendKind::default(), ); let provenance = AccountOAuthProvenance { issuer: "https://idp.example".to_string(), client_id: "codex-client".to_string(), }; let initial = storage .load()? .context("stored auth")? .tokens .context("stored tokens")?; let updated = super::persist_tokens( &storage, &initial, Some(provenance.clone()), /*id_token*/ None, Some("new-access-token".to_string()), Some("new-refresh-token".to_string()), )?; let tokens = updated.tokens.context("persisted auth is missing tokens")?; assert_eq!(tokens.id_token.raw_jwt, fake_jwt); assert_eq!(tokens.access_token, "new-access-token"); assert_eq!(tokens.refresh_token, "new-refresh-token"); assert_eq!(tokens.oauth_provenance, Some(provenance.clone())); let updated = super::persist_tokens( &storage, &tokens, /*provenance*/ None, /*id_token*/ None, Some("later-access-token".to_string()), /*refresh_token*/ None, )?; let previous = tokens; let tokens = updated.tokens.context("persisted auth is missing tokens")?; assert_eq!(tokens.refresh_token, "new-refresh-token"); assert_eq!(tokens.oauth_provenance, None); // Reject this stale refresh response: newer tokens have already been saved // for the same account. let before = storage.load()?; assert!( super::persist_tokens( &storage, &previous, Some(provenance), /*id_token*/ None, Some("stale-access-token".into()), Some("stale-refresh-token".into()), ) .is_err() ); assert_eq!(storage.load()?, before); Ok(()) } #[test] fn login_with_api_key_overwrites_existing_auth_json() { let dir = tempdir().unwrap(); let auth_path = dir.path().join("auth.json"); let stale_auth = json!({ "OPENAI_API_KEY": "sk-old", "tokens": { "id_token": "stale.header.payload", "access_token": "stale-access", "refresh_token": "stale-refresh", "account_id": "stale-acc" } }); for api_key in [ "sk-new", "sk-proj-fedramp-synthetic-test-key", "sk-svcacct-fedramp-synthetic-test-key", ] { std::fs::write( &auth_path, serde_json::to_string_pretty(&stale_auth).unwrap(), ) .unwrap(); super::login_with_api_key( dir.path(), api_key, AuthCredentialsStoreMode::File, AuthKeyringBackendKind::default(), ) .expect("login_with_api_key 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.openai_api_key.as_deref(), Some(api_key)); assert!(auth.tokens.is_none(), "tokens should be cleared"); } } #[tokio::test] #[serial(codex_auth_env)] async fn login_with_access_token_writes_agent_identity_jwt() { let dir = tempdir().unwrap(); let auth_path = dir.path().join("auth.json"); let record = agent_identity_record(WORKSPACE_ID_ALLOWED); let agent_identity = signed_agent_identity_jwt(&record, json!(record.plan_type)).expect("signed agent identity"); let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/backend-api/wham/agent-identities/jwks")) .respond_with(ResponseTemplate::new(200).set_body_json(test_jwks_body())) .expect(1) .mount(&server) .await; let authapi_base_url = server.uri(); let chatgpt_base_url = format!("{authapi_base_url}/backend-api"); super::login_with_access_token( dir.path(), &agent_identity, AuthCredentialsStoreMode::File, /*forced_chatgpt_workspace_id*/ None, Some(&chatgpt_base_url), AuthKeyringBackendKind::default(), &crate::test_support::transport_default_auth_route_config(), ) .await .expect("login_with_access_token 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, Some(AgentIdentityStorage::Jwt(agent_identity)) ); assert!(auth.tokens.is_none(), "tokens should be cleared"); assert!(auth.openai_api_key.is_none(), "API key should be cleared"); server.verify().await; } #[tokio::test] async fn login_with_access_token_rejects_agent_identity_workspace_mismatch() { let dir = tempdir().unwrap(); let record = agent_identity_record(WORKSPACE_ID_DISALLOWED); let agent_identity = signed_agent_identity_jwt(&record, json!(record.plan_type)).expect("signed agent identity"); let server = MockServer::start().await; let chatgpt_base_url = format!("{}/backend-api", server.uri()); let allowed_workspaces = [WORKSPACE_ID_ALLOWED.to_string()]; let err = super::login_with_access_token( dir.path(), &agent_identity, AuthCredentialsStoreMode::File, Some(&allowed_workspaces), Some(&chatgpt_base_url), AuthKeyringBackendKind::default(), &crate::test_support::transport_default_auth_route_config(), ) .await .expect_err("agent identity workspace mismatch should fail"); assert_eq!(err.kind(), std::io::ErrorKind::PermissionDenied); assert!(!get_auth_file(dir.path()).exists()); assert!(server.received_requests().await.unwrap().is_empty()); } #[tokio::test] #[serial(codex_auth_env)] async fn agent_identity_jwt_uses_explicit_staging_endpoint_overrides() -> anyhow::Result<()> { let jwks_server = MockServer::start().await; let authapi_server = MockServer::start().await; let record = agent_identity_record(WORKSPACE_ID_ALLOWED); let jwt = signed_agent_identity_jwt(&record, json!(record.plan_type))?; Mock::given(method("GET")) .and(path("/api/codex/agent-identities/jwks")) .respond_with(ResponseTemplate::new(200).set_body_json(test_jwks_body())) .expect(1) .mount(&jwks_server) .await; mock_agent_task_registration( &authapi_server, "/api/accounts", &record.agent_runtime_id, "task-id", ) .await; let authapi_base_url = format!("{}/api/accounts/", authapi_server.uri()); let _authapi_guard = EnvVarGuard::set("CODEX_AGENT_IDENTITY_AUTHAPI_BASE_URL", &authapi_base_url); let jwks_base_url = format!("{}/api/codex/", jwks_server.uri()); let _jwks_guard = EnvVarGuard::set("CODEX_AGENT_IDENTITY_JWKS_BASE_URL", &jwks_base_url); let auth = CodexAuth::from_agent_identity_jwt( &jwt, Some(ChatGptEnvironment::Staging.chatgpt_base_url()), &crate::test_support::transport_default_auth_route_config(), ) .await?; let CodexAuth::AgentIdentity(agent_identity) = auth else { panic!("JWT should load as agent identity auth"); }; assert_eq!(agent_identity.run_task_id(), "task-id"); jwks_server.verify().await; authapi_server.verify().await; Ok(()) } #[tokio::test] #[serial(codex_auth_env)] async fn agent_identity_jwt_supports_existing_staging_launcher() -> anyhow::Result<()> { let jwks_server = MockServer::start().await; let authapi_server = MockServer::start().await; let record = agent_identity_record(WORKSPACE_ID_ALLOWED); let jwt = signed_agent_identity_jwt(&record, json!(record.plan_type))?; Mock::given(method("GET")) .and(path("/api/codex/agent-identities/jwks")) .respond_with(ResponseTemplate::new(200).set_body_json(test_jwks_body())) .expect(1) .mount(&jwks_server) .await; mock_agent_task_registration( &authapi_server, "/api/accounts", &record.agent_runtime_id, "task-id", ) .await; let authapi_base_url = format!("{}/api/accounts", authapi_server.uri()); let _authapi_guard = EnvVarGuard::set("CODEX_AGENT_IDENTITY_AUTHAPI_BASE_URL", &authapi_base_url); let jwks_base_url = format!("{}/api/codex", jwks_server.uri()); let _jwks_guard = EnvVarGuard::set("CODEX_AGENT_IDENTITY_JWKS_BASE_URL", &jwks_base_url); let auth = CodexAuth::from_agent_identity_jwt( &jwt, Some(&jwks_base_url), &crate::test_support::transport_default_auth_route_config(), ) .await?; let CodexAuth::AgentIdentity(agent_identity) = auth else { panic!("JWT should load as agent identity auth"); }; assert_eq!(agent_identity.run_task_id(), "task-id"); jwks_server.verify().await; authapi_server.verify().await; Ok(()) } #[test] #[serial(codex_auth_env)] fn agent_identity_authapi_override_preserves_chatgpt_environment_validation() { let _authapi_guard = EnvVarGuard::set( "CODEX_AGENT_IDENTITY_AUTHAPI_BASE_URL", "https://authapi.example/api/accounts", ); let _jwks_guard = EnvVarGuard::set( "CODEX_AGENT_IDENTITY_JWKS_BASE_URL", "https://jwks.example/api/codex", ); let error = agent_identity_authapi_base_url(Some("https://attacker.example/backend-api")) .expect_err("AuthAPI overrides must not bypass ChatGPT environment validation"); assert_eq!( error.to_string(), "Agent Identity only supports production and staging ChatGPT environments" ); } #[test] #[serial(codex_auth_env)] fn agent_identity_custom_jwks_base_requires_explicit_authapi_override() { let _authapi_guard = EnvVarGuard::remove("CODEX_AGENT_IDENTITY_AUTHAPI_BASE_URL"); let jwks_base_url = "https://jwks.example/api/codex"; let _jwks_guard = EnvVarGuard::set("CODEX_AGENT_IDENTITY_JWKS_BASE_URL", jwks_base_url); let error = agent_identity_authapi_base_url(Some(jwks_base_url)) .expect_err("custom JWKS bases must also explicitly configure AuthAPI"); assert_eq!( error.to_string(), "Agent Identity only supports production and staging ChatGPT environments" ); } #[tokio::test] #[serial(codex_auth_env)] async fn agent_identity_jwks_override_preserves_chatgpt_environment_validation() { let record = agent_identity_record(WORKSPACE_ID_ALLOWED); let jwt = signed_agent_identity_jwt(&record, json!(record.plan_type)).expect("signed agent identity"); let _jwks_guard = EnvVarGuard::set( "CODEX_AGENT_IDENTITY_JWKS_BASE_URL", "https://jwks.example/api/codex", ); let error = verified_record_from_jwt( &jwt, "https://attacker.example/backend-api", &crate::test_support::transport_default_auth_route_config(), ) .await .expect_err("JWKS overrides must not bypass ChatGPT environment validation"); assert_eq!( error.to_string(), "Agent Identity only supports production and staging ChatGPT environments" ); } #[tokio::test] #[serial(codex_auth_env)] async fn stored_agent_identity_jwt_keeps_auth_json_unchanged() -> anyhow::Result<()> { let _access_token_guard = remove_access_token_env_var(); let codex_home = tempdir()?; let record = agent_identity_record(WORKSPACE_ID_ALLOWED); let agent_identity = signed_agent_identity_jwt(&record, json!(record.plan_type)).expect("signed agent identity"); let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/backend-api/wham/agent-identities/jwks")) .respond_with(ResponseTemplate::new(200).set_body_json(test_jwks_body())) .expect(1) .mount(&server) .await; mock_agent_task_registration(&server, "", &record.agent_runtime_id, "task-id").await; let authapi_base_url = server.uri(); let chatgpt_base_url = format!("{authapi_base_url}/backend-api"); save_auth( codex_home.path(), &AuthDotJson { auth_mode: Some(AuthMode::AgentIdentity), openai_api_key: None, tokens: None, last_refresh: None, agent_identity: Some(AgentIdentityStorage::Jwt(agent_identity.clone())), personal_access_token: None, bedrock_api_key: None, bedrock_access_keys: None, }, AuthCredentialsStoreMode::File, AuthKeyringBackendKind::Direct, )?; let auth = super::load_auth( codex_home.path(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, /*allowed_login_methods*/ None, /*forced_chatgpt_workspace_id*/ None, Some(&chatgpt_base_url), AuthKeyringBackendKind::Direct, Some(&authapi_base_url), &crate::test_support::transport_default_auth_route_config(), /*cached_auth*/ None, /*allowed_auth_modes*/ None, ) .await? .expect("auth should load"); let CodexAuth::AgentIdentity(agent_identity_auth) = auth else { panic!("stored JWT should load as agent identity auth"); }; assert_eq!(agent_identity_auth.run_task_id(), "task-id"); let storage = FileAuthStorage::new(codex_home.path().to_path_buf()); let auth = storage .try_read_auth_json(&get_auth_file(codex_home.path())) .expect("auth.json should parse"); assert_eq!( auth.agent_identity, Some(AgentIdentityStorage::Jwt(agent_identity)) ); server.verify().await; Ok(()) } #[tokio::test] #[serial(codex_auth_env)] async fn login_with_access_token_writes_only_personal_access_token() { let dir = tempdir().unwrap(); let auth_path = dir.path().join("auth.json"); let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/v1/user-auth-credential/whoami")) .and(header("authorization", "Bearer at-login-test")) .respond_with( ResponseTemplate::new(200) .set_body_json(personal_access_token_whoami(WORKSPACE_ID_ALLOWED)), ) .expect(1) .mount(&server) .await; let _authapi_guard = EnvVarGuard::set("CODEX_AUTHAPI_BASE_URL", &server.uri()); let allowed_workspaces = [WORKSPACE_ID_ALLOWED.to_string()]; super::login_with_access_token( dir.path(), "at-login-test", AuthCredentialsStoreMode::File, Some(&allowed_workspaces), /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), &crate::test_support::transport_default_auth_route_config(), ) .await .expect("personal access token login 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, AuthDotJson { auth_mode: None, openai_api_key: None, tokens: None, last_refresh: None, agent_identity: None, personal_access_token: Some("at-login-test".to_string()), bedrock_api_key: None, bedrock_access_keys: None, } ); assert_eq!(auth.resolved_mode(), AuthMode::PersonalAccessToken); let persisted: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(auth_path).unwrap()).unwrap(); assert!(persisted.get("auth_mode").is_none()); server.verify().await; } #[tokio::test] #[serial(codex_auth_env)] async fn login_with_access_token_rejects_personal_access_token_workspace_mismatch() { let dir = tempdir().unwrap(); let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/v1/user-auth-credential/whoami")) .and(header("authorization", "Bearer at-workspace-mismatch")) .respond_with( ResponseTemplate::new(200) .set_body_json(personal_access_token_whoami(WORKSPACE_ID_DISALLOWED)), ) .expect(1) .mount(&server) .await; let _authapi_guard = EnvVarGuard::set("CODEX_AUTHAPI_BASE_URL", &server.uri()); let allowed_workspaces = [WORKSPACE_ID_ALLOWED.to_string()]; let err = super::login_with_access_token( dir.path(), "at-workspace-mismatch", AuthCredentialsStoreMode::File, Some(&allowed_workspaces), /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), &crate::test_support::transport_default_auth_route_config(), ) .await .expect_err("personal access token workspace mismatch should fail"); assert_eq!(err.kind(), std::io::ErrorKind::PermissionDenied); assert!( !get_auth_file(dir.path()).exists(), "workspace mismatch should not write auth.json" ); server.verify().await; } #[tokio::test] #[serial(codex_auth_env)] async fn login_with_access_token_rejects_invalid_personal_access_token() { let dir = tempdir().unwrap(); let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/v1/user-auth-credential/whoami")) .respond_with(ResponseTemplate::new(403)) .expect(1) .mount(&server) .await; let _authapi_guard = EnvVarGuard::set("CODEX_AUTHAPI_BASE_URL", &server.uri()); let err = super::login_with_access_token( dir.path(), "at-invalid-login", AuthCredentialsStoreMode::File, /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), &crate::test_support::transport_default_auth_route_config(), ) .await .expect_err("invalid personal access token should fail"); assert_eq!(err.kind(), std::io::ErrorKind::Other); assert!( !get_auth_file(dir.path()).exists(), "invalid personal access token should not write auth.json" ); server.verify().await; } #[tokio::test] async fn login_with_access_token_rejects_invalid_jwt() { let dir = tempdir().unwrap(); let err = super::login_with_access_token( dir.path(), "not-a-jwt", AuthCredentialsStoreMode::File, /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), &crate::test_support::transport_default_auth_route_config(), ) .await .expect_err("invalid access token should fail"); assert_eq!(err.kind(), std::io::ErrorKind::Other); assert!( !get_auth_file(dir.path()).exists(), "invalid access token should not write auth.json" ); } #[tokio::test] #[serial(codex_auth_env)] async fn chatgpt_auth_registers_agent_identity_when_enabled() -> anyhow::Result<()> { let codex_home = tempdir()?; 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(), )?; let storage = FileAuthStorage::new(codex_home.path().to_path_buf()); let mut stored = storage.load()?.expect("auth should exist"); stored.tokens.as_mut().unwrap().login_owner_id = Some("login-a".into()); storage.save(&stored)?; let auth = super::load_auth( codex_home.path(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, /*allowed_login_methods*/ None, /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, /*agent_identity_authapi_base_url*/ None, &crate::test_support::transport_default_auth_route_config(), /*cached_auth*/ None, /*allowed_auth_modes*/ None, ) .await? .expect("auth should load"); // An older client rewrites the same credentials without login metadata. stored.tokens.as_mut().unwrap().login_owner_id = None; storage.save(&stored)?; assert!( auth.agent_identity_auth( AgentIdentityAuthPolicy::JwtOnly, /*agent_identity_authapi_base_url*/ None, /*forced_chatgpt_workspace_id*/ None, &crate::test_support::transport_default_auth_route_config(), SessionSource::Cli, ) .await? .is_none() ); let server = MockServer::start().await; Mock::given(method("POST")) .and(path("/v1/agent/register")) .and(header("authorization", "Bearer test-access-token")) .and(body_partial_json(json!({ "abom": { "agent_harness_id": "codex-cli", }, "capabilities": ["responsesapi"], "ttl": null, }))) .respond_with(ResponseTemplate::new(/*s*/ 200).set_body_json(json!({ "agent_runtime_id": "agent-runtime-123", }))) .expect(/*r*/ 1) .mount(&server) .await; mock_agent_task_registration(&server, "", "agent-runtime-123", "task-123").await; let agent_auth = auth .agent_identity_auth( AgentIdentityAuthPolicy::ChatGptAuth, Some(&server.uri()), /*forced_chatgpt_workspace_id*/ None, &crate::test_support::transport_default_auth_route_config(), SessionSource::Cli, ) .await? .expect("agent identity should register"); let reused = auth .agent_identity_auth( AgentIdentityAuthPolicy::ChatGptAuth, Some(&server.uri()), /*forced_chatgpt_workspace_id*/ None, &crate::test_support::transport_default_auth_route_config(), SessionSource::Cli, ) .await? .expect("agent identity should be reused"); assert_eq!( agent_auth.record().agent_runtime_id, reused.record().agent_runtime_id ); assert_eq!(agent_auth.run_task_id(), "task-123"); assert_eq!(reused.run_task_id(), "task-123"); assert_eq!(agent_auth.record().agent_runtime_id, "agent-runtime-123"); assert_eq!(agent_auth.record().account_id, "account-123"); assert_eq!(agent_auth.record().chatgpt_user_id, "user-12345"); assert_eq!(agent_auth.record().task_id.as_deref(), Some("task-123")); assert_eq!(reused.record().task_id.as_deref(), Some("task-123")); let persisted = auth .stored_managed_chatgpt_agent_identity_record("account-123") .expect("identity should persist"); assert_eq!(persisted.agent_runtime_id, "agent-runtime-123"); assert_eq!(persisted.task_id.as_deref(), Some("task-123")); stored.agent_identity = Some(AgentIdentityStorage::Record(agent_auth.record().clone())); assert_eq!(storage.load()?, Some(stored)); let reloaded = super::load_auth( codex_home.path(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, /*allowed_login_methods*/ None, /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, /*agent_identity_authapi_base_url*/ None, &crate::test_support::transport_default_auth_route_config(), /*cached_auth*/ None, /*allowed_auth_modes*/ None, ) .await? .expect("auth should reload"); let reloaded_agent_auth = reloaded .agent_identity_auth( AgentIdentityAuthPolicy::ChatGptAuth, Some(&server.uri()), /*forced_chatgpt_workspace_id*/ None, &crate::test_support::transport_default_auth_route_config(), SessionSource::Cli, ) .await? .expect("agent identity should reload from storage"); assert_eq!( reloaded_agent_auth.record().agent_runtime_id, "agent-runtime-123" ); assert_eq!(reloaded_agent_auth.run_task_id(), "task-123"); Ok(()) } #[tokio::test] #[serial(codex_auth_env)] async fn chatgpt_auth_retries_transient_agent_identity_registration() -> anyhow::Result<()> { let codex_home = tempdir()?; 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(), )?; let auth = super::load_auth( codex_home.path(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, /*allowed_login_methods*/ None, /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, /*agent_identity_authapi_base_url*/ None, &crate::test_support::transport_default_auth_route_config(), /*cached_auth*/ None, /*allowed_auth_modes*/ None, ) .await? .expect("auth should load"); let server = MockServer::start().await; let registration_count = Arc::new(AtomicUsize::new(0)); let response_count = Arc::clone(®istration_count); Mock::given(method("POST")) .and(path("/v1/agent/register")) .respond_with(move |_request: &wiremock::Request| { if response_count.fetch_add(1, Ordering::SeqCst) < 2 { ResponseTemplate::new(/*status*/ 503) } else { ResponseTemplate::new(/*status*/ 200).set_body_json(json!({ "agent_runtime_id": "agent-runtime-123", })) } }) .expect(/*requests*/ 3) .mount(&server) .await; mock_agent_task_registration(&server, "", "agent-runtime-123", "task-123").await; let agent_auth = auth .agent_identity_auth( AgentIdentityAuthPolicy::ChatGptAuth, Some(&server.uri()), /*forced_chatgpt_workspace_id*/ None, &crate::test_support::transport_default_auth_route_config(), SessionSource::Cli, ) .await? .expect("agent identity should register after retries"); assert_eq!(registration_count.load(Ordering::SeqCst), 3); assert_eq!(agent_auth.record().agent_runtime_id, "agent-runtime-123"); assert_eq!(agent_auth.record().task_id.as_deref(), Some("task-123")); assert_eq!( auth.stored_managed_chatgpt_agent_identity_record("account-123") .and_then(|record| record.task_id), Some("task-123".to_string()) ); Ok(()) } #[tokio::test] #[serial(codex_auth_env)] async fn chatgpt_auth_registration_retry_exhaustion_is_fallback_eligible() -> anyhow::Result<()> { let codex_home = tempdir()?; 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(), )?; let auth = super::load_auth( codex_home.path(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, /*allowed_login_methods*/ None, /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, /*agent_identity_authapi_base_url*/ None, &crate::test_support::transport_default_auth_route_config(), /*cached_auth*/ None, /*allowed_auth_modes*/ None, ) .await? .expect("auth should load"); let server = MockServer::start().await; Mock::given(method("POST")) .and(path("/v1/agent/register")) .respond_with(ResponseTemplate::new(/*status*/ 503)) .expect(/*requests*/ 3) .mount(&server) .await; let err = auth .agent_identity_auth( AgentIdentityAuthPolicy::ChatGptAuth, Some(&server.uri()), /*forced_chatgpt_workspace_id*/ None, &crate::test_support::transport_default_auth_route_config(), SessionSource::Cli, ) .await .expect_err("retry exhaustion should return an error"); assert!(AgentIdentityAuthError::bootstrap_unavailable(&err).is_some()); assert!( auth.stored_managed_chatgpt_agent_identity_record("account-123") .is_none() ); Ok(()) } #[tokio::test] #[serial(codex_auth_env)] async fn chatgpt_auth_task_registration_retry_exhaustion_is_fallback_eligible() -> anyhow::Result<()> { let codex_home = tempdir()?; 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(), )?; let mut record = agent_identity_record("account-123"); record.chatgpt_user_id = "user-12345".to_string(); record.email = Some("user@example.com".to_string()); let storage = FileAuthStorage::new(codex_home.path().to_path_buf()); let auth_path = get_auth_file(codex_home.path()); let mut auth_json = storage.try_read_auth_json(&auth_path)?; auth_json.agent_identity = Some(AgentIdentityStorage::Record(record.clone())); storage.save(&auth_json)?; let auth = super::load_auth( codex_home.path(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, /*allowed_login_methods*/ None, /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, /*agent_identity_authapi_base_url*/ None, &crate::test_support::transport_default_auth_route_config(), /*cached_auth*/ None, /*allowed_auth_modes*/ None, ) .await? .expect("auth should load"); let server = MockServer::start().await; Mock::given(method("POST")) .and(path(format!( "/v1/agent/{}/task/register", record.agent_runtime_id ))) .respond_with(ResponseTemplate::new(/*status*/ 503)) .expect(/*requests*/ 3) .mount(&server) .await; let err = auth .agent_identity_auth( AgentIdentityAuthPolicy::ChatGptAuth, Some(&server.uri()), /*forced_chatgpt_workspace_id*/ None, &crate::test_support::transport_default_auth_route_config(), SessionSource::Cli, ) .await .expect_err("task retry exhaustion should return an error"); assert!(AgentIdentityAuthError::bootstrap_unavailable(&err).is_some()); record.task_id = None; assert_eq!( auth.stored_managed_chatgpt_agent_identity_record("account-123"), Some(record) ); Ok(()) } #[tokio::test] #[serial(codex_auth_env)] async fn chatgpt_auth_non_retryable_registration_error_is_hard_failure() -> anyhow::Result<()> { let codex_home = tempdir()?; 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(), )?; let auth = super::load_auth( codex_home.path(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, /*allowed_login_methods*/ None, /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, /*agent_identity_authapi_base_url*/ None, &crate::test_support::transport_default_auth_route_config(), /*cached_auth*/ None, /*allowed_auth_modes*/ None, ) .await? .expect("auth should load"); let server = MockServer::start().await; Mock::given(method("POST")) .and(path("/v1/agent/register")) .respond_with(ResponseTemplate::new(/*status*/ 403)) .expect(/*requests*/ 1) .mount(&server) .await; let err = auth .agent_identity_auth( AgentIdentityAuthPolicy::ChatGptAuth, Some(&server.uri()), /*forced_chatgpt_workspace_id*/ None, &crate::test_support::transport_default_auth_route_config(), SessionSource::Cli, ) .await .expect_err("hard registration failure should return an error"); assert!(AgentIdentityAuthError::bootstrap_unavailable(&err).is_none()); assert!( auth.stored_managed_chatgpt_agent_identity_record("account-123") .is_none() ); Ok(()) } #[tokio::test] #[serial(codex_auth_env)] async fn agent_identity_jwt_task_registration_retry_exhaustion_is_strict() -> anyhow::Result<()> { let record = agent_identity_record(WORKSPACE_ID_ALLOWED); let agent_identity = signed_agent_identity_jwt(&record, json!(record.plan_type)).expect("signed agent identity"); let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/backend-api/wham/agent-identities/jwks")) .respond_with(ResponseTemplate::new(200).set_body_json(test_jwks_body())) .expect(1) .mount(&server) .await; Mock::given(method("POST")) .and(path(format!( "/v1/agent/{}/task/register", record.agent_runtime_id ))) .respond_with(ResponseTemplate::new(/*status*/ 503)) .expect(/*requests*/ 3) .mount(&server) .await; let authapi_base_url = server.uri(); let chatgpt_base_url = format!("{authapi_base_url}/backend-api"); let err = CodexAuth::from_agent_identity_jwt_with_authapi_base_url( &agent_identity, Some(&chatgpt_base_url), &authapi_base_url, &crate::test_support::transport_default_auth_route_config(), ) .await .expect_err("agent identity jwt task retry exhaustion should fail"); assert!(AgentIdentityAuthError::bootstrap_unavailable(&err).is_none()); Ok(()) } #[tokio::test] #[serial(codex_auth_env)] async fn login_with_access_token_rejects_unsigned_jwt() { let dir = tempdir().unwrap(); let record = agent_identity_record(WORKSPACE_ID_ALLOWED); let agent_identity = fake_agent_identity_jwt(&record).expect("fake agent identity"); let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/backend-api/wham/agent-identities/jwks")) .respond_with(ResponseTemplate::new(200).set_body_json(test_jwks_body())) .expect(1) .mount(&server) .await; let authapi_base_url = server.uri(); let chatgpt_base_url = format!("{authapi_base_url}/backend-api"); super::login_with_access_token( dir.path(), &agent_identity, AuthCredentialsStoreMode::File, /*forced_chatgpt_workspace_id*/ None, Some(&chatgpt_base_url), AuthKeyringBackendKind::default(), &crate::test_support::transport_default_auth_route_config(), ) .await .expect_err("unsigned access token should fail"); assert!( !get_auth_file(dir.path()).exists(), "unsigned access token should not write auth.json" ); server.verify().await; } #[tokio::test] #[serial(codex_auth_env)] async fn missing_auth_json_returns_none() { let dir = tempdir().unwrap(); let _access_token_guard = remove_access_token_env_var(); let auth = CodexAuth::from_auth_storage( dir.path(), AuthCredentialsStoreMode::File, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), &crate::test_support::transport_default_auth_route_config(), ) .await .expect("call should succeed"); assert_eq!(auth, None); } #[tokio::test] #[serial(codex_auth_env)] async fn pro_account_with_no_api_key_uses_chatgpt_auth() { let codex_home = tempdir().unwrap(); let _access_token_guard = remove_access_token_env_var(); let fake_jwt = write_auth_file( AuthFileParams { openai_api_key: None, chatgpt_plan_type: Some("pro".to_string()), chatgpt_account_id: None, }, 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, /*allowed_login_methods*/ None, /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, /*agent_identity_authapi_base_url*/ None, &crate::test_support::transport_default_auth_route_config(), /*cached_auth*/ None, /*allowed_auth_modes*/ None, ) .await .unwrap() .unwrap(); assert_eq!(None, auth.api_key()); assert_eq!(AuthMode::Chatgpt, auth.auth_mode()); assert_eq!(auth.get_chatgpt_user_id().as_deref(), Some("user-12345")); let auth_dot_json = auth .get_current_auth_json() .expect("AuthDotJson should exist"); let last_refresh = auth_dot_json .last_refresh .expect("last_refresh should be recorded"); assert_eq!( AuthDotJson { auth_mode: None, openai_api_key: None, tokens: Some(TokenData { id_token: IdTokenInfo { email: Some("user@example.com".to_string()), chatgpt_plan_type: Some(InternalPlanType::Known(InternalKnownPlan::Pro)), chatgpt_user_id: Some("user-12345".to_string()), chatgpt_account_id: None, chatgpt_account_is_fedramp: false, raw_jwt: fake_jwt, }, access_token: "test-access-token".to_string(), refresh_token: "test-refresh-token".to_string(), oauth_provenance: None, login_owner_id: None, account_id: None, }), last_refresh: Some(last_refresh), agent_identity: None, personal_access_token: None, bedrock_api_key: None, bedrock_access_keys: None, }, auth_dot_json ); } #[tokio::test] #[serial(codex_auth_env)] async fn loads_api_key_from_auth_json() { let dir = tempdir().unwrap(); let _access_token_guard = remove_access_token_env_var(); let auth_file = dir.path().join("auth.json"); for (api_key, expected_fedramp) in [ ("sk-test-key", false), ("sk-proj-fedramp-synthetic-test-key", true), ("sk-svcacct-fedramp-synthetic-test-key", true), ("sk-proj-synthetic-test-key", false), ("sk-svcacct-synthetic-test-key", false), ("sk-proj-fedramp", false), ("sk-svcacct-fedrampish-test-key", false), ("sk-proj-FEDRAMP-test-key", false), ("sk-admin-fedramp-test-key", false), ("other-sk-proj-fedramp-test-key", false), ] { std::fs::write( &auth_file, json!({"OPENAI_API_KEY": api_key, "tokens": null, "last_refresh": null}).to_string(), ) .unwrap(); let auth = super::load_auth( dir.path(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, /*allowed_login_methods*/ None, /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, /*agent_identity_authapi_base_url*/ None, &crate::test_support::transport_default_auth_route_config(), /*cached_auth*/ None, /*allowed_auth_modes*/ None, ) .await .unwrap() .unwrap(); assert_eq!(auth.auth_mode(), AuthMode::ApiKey); assert_eq!(auth.api_key(), Some(api_key)); assert_eq!( (auth.get_token().unwrap(), auth.is_fedramp_account()), (api_key.to_string(), expected_fedramp) ); assert!(auth.get_token_data().is_err()); } } #[test] fn logout_does_not_create_a_missing_file_store() -> std::io::Result<()> { let root = tempdir()?; let missing_home = root.path().join("missing"); assert!(!logout( &missing_home, AuthCredentialsStoreMode::File, AuthKeyringBackendKind::default() )?); assert!(!missing_home.exists()); Ok(()) } #[test] fn logout_removes_auth_file() -> Result<(), std::io::Error> { let dir = tempdir()?; let auth_dot_json = AuthDotJson { auth_mode: Some(AuthMode::ApiKey), openai_api_key: Some("sk-test-key".to_string()), tokens: None, last_refresh: None, agent_identity: None, personal_access_token: None, bedrock_api_key: None, bedrock_access_keys: None, }; super::save_auth( dir.path(), &auth_dot_json, AuthCredentialsStoreMode::File, AuthKeyringBackendKind::default(), )?; let auth_file = get_auth_file(dir.path()); assert!(auth_file.exists()); assert!(logout( dir.path(), AuthCredentialsStoreMode::File, AuthKeyringBackendKind::default(), )?); assert!(!auth_file.exists()); Ok(()) } #[tokio::test] #[serial(codex_auth_env)] async fn unauthorized_recovery_reports_mode_and_step_names() { let dir = tempdir().unwrap(); let manager = AuthManager::shared( dir.path().to_path_buf(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), crate::test_support::transport_default_auth_route_config(), ) .await; let managed = UnauthorizedRecovery { manager: Arc::clone(&manager), step: UnauthorizedRecoveryStep::Reload, expected_account_id: None, mode: UnauthorizedRecoveryMode::Managed, }; assert_eq!(managed.mode_name(), "managed"); assert_eq!(managed.step_name(), "reload"); let external = UnauthorizedRecovery { manager, step: UnauthorizedRecoveryStep::ExternalRefresh, expected_account_id: None, mode: UnauthorizedRecoveryMode::External, }; assert_eq!(external.mode_name(), "external"); assert_eq!(external.step_name(), "external_refresh"); } #[tokio::test] #[serial(codex_auth_env)] async fn refresh_failure_is_scoped_to_the_matching_auth_snapshot() { let codex_home = tempdir().unwrap(); let _access_token_guard = remove_access_token_env_var(); write_auth_file( AuthFileParams { openai_api_key: None, chatgpt_plan_type: Some("pro".to_string()), chatgpt_account_id: Some(WORKSPACE_ID_ALLOWED.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, /*allowed_login_methods*/ None, /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, /*agent_identity_authapi_base_url*/ None, &crate::test_support::transport_default_auth_route_config(), /*cached_auth*/ None, /*allowed_auth_modes*/ None, ) .await .expect("load auth") .expect("auth available"); let mut updated_auth_dot_json = auth .get_current_auth_json() .expect("AuthDotJson should exist"); let updated_tokens = updated_auth_dot_json .tokens .as_mut() .expect("tokens should exist"); updated_tokens.access_token = "new-access-token".to_string(); updated_tokens.refresh_token = "new-refresh-token".to_string(); let updated_auth = CodexAuth::from_auth_dot_json( codex_home.path(), updated_auth_dot_json, AuthCredentialsStoreMode::File, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, /*agent_identity_authapi_base_url*/ None, &crate::test_support::transport_default_auth_route_config(), /*cached_auth*/ None, ) .await .expect("updated auth should parse"); let manager = AuthManager::from_auth_for_testing(auth.clone()); let error = RefreshTokenFailedError::new( RefreshTokenFailedReason::Exhausted, "refresh token already used", ); manager.record_permanent_refresh_failure_if_unchanged(&auth, &error); assert_eq!(manager.refresh_failure_for_auth(&auth), Some(error)); assert_eq!(manager.refresh_failure_for_auth(&updated_auth), None); } #[tokio::test] async fn external_bearer_only_auth_manager_uses_cached_provider_token() { let script = ProviderAuthScript::new(&["provider-token", "next-token"]).unwrap(); let manager = AuthManager::external_bearer_only(script.auth_config()); let first = manager .auth() .await .and_then(|auth| auth.api_key().map(str::to_string)); let second = manager .auth() .await .and_then(|auth| auth.api_key().map(str::to_string)); assert_eq!(first.as_deref(), Some("provider-token")); assert_eq!(second.as_deref(), Some("provider-token")); assert_eq!(manager.auth_mode(), Some(AuthMode::ApiKey)); assert_eq!(manager.get_api_auth_mode(), Some(AuthMode::ApiKey)); } #[tokio::test] async fn external_bearer_only_auth_manager_disables_auto_refresh_when_interval_is_zero() { let script = ProviderAuthScript::new(&["provider-token", "next-token"]).unwrap(); let mut auth_config = script.auth_config(); auth_config.refresh_interval_ms = 0; let manager = AuthManager::external_bearer_only(auth_config); let first = manager .auth() .await .and_then(|auth| auth.api_key().map(str::to_string)); let second = manager .auth() .await .and_then(|auth| auth.api_key().map(str::to_string)); assert_eq!(first.as_deref(), Some("provider-token")); assert_eq!(second.as_deref(), Some("provider-token")); } #[tokio::test] async fn external_bearer_only_auth_manager_returns_none_when_command_fails() { let script = ProviderAuthScript::new_failing().unwrap(); let manager = AuthManager::external_bearer_only(script.auth_config()); assert_eq!(manager.auth().await, None); } #[tokio::test] async fn unauthorized_recovery_retries_provider_command_after_initial_failure() { let script = ProviderAuthScript::new(&["provider-token"]).unwrap(); std::fs::write(script.tempdir.path().join("fail-once"), "").unwrap(); let manager = AuthManager::external_bearer_only(script.auth_config()); let mut recovery = manager.unauthorized_recovery(); assert_eq!(manager.auth().await, None); assert_eq!(manager.auth_cached(), None); assert!(recovery.has_next()); assert_eq!(recovery.unavailable_reason(), "ready"); let result = recovery .next() .await .expect("external refresh should succeed"); assert_eq!(result.auth_state_changed(), Some(true)); assert_eq!( manager.auth_cached(), Some(CodexAuth::from_api_key("provider-token")) ); assert!(!recovery.has_next()); assert_eq!(recovery.unavailable_reason(), "recovery_exhausted"); recovery.next().await.expect_err("recovery is bounded"); } #[test] fn unauthorized_recovery_without_an_external_provider_still_requires_refreshable_auth() { for auth in [None, Some(CodexAuth::from_api_key("static-token"))] { let manager = AuthManager::from_optional_auth_for_testing(auth); let recovery = manager.unauthorized_recovery(); assert!(!recovery.has_next()); assert_eq!(recovery.unavailable_reason(), "not_chatgpt_auth"); } } #[tokio::test] async fn unauthorized_recovery_uses_external_refresh_for_bearer_manager() { let script = ProviderAuthScript::new(&["provider-token", "refreshed-provider-token"]).unwrap(); let mut auth_config = script.auth_config(); auth_config.refresh_interval_ms = 0; let manager = AuthManager::external_bearer_only(auth_config); let mut recovery = manager.unauthorized_recovery(); let initial_token = manager .auth() .await .and_then(|auth| auth.api_key().map(str::to_string)); assert!(recovery.has_next()); assert_eq!(recovery.mode_name(), "external"); assert_eq!(recovery.step_name(), "external_refresh"); let result = recovery .next() .await .expect("external refresh should succeed"); assert_eq!(result.auth_state_changed(), Some(true)); let refreshed_token = manager .auth() .await .and_then(|auth| auth.api_key().map(str::to_string)); assert_eq!(initial_token.as_deref(), Some("provider-token")); assert_eq!(refreshed_token.as_deref(), Some("refreshed-provider-token")); } #[derive(Clone)] struct StaticExternalAuth(CodexAuth); impl ExternalAuth for StaticExternalAuth { fn resolve(&self) -> ExternalAuthFuture<'_, CodexAuth> { Box::pin(async { Ok(self.0.clone()) }) } fn refresh(&self, _context: ExternalAuthRefreshContext) -> ExternalAuthFuture<'_, CodexAuth> { Box::pin(async { Ok(self.0.clone()) }) } } struct RefreshingExternalAuth { initial: CodexAuth, refreshed: CodexAuth, } impl ExternalAuth for RefreshingExternalAuth { fn resolve(&self) -> ExternalAuthFuture<'_, CodexAuth> { Box::pin(async { Ok(self.initial.clone()) }) } fn refresh(&self, _context: ExternalAuthRefreshContext) -> ExternalAuthFuture<'_, CodexAuth> { Box::pin(async { Ok(self.refreshed.clone()) }) } } fn external_header_auth(account_id: Option<&'static str>) -> CodexAuth { let mut headers = http::HeaderMap::new(); headers.insert( http::header::AUTHORIZATION, http::HeaderValue::from_static("Bearer external"), ); if let Some(account_id) = account_id { headers.insert( "chatgpt-account-id", http::HeaderValue::from_static(account_id), ); } CodexAuth::Headers(AuthHeaders::new(headers)) } struct FailingExternalAuth { auth: CodexAuth, resolve_count: AtomicUsize, } impl ExternalAuth for FailingExternalAuth { fn resolve(&self) -> ExternalAuthFuture<'_, CodexAuth> { let resolve_count = self.resolve_count.fetch_add(1, Ordering::SeqCst); Box::pin(async move { if resolve_count == 0 { Ok(self.auth.clone()) } else { Err(std::io::Error::other("external auth failed")) } }) } fn refresh(&self, _context: ExternalAuthRefreshContext) -> ExternalAuthFuture<'_, CodexAuth> { Box::pin(async { Err(std::io::Error::other("external auth failed")) }) } fn classify_error(&self, error: std::io::Error) -> RefreshTokenError { RefreshTokenError::Permanent(RefreshTokenFailedError::new( RefreshTokenFailedReason::Other, error.to_string(), )) } } #[tokio::test] async fn external_auth_keeps_cached_credentials_after_permanent_reload_failure() { let manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("seed")); let auth = CodexAuth::from_api_key("configured-token"); let external_auth = Arc::new(FailingExternalAuth { auth: auth.clone(), resolve_count: AtomicUsize::new(0), }); manager .set_external_auth(external_auth.clone()) .await .expect("external auth should install"); assert_eq!(external_auth.resolve_count.load(Ordering::SeqCst), 1); assert_eq!(manager.auth().await, Some(auth.clone())); assert_eq!(external_auth.resolve_count.load(Ordering::SeqCst), 2); assert_eq!( manager .refresh_failure_for_auth(&auth) .expect("permanent failure should be recorded") .to_string(), "external auth failed" ); assert_eq!(manager.auth().await, Some(auth)); assert_eq!(external_auth.resolve_count.load(Ordering::SeqCst), 2); } struct RecoveringExternalAuth { initial: CodexAuth, recovered: CodexAuth, resolve_count: AtomicUsize, } impl ExternalAuth for RecoveringExternalAuth { fn resolve(&self) -> ExternalAuthFuture<'_, CodexAuth> { let attempt = self.resolve_count.fetch_add(1, Ordering::SeqCst); Box::pin(async move { match attempt { 0 => Ok(self.initial.clone()), 1 => Err(std::io::Error::new( std::io::ErrorKind::TimedOut, "external credential exchange timed out", )), _ => Ok(self.recovered.clone()), } }) } fn refresh(&self, _context: ExternalAuthRefreshContext) -> ExternalAuthFuture<'_, CodexAuth> { self.resolve() } } #[tokio::test] async fn external_auth_keeps_its_identity_through_a_transient_reload_failure() { let manager = AuthManager::from_optional_auth_for_testing(/*auth*/ None); let initial = CodexAuth::from_api_key("initial-token"); let recovered = CodexAuth::from_api_key("recovered-token"); manager .set_external_auth(Arc::new(RecoveringExternalAuth { initial: initial.clone(), recovered, resolve_count: AtomicUsize::new(0), })) .await .expect("external auth should install"); let mut changes = manager.auth_change_state_receiver(); let mut revisions = manager.auth_change_receiver(); let initial_state = *changes.borrow_and_update(); let initial_revision = *revisions.borrow_and_update(); let retained = manager .auth() .await .expect("cached auth should be retained"); assert_eq!(retained.api_key(), Some("initial-token")); assert_eq!(*changes.borrow(), initial_state); assert!( !changes .has_changed() .expect("auth state sender remains alive") ); assert_eq!(*revisions.borrow(), initial_revision); assert!( !revisions .has_changed() .expect("auth revision sender remains alive") ); assert!(manager.refresh_failure_for_auth(&initial).is_none()); let recovered = manager.auth().await.expect("provider should recover"); assert_eq!(recovered.api_key(), Some("recovered-token")); let cached = manager .auth_cached() .expect("recovered auth should be cached"); assert_eq!(cached.api_key(), Some("recovered-token")); tokio::time::timeout(std::time::Duration::from_secs(5), changes.changed()) .await .expect("recovery should notify auth state watchers") .expect("auth state sender remains alive"); tokio::time::timeout(std::time::Duration::from_secs(5), revisions.changed()) .await .expect("recovery should notify auth revision watchers") .expect("auth revision sender remains alive"); assert!(changes.borrow().generation > initial_state.generation); assert!(*revisions.borrow() > initial_revision); } #[tokio::test] async fn workspace_policy_change_rejects_pending_transient_auth_fallback() { let manager = AuthManager::from_optional_auth_for_testing(/*auth*/ None); manager.set_forced_chatgpt_workspace_id(Some(vec![WORKSPACE_ID_ALLOWED.to_string()])); manager .set_external_auth(Arc::new(RecoveringExternalAuth { initial: external_header_auth(Some(WORKSPACE_ID_ALLOWED)), recovered: external_header_auth(Some(WORKSPACE_ID_SECOND_ALLOWED)), resolve_count: AtomicUsize::new(0), })) .await .expect("external auth should install"); let generation = manager.auth_change_state_receiver().borrow().generation; let fallback = manager .load_auth(/*cached_auth*/ None) .await .expect("external auth fallback should load"); assert_eq!( fallback .as_ref() .and_then(CodexAuth::get_account_id) .as_deref(), Some(WORKSPACE_ID_ALLOWED) ); // Change policy after the fallback was validated but before it can be published. manager.set_forced_chatgpt_workspace_id(Some(vec![WORKSPACE_ID_SECOND_ALLOWED.to_string()])); assert!( manager .publish_reloaded_auth(generation, fallback) .is_none() ); let recovered = manager .auth() .await .expect("new workspace should be allowed"); assert_eq!( recovered.get_account_id().as_deref(), Some(WORKSPACE_ID_SECOND_ALLOWED) ); } #[tokio::test] async fn external_auth_does_not_reuse_disallowed_credentials_after_a_transient_failure() { let manager = AuthManager::from_optional_auth_for_testing(/*auth*/ None); manager.set_forced_chatgpt_workspace_id(Some(vec![WORKSPACE_ID_ALLOWED.to_string()])); let initial = external_header_auth(Some(WORKSPACE_ID_ALLOWED)); let recovered = external_header_auth(Some(WORKSPACE_ID_SECOND_ALLOWED)); manager .set_external_auth(Arc::new(RecoveringExternalAuth { initial, recovered: recovered.clone(), resolve_count: AtomicUsize::new(0), })) .await .expect("external auth should install"); manager.set_forced_chatgpt_workspace_id(Some(vec![WORKSPACE_ID_SECOND_ALLOWED.to_string()])); assert_eq!(manager.auth().await, None); assert_eq!(manager.auth_cached(), None); assert_eq!(manager.auth().await, Some(recovered)); } #[tokio::test] async fn replacing_external_auth_clears_permanent_failure() { let manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("seed")); let auth = CodexAuth::from_api_key("external-token"); manager .set_external_auth(Arc::new(FailingExternalAuth { auth: auth.clone(), resolve_count: AtomicUsize::new(0), })) .await .expect("external auth should install"); assert_eq!(manager.auth().await, Some(auth.clone())); assert!(manager.refresh_failure_for_auth(&auth).is_some()); manager .set_external_auth(Arc::new(StaticExternalAuth(auth.clone()))) .await .expect("replacement external auth should install"); manager .refresh_token_from_authority() .await .expect("replacement external auth should refresh"); assert_eq!(manager.auth_cached(), Some(auth)); } #[tokio::test] async fn runtime_external_auth_uses_provider_error_classification() { let manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("seed")); manager .set_external_auth(Arc::new(FailingExternalAuth { auth: CodexAuth::from_api_key("runtime-token"), resolve_count: AtomicUsize::new(0), })) .await .expect("runtime auth should install"); assert!(matches!( manager.refresh_token_from_authority().await, Err(RefreshTokenError::Permanent(_)) )); } #[tokio::test] async fn external_auth_provider_can_install_headers() { let mut headers = http::HeaderMap::new(); headers.insert( http::header::AUTHORIZATION, http::HeaderValue::from_static("Bearer external"), ); headers.insert("x-external-auth", http::HeaderValue::from_static("enabled")); let auth = CodexAuth::Headers(AuthHeaders::new(headers)); let codex_home = tempdir().expect("tempdir"); login_with_api_key( codex_home.path(), "stored-key", AuthCredentialsStoreMode::Ephemeral, AuthKeyringBackendKind::default(), ) .unwrap(); let manager = AuthManager::new( codex_home.path().to_path_buf(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::Ephemeral, /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), crate::test_support::transport_default_auth_route_config(), ) .await; manager .set_external_auth(Arc::new(StaticExternalAuth(auth.clone()))) .await .expect("external auth should install"); assert!(!manager.reload_with_cached_auth().await.unwrap()); assert_eq!(manager.auth_cached(), Some(auth)); assert!( manager .auth_cached() .is_some_and(|auth| auth.uses_codex_backend()) ); assert!( !manager .auth_cached() .is_some_and(|auth| auth.is_chatgpt_auth()) ); } #[tokio::test] async fn external_header_auth_obeys_workspace_policy() { for (account_id, should_succeed) in [ (Some(WORKSPACE_ID_ALLOWED), true), (Some(WORKSPACE_ID_DISALLOWED), false), (None, false), ] { let auth = external_header_auth(account_id); let expected_auth = should_succeed.then_some(auth.clone()); let manager = AuthManager::from_optional_auth_for_testing(/*auth*/ None); manager.set_forced_chatgpt_workspace_id(Some(vec![WORKSPACE_ID_ALLOWED.to_string()])); let result = manager .set_external_auth(Arc::new(StaticExternalAuth(auth))) .await; assert_eq!(result.is_ok(), should_succeed, "account ID: {account_id:?}"); assert_eq!(manager.auth_cached(), expected_auth); } } #[tokio::test] async fn external_header_auth_rejects_a_disallowed_workspace_on_refresh() { let allowed_auth = external_header_auth(Some(WORKSPACE_ID_ALLOWED)); let disallowed_auth = external_header_auth(Some(WORKSPACE_ID_DISALLOWED)); let manager = AuthManager::from_optional_auth_for_testing(/*auth*/ None); manager.set_forced_chatgpt_workspace_id(Some(vec![WORKSPACE_ID_ALLOWED.to_string()])); manager .set_external_auth(Arc::new(RefreshingExternalAuth { initial: allowed_auth.clone(), refreshed: disallowed_auth, })) .await .expect("initial external header auth should install"); manager .refresh_token_from_authority() .await .expect_err("external header auth from a disallowed workspace must not replace the cache"); assert_eq!(manager.auth_cached(), Some(allowed_auth)); } #[tokio::test] async fn workload_identity_auth_is_immutable_and_process_local() { let codex_home = tempdir().expect("tempdir"); let mut manager = AuthManager::from_auth_for_testing_with_home( CodexAuth::from_api_key("seed"), codex_home.path().to_path_buf(), ); Arc::get_mut(&mut manager) .expect("test manager should not be shared yet") .workload_identity_selected = true; let access_token = fake_jwt_for_auth_file_params(&AuthFileParams { openai_api_key: None, chatgpt_plan_type: Some("enterprise".to_string()), chatgpt_account_id: Some("workspace-one".to_string()), }) .expect("fake access token"); let auth = CodexAuth::from_external_chatgpt_tokens(&access_token, "workspace-one", Some("enterprise")) .expect("external ChatGPT auth"); manager .install_external_auth(Arc::new(StaticExternalAuth(auth.clone()))) .await .expect("workload identity auth should install"); manager.clear_external_auth(); assert!(manager.has_external_auth()); assert_eq!(manager.auth().await, Some(auth.clone())); assert!(matches!( manager .set_external_auth(Arc::new(StaticExternalAuth(auth.clone()))) .await, Err(RefreshTokenError::Permanent(_)) )); let logout_error = manager .logout() .await .expect_err("workload identity auth must not be logged out"); assert_eq!(logout_error.kind(), std::io::ErrorKind::PermissionDenied); assert!(manager.has_external_auth()); assert_eq!(manager.auth_cached(), Some(auth)); assert!(!get_auth_file(codex_home.path()).exists()); let ephemeral_storage = create_auth_storage( codex_home.path().to_path_buf(), AuthCredentialsStoreMode::Ephemeral, AuthKeyringBackendKind::default(), ); assert!( ephemeral_storage .load() .expect("load ephemeral auth") .is_some() ); } struct ProviderAuthScript { tempdir: TempDir, command: String, args: Vec, } impl ProviderAuthScript { fn new(tokens: &[&str]) -> std::io::Result { let tempdir = tempfile::tempdir()?; let token_file = tempdir.path().join("tokens.txt"); // `cmd.exe`'s `set /p` treats LF-only input as one line, so use CRLF on Windows. let token_line_ending = if cfg!(windows) { "\r\n" } else { "\n" }; let mut token_file_contents = String::new(); for token in tokens { token_file_contents.push_str(token); token_file_contents.push_str(token_line_ending); } std::fs::write(&token_file, token_file_contents)?; #[cfg(unix)] let (command, args) = { let script_path = tempdir.path().join("print-token.sh"); codex_utils_cargo_bin::write_executable( &script_path, r#"#!/bin/sh if [ -f fail-once ]; then rm fail-once exit 1 fi first_line=$(sed -n '1p' tokens.txt) printf '%s\n' "$first_line" tail -n +2 tokens.txt > tokens.next mv tokens.next tokens.txt "#, )?; ("./print-token.sh".to_string(), Vec::new()) }; #[cfg(windows)] let (command, args) = { let script_path = tempdir.path().join("print-token.cmd"); std::fs::write( &script_path, r#"@echo off setlocal EnableExtensions DisableDelayedExpansion if exist fail-once ( del fail-once exit /b 1 ) set "first_line=" std::io::Result { let tempdir = tempfile::tempdir()?; #[cfg(unix)] let (command, args) = { let script_path = tempdir.path().join("fail.sh"); codex_utils_cargo_bin::write_executable( &script_path, r#"#!/bin/sh exit 1 "#, )?; ("./fail.sh".to_string(), Vec::new()) }; #[cfg(windows)] let (command, args) = ( "cmd.exe".to_string(), vec![ "/d".to_string(), "/s".to_string(), "/c".to_string(), "exit /b 1".to_string(), ], ); Ok(Self { tempdir, command, args, }) } fn auth_config(&self) -> ModelProviderAuthInfo { serde_json::from_value(json!({ "command": self.command, "args": self.args, // Process startup can be slow on loaded Windows CI workers, so leave enough slack to // avoid turning these auth-cache assertions into a process-launch timing test. "timeout_ms": 10_000, "refresh_interval_ms": 60000, "cwd": self.tempdir.path(), })) .expect("provider auth config should deserialize") } } struct AuthFileParams { openai_api_key: Option, chatgpt_plan_type: Option, chatgpt_account_id: Option, } fn write_auth_file(params: AuthFileParams, codex_home: &Path) -> std::io::Result { let fake_jwt = fake_jwt_for_auth_file_params(¶ms)?; let auth_file = get_auth_file(codex_home); let auth_json_data = json!({ "OPENAI_API_KEY": params.openai_api_key, "tokens": { "id_token": fake_jwt, "access_token": "test-access-token", "refresh_token": "test-refresh-token" }, "last_refresh": Utc::now(), }); let auth_json = serde_json::to_string_pretty(&auth_json_data)?; std::fs::write(auth_file, auth_json)?; Ok(fake_jwt) } fn fake_jwt_for_auth_file_params(params: &AuthFileParams) -> std::io::Result { #[derive(Serialize)] struct Header { alg: &'static str, typ: &'static str, } let header = Header { alg: "none", typ: "JWT", }; let mut auth_payload = serde_json::json!({ "chatgpt_user_id": "user-12345", "user_id": "user-12345", }); if let Some(chatgpt_plan_type) = params.chatgpt_plan_type.as_ref() { auth_payload["chatgpt_plan_type"] = serde_json::Value::String(chatgpt_plan_type.clone()); } if let Some(chatgpt_account_id) = params.chatgpt_account_id.as_ref() { auth_payload["chatgpt_account_id"] = serde_json::Value::String(chatgpt_account_id.clone()); } let payload = serde_json::json!({ "email": "user@example.com", "email_verified": true, "https://api.openai.com/auth": auth_payload, }); 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, forced_chatgpt_workspace_id: Option>, ) -> AuthConfig { AuthConfig { codex_home: codex_home.to_path_buf(), auth_credentials_store_mode: AuthCredentialsStoreMode::File, keyring_backend_kind: AuthKeyringBackendKind::Direct, forced_login_method, forced_chatgpt_workspace_id, managed_auth_policy: ManagedAuthPolicy::default(), chatgpt_base_url: None, auth_route_config: crate::test_support::transport_default_auth_route_config(), } } /// Use sparingly. /// TODO (gpeal): replace this with an injectable env var provider. #[cfg(test)] struct EnvVarGuard { key: &'static str, original: Option, } #[cfg(test)] impl EnvVarGuard { fn set(key: &'static str, value: &str) -> Self { let original = env::var_os(key); unsafe { env::set_var(key, value); } 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)] impl Drop for EnvVarGuard { fn drop(&mut self) { unsafe { match &self.original { Some(value) => env::set_var(self.key, value), None => env::remove_var(self.key), } } } } fn remove_access_token_env_var() -> EnvVarGuard { EnvVarGuard::remove(CODEX_ACCESS_TOKEN_ENV_VAR) } struct TestAuthManagerConfig(AuthConfig); impl AuthManagerConfig for TestAuthManagerConfig { fn codex_home(&self) -> PathBuf { self.0.codex_home.clone() } fn cli_auth_credentials_store_mode(&self) -> AuthCredentialsStoreMode { self.0.auth_credentials_store_mode } fn auth_keyring_backend_kind(&self) -> AuthKeyringBackendKind { self.0.keyring_backend_kind } fn forced_login_method(&self) -> Option { self.0.forced_login_method } fn forced_chatgpt_workspace_id(&self) -> Option> { self.0.forced_chatgpt_workspace_id.clone() } fn managed_auth_policy(&self) -> ManagedAuthPolicy { self.0.managed_auth_policy.clone() } fn chatgpt_base_url(&self) -> String { self.0 .chatgpt_base_url .clone() .expect("test config should include a ChatGPT base URL") } fn auth_route_config(&self) -> AuthRouteConfig { self.0.auth_route_config.clone() } } fn test_auth_manager_config(codex_home: &Path) -> TestAuthManagerConfig { TestAuthManagerConfig(AuthConfig { codex_home: codex_home.to_path_buf(), auth_credentials_store_mode: AuthCredentialsStoreMode::File, keyring_backend_kind: AuthKeyringBackendKind::Direct, forced_login_method: Some(ForcedLoginMethod::Chatgpt), chatgpt_base_url: Some("https://chatgpt-staging.com/backend-api".to_string()), forced_chatgpt_workspace_id: Some(vec!["forced-workspace".to_string()]), managed_auth_policy: ManagedAuthPolicy { allowed_login_methods: Some(vec![ForcedLoginMethod::Chatgpt]), allowed_chatgpt_workspaces: Some(vec![ "forced-workspace".to_string(), "managed-workspace".to_string(), ]), }, auth_route_config: AuthRouteConfig::from_http_client_factory(HttpClientFactory::new( OutboundProxyPolicy::RespectSystemProxy, )), }) } #[test] fn auth_config_from_preserves_all_fields() { let codex_home = tempdir().expect("tempdir"); let config = test_auth_manager_config(codex_home.path()); assert_eq!(auth_config_from(&config), config.0); } #[tokio::test] #[serial(codex_auth_env)] async fn shared_from_config_prefers_workload_identity_to_explicit_access_token() { let codex_home = tempdir().expect("tempdir"); let config = test_auth_manager_config(codex_home.path()); let _access_token_guard = EnvVarGuard::set(CODEX_ACCESS_TOKEN_ENV_VAR, "at-explicit"); let _rule_guard = EnvVarGuard::set(OPENAI_FEDERATION_RULE_ID_ENV_VAR, "rule-one"); let _assertion_file_guard = EnvVarGuard::remove(OPENAI_IDENTITY_TOKEN_FILE_ENV_VAR); let error = AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ false) .await .expect_err("partial workload identity config should fail closed"); assert!( error .to_string() .contains(OPENAI_IDENTITY_TOKEN_FILE_ENV_VAR) ); } #[tokio::test] #[serial(codex_auth_env)] async fn load_auth_reads_access_token_from_env() { let codex_home = tempdir().unwrap(); let mut expected_record = agent_identity_record(WORKSPACE_ID_ALLOWED); let agent_identity = signed_agent_identity_jwt(&expected_record, json!(expected_record.plan_type)) .expect("signed agent identity"); let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/backend-api/wham/agent-identities/jwks")) .respond_with(ResponseTemplate::new(200).set_body_json(test_jwks_body())) .expect(1) .mount(&server) .await; Mock::given(method("POST")) .and(path("/v1/agent/agent-runtime-id/task/register")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "task_id": "task-123", }))) .expect(1) .mount(&server) .await; expected_record.task_id = Some("task-123".to_string()); let _access_token_guard = EnvVarGuard::set(CODEX_ACCESS_TOKEN_ENV_VAR, &agent_identity); let authapi_base_url = server.uri(); let chatgpt_base_url = format!("{authapi_base_url}/backend-api"); let auth = super::load_auth( codex_home.path(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, /*allowed_login_methods*/ None, /*forced_chatgpt_workspace_id*/ None, Some(&chatgpt_base_url), AuthKeyringBackendKind::Direct, Some(&authapi_base_url), &crate::test_support::transport_default_auth_route_config(), /*cached_auth*/ None, /*allowed_auth_modes*/ None, ) .await .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_eq!(agent_identity.run_task_id(), "task-123"); assert!( !get_auth_file(codex_home.path()).exists(), "env auth should not write auth.json" ); server.verify().await; } #[tokio::test] #[serial(codex_auth_env)] async fn load_auth_reads_personal_access_token_from_env() { let codex_home = tempdir().unwrap(); let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/v1/user-auth-credential/whoami")) .and(header("authorization", "Bearer at-env-test")) .respond_with( ResponseTemplate::new(200) .set_body_json(personal_access_token_whoami(WORKSPACE_ID_ALLOWED)), ) .expect(2) .mount(&server) .await; let _authapi_guard = EnvVarGuard::set("CODEX_AUTHAPI_BASE_URL", &server.uri()); let _access_token_guard = EnvVarGuard::set(CODEX_ACCESS_TOKEN_ENV_VAR, "at-env-test"); for auth_credentials_store_mode in [ AuthCredentialsStoreMode::File, AuthCredentialsStoreMode::Ephemeral, ] { let auth = super::load_auth( codex_home.path(), /*enable_codex_api_key_env*/ false, auth_credentials_store_mode, /*allowed_login_methods*/ None, /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), /*agent_identity_authapi_base_url*/ None, &crate::test_support::transport_default_auth_route_config(), /*cached_auth*/ None, /*allowed_auth_modes*/ None, ) .await .expect("env auth should load") .expect("env auth should be present"); assert_eq!(auth.api_auth_mode(), AuthMode::PersonalAccessToken); assert_eq!( auth.get_token() .expect("personal access token should be exposed"), "at-env-test" ); assert_eq!(auth.get_account_id().as_deref(), Some(WORKSPACE_ID_ALLOWED)); assert_eq!(auth.get_chatgpt_user_id().as_deref(), Some("user-123")); assert_eq!( auth.get_account_email().as_deref(), Some("user@example.com") ); assert_eq!(auth.account_plan_type(), Some(AccountPlanType::Business)); assert!(auth.is_fedramp_account()); } assert!( !get_auth_file(codex_home.path()).exists(), "env auth should not write auth.json" ); server.verify().await; } #[tokio::test] #[serial(codex_auth_env)] async fn auth_manager_rejects_env_personal_access_token_workspace_mismatch() { let codex_home = tempdir().unwrap(); let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/v1/user-auth-credential/whoami")) .and(header("authorization", "Bearer at-env-workspace-mismatch")) .respond_with( ResponseTemplate::new(200) .set_body_json(personal_access_token_whoami(WORKSPACE_ID_DISALLOWED)), ) .expect(1) .mount(&server) .await; let _authapi_guard = EnvVarGuard::set("CODEX_AUTHAPI_BASE_URL", &server.uri()); let _access_token_guard = EnvVarGuard::set(CODEX_ACCESS_TOKEN_ENV_VAR, "at-env-workspace-mismatch"); let manager = AuthManager::new( codex_home.path().to_path_buf(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, Some(vec![WORKSPACE_ID_ALLOWED.to_string()]), /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), crate::test_support::transport_default_auth_route_config(), ) .await; assert_eq!(manager.auth().await, None); server.verify().await; } #[tokio::test] #[serial(codex_auth_env)] async fn auth_manager_rejects_stored_personal_access_token_workspace_mismatch() { let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/v1/user-auth-credential/whoami")) .and(header( "authorization", "Bearer at-stored-workspace-mismatch", )) .respond_with( ResponseTemplate::new(200) .set_body_json(personal_access_token_whoami(WORKSPACE_ID_DISALLOWED)), ) .expect(4) .mount(&server) .await; let _authapi_guard = EnvVarGuard::set("CODEX_AUTHAPI_BASE_URL", &server.uri()); let _access_token_guard = remove_access_token_env_var(); for auth_credentials_store_mode in [ AuthCredentialsStoreMode::File, AuthCredentialsStoreMode::Ephemeral, ] { let codex_home = tempdir().unwrap(); super::login_with_access_token( codex_home.path(), "at-stored-workspace-mismatch", auth_credentials_store_mode, /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), &crate::test_support::transport_default_auth_route_config(), ) .await .expect("personal access token login should succeed"); let manager = AuthManager::new( codex_home.path().to_path_buf(), /*enable_codex_api_key_env*/ false, auth_credentials_store_mode, Some(vec![WORKSPACE_ID_ALLOWED.to_string()]), /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), crate::test_support::transport_default_auth_route_config(), ) .await; assert_eq!(manager.auth().await, None); } server.verify().await; } #[tokio::test] #[serial(codex_auth_env)] async fn personal_access_token_does_not_offer_unauthorized_recovery() { let codex_home = tempdir().unwrap(); let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/v1/user-auth-credential/whoami")) .respond_with( ResponseTemplate::new(200) .set_body_json(personal_access_token_whoami(WORKSPACE_ID_ALLOWED)), ) .expect(1) .mount(&server) .await; let _authapi_guard = EnvVarGuard::set("CODEX_AUTHAPI_BASE_URL", &server.uri()); let _access_token_guard = EnvVarGuard::set(CODEX_ACCESS_TOKEN_ENV_VAR, "at-no-unauthorized-recovery"); let manager = Arc::new( AuthManager::new( codex_home.path().to_path_buf(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), crate::test_support::transport_default_auth_route_config(), ) .await, ); let recovery = manager.unauthorized_recovery(); assert!(!recovery.has_next()); assert_eq!(recovery.unavailable_reason(), "not_refreshable_auth"); manager .refresh_token_from_authority() .await .expect("personal access tokens do not use OAuth refresh"); server.verify().await; } #[tokio::test] #[serial(codex_auth_env)] async fn load_auth_keeps_codex_api_key_env_precedence() { let codex_home = tempdir().unwrap(); let record = agent_identity_record(WORKSPACE_ID_ALLOWED); let agent_identity = fake_agent_identity_jwt(&record).expect("fake agent identity"); let _access_token_guard = EnvVarGuard::set(CODEX_ACCESS_TOKEN_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, /*allowed_login_methods*/ None, /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, /*agent_identity_authapi_base_url*/ None, &crate::test_support::transport_default_auth_route_config(), /*cached_auth*/ None, /*allowed_auth_modes*/ None, ) .await .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(); let _access_token_guard = remove_access_token_env_var(); login_with_api_key( codex_home.path(), "sk-test", AuthCredentialsStoreMode::File, AuthKeyringBackendKind::default(), ) .expect("seed api key"); let config = build_config( codex_home.path(), Some(ForcedLoginMethod::Chatgpt), /*forced_chatgpt_workspace_id*/ None, ) .await; let err = super::enforce_login_restrictions(&config) .await .expect_err("expected method mismatch to error"); assert!(err.to_string().contains("ChatGPT login is required")); assert!( !codex_home.path().join("auth.json").exists(), "auth.json should be removed on mismatch" ); } #[tokio::test] #[serial(codex_auth_env)] async fn auth_manager_rejects_disallowed_stored_and_external_auth() { let codex_home = tempdir().unwrap(); let _access_token_guard = remove_access_token_env_var(); login_with_api_key( codex_home.path(), "sk-test", AuthCredentialsStoreMode::File, AuthKeyringBackendKind::default(), ) .expect("seed api key"); let mut config = build_config( codex_home.path(), /*forced_login_method*/ None, /*forced_chatgpt_workspace_id*/ None, ) .await; config.managed_auth_policy.allowed_login_methods = Some(vec![ForcedLoginMethod::Chatgpt]); let manager = AuthManager::shared_from_auth_config(config, /*enable_codex_api_key_env*/ false) .await .expect("auth manager"); assert_eq!(manager.auth().await, None); assert!( manager .set_external_auth(Arc::new(StaticExternalAuth(CodexAuth::from_api_key( "sk-external", )))) .await .is_err(), "external auth cannot bypass managed login policy" ); } #[tokio::test] #[serial(codex_auth_env)] async fn api_only_policy_rejects_access_tokens_before_hydration() { let codex_home = tempdir().unwrap(); let server = MockServer::start().await; let _authapi_guard = EnvVarGuard::set("CODEX_AUTHAPI_BASE_URL", &server.uri()); let _access_token_guard = EnvVarGuard::set(CODEX_ACCESS_TOKEN_ENV_VAR, "at-rejected"); let mut config = build_config( codex_home.path(), /*forced_login_method*/ None, /*forced_chatgpt_workspace_id*/ None, ) .await; config.managed_auth_policy.allowed_login_methods = Some(vec![ForcedLoginMethod::Api]); let manager = AuthManager::shared_from_auth_config(config, /*enable_codex_api_key_env*/ false) .await .expect("auth manager"); assert_eq!(manager.auth().await, None); assert!( server .received_requests() .await .expect("inspect auth requests") .is_empty(), "rejected access tokens must not call whoami or register Agent Identity" ); } #[tokio::test] #[serial(codex_auth_env)] async fn workspace_policy_rejects_agent_identity_before_hydration() { let codex_home = tempdir().unwrap(); let server = MockServer::start().await; let record = agent_identity_record(WORKSPACE_ID_DISALLOWED); let agent_identity = signed_agent_identity_jwt(&record, json!(record.plan_type)).expect("signed agent identity"); let _authapi_guard = EnvVarGuard::set("CODEX_AUTHAPI_BASE_URL", &server.uri()); let _access_token_reset = remove_access_token_env_var(); let access_token_guard = EnvVarGuard::set(CODEX_ACCESS_TOKEN_ENV_VAR, &agent_identity); let mut config = build_config( codex_home.path(), /*forced_login_method*/ None, /*forced_chatgpt_workspace_id*/ None, ) .await; config.managed_auth_policy.allowed_chatgpt_workspaces = Some(vec![WORKSPACE_ID_ALLOWED.to_string()]); let manager = AuthManager::shared_from_auth_config(config, /*enable_codex_api_key_env*/ false) .await .expect("auth manager"); assert_eq!(manager.auth().await, None); drop(access_token_guard); for stored_agent_identity in [ AgentIdentityStorage::Jwt(agent_identity), AgentIdentityStorage::Record(record), ] { save_auth( codex_home.path(), &AuthDotJson { auth_mode: Some(AuthMode::AgentIdentity), openai_api_key: None, tokens: None, last_refresh: None, agent_identity: Some(stored_agent_identity), personal_access_token: None, bedrock_api_key: None, bedrock_access_keys: None, }, AuthCredentialsStoreMode::File, AuthKeyringBackendKind::Direct, ) .expect("store agent identity"); let mut config = build_config( codex_home.path(), /*forced_login_method*/ None, /*forced_chatgpt_workspace_id*/ None, ) .await; config.managed_auth_policy.allowed_chatgpt_workspaces = Some(vec![WORKSPACE_ID_ALLOWED.to_string()]); let manager = AuthManager::shared_from_auth_config(config, /*enable_codex_api_key_env*/ false) .await .expect("auth manager"); assert_eq!(manager.auth().await, None); } assert!( server.received_requests().await.unwrap().is_empty(), "rejected agent identities must not fetch JWKS or register" ); } #[tokio::test] #[serial(codex_auth_env)] async fn workspace_policy_checks_the_selected_request_account() { let codex_home = tempdir().unwrap(); let _access_token_guard = remove_access_token_env_var(); write_auth_file( AuthFileParams { openai_api_key: None, chatgpt_plan_type: Some("pro".to_string()), chatgpt_account_id: Some(WORKSPACE_ID_ALLOWED.to_string()), }, codex_home.path(), ) .expect("seed ChatGPT credentials"); let auth_path = codex_home.path().join("auth.json"); let mut stored: serde_json::Value = serde_json::from_slice(&std::fs::read(&auth_path).unwrap()).unwrap(); stored["tokens"]["account_id"] = json!(WORKSPACE_ID_DISALLOWED); std::fs::write(&auth_path, serde_json::to_vec(&stored).unwrap()).unwrap(); let config = build_config( codex_home.path(), /*forced_login_method*/ None, Some(vec![WORKSPACE_ID_ALLOWED.to_string()]), ) .await; let manager = AuthManager::shared_from_auth_config(config, /*enable_codex_api_key_env*/ false) .await .expect("auth manager"); assert_eq!(manager.auth().await, None); } #[tokio::test] #[serial(codex_auth_env)] async fn enforce_login_restrictions_logs_out_for_workspace_mismatch() { let codex_home = tempdir().unwrap(); let _access_token_guard = remove_access_token_env_var(); let _jwt = write_auth_file( AuthFileParams { openai_api_key: None, chatgpt_plan_type: Some("pro".to_string()), chatgpt_account_id: Some(WORKSPACE_ID_DISALLOWED.to_string()), }, codex_home.path(), ) .expect("failed to write auth file"); let config = build_config( codex_home.path(), /*forced_login_method*/ None, Some(vec![WORKSPACE_ID_ALLOWED.to_string()]), ) .await; let err = super::enforce_login_restrictions(&config) .await .expect_err("expected workspace mismatch to error"); assert!( err.to_string() .contains(&format!("workspace(s) {WORKSPACE_ID_ALLOWED}")) ); assert!( !codex_home.path().join("auth.json").exists(), "auth.json should be removed on mismatch" ); } #[tokio::test] #[serial(codex_auth_env)] async fn enforce_login_restrictions_logs_out_for_personal_access_token_workspace_mismatch() { let codex_home = tempdir().unwrap(); let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/v1/user-auth-credential/whoami")) .respond_with( ResponseTemplate::new(200) .set_body_json(personal_access_token_whoami(WORKSPACE_ID_DISALLOWED)), ) .expect(2) .mount(&server) .await; let _access_token_guard = remove_access_token_env_var(); let _authapi_guard = EnvVarGuard::set("CODEX_AUTHAPI_BASE_URL", &server.uri()); super::login_with_access_token( codex_home.path(), "at-workspace-mismatch", AuthCredentialsStoreMode::File, /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), &crate::test_support::transport_default_auth_route_config(), ) .await .expect("personal access token login should succeed"); let config = AuthConfig { codex_home: codex_home.path().to_path_buf(), auth_credentials_store_mode: AuthCredentialsStoreMode::File, keyring_backend_kind: AuthKeyringBackendKind::default(), forced_login_method: None, forced_chatgpt_workspace_id: Some(vec![WORKSPACE_ID_ALLOWED.to_string()]), managed_auth_policy: ManagedAuthPolicy::default(), chatgpt_base_url: None, auth_route_config: crate::test_support::transport_default_auth_route_config(), }; let err = super::enforce_login_restrictions(&config) .await .expect_err("expected workspace mismatch to error"); assert!(err.to_string().contains(&format!( "current credentials belong to {WORKSPACE_ID_DISALLOWED}" ))); assert!( !codex_home.path().join("auth.json").exists(), "auth.json should be removed on mismatch" ); server.verify().await; } #[tokio::test] #[serial(codex_auth_env)] async fn enforce_login_restrictions_allows_matching_workspace() { let codex_home = tempdir().unwrap(); let _access_token_guard = remove_access_token_env_var(); let _jwt = write_auth_file( AuthFileParams { openai_api_key: None, chatgpt_plan_type: Some("pro".to_string()), chatgpt_account_id: Some(WORKSPACE_ID_ALLOWED.to_string()), }, codex_home.path(), ) .expect("failed to write auth file"); let config = build_config( codex_home.path(), /*forced_login_method*/ None, Some(vec![WORKSPACE_ID_ALLOWED.to_string()]), ) .await; super::enforce_login_restrictions(&config) .await .expect("matching workspace should succeed"); assert!( codex_home.path().join("auth.json").exists(), "auth.json should remain when restrictions pass" ); } #[tokio::test] #[serial(codex_auth_env)] async fn enforce_login_restrictions_allows_any_matching_workspace_in_list() { let codex_home = tempdir().unwrap(); let _jwt = write_auth_file( AuthFileParams { openai_api_key: None, chatgpt_plan_type: Some("pro".to_string()), chatgpt_account_id: Some(WORKSPACE_ID_ALLOWED.to_string()), }, codex_home.path(), ) .expect("failed to write auth file"); let config = build_config( codex_home.path(), /*forced_login_method*/ None, Some(vec![ WORKSPACE_ID_SECOND_ALLOWED.to_string(), WORKSPACE_ID_ALLOWED.to_string(), ]), ) .await; super::enforce_login_restrictions(&config) .await .expect("any matching workspace in the allowed list should succeed"); } #[tokio::test] #[serial(codex_auth_env)] async fn enforce_login_restrictions_logs_out_for_agent_identity_workspace_mismatch() { let codex_home = tempdir().unwrap(); let _access_token_guard = remove_access_token_env_var(); let record = agent_identity_record(WORKSPACE_ID_DISALLOWED); let agent_identity = signed_agent_identity_jwt(&record, json!(record.plan_type)).expect("signed agent identity"); let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/backend-api/wham/agent-identities/jwks")) .respond_with(ResponseTemplate::new(200).set_body_json(test_jwks_body())) .expect(1) .mount(&server) .await; Mock::given(method("POST")) .and(path("/v1/agent/agent-runtime-id/task/register")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "task_id": "task-123", }))) .expect(1) .mount(&server) .await; let authapi_base_url = server.uri(); let chatgpt_base_url = format!("{authapi_base_url}/backend-api"); save_auth( codex_home.path(), &AuthDotJson { auth_mode: Some(AuthMode::AgentIdentity), openai_api_key: None, tokens: None, last_refresh: None, agent_identity: Some(AgentIdentityStorage::Jwt(agent_identity)), personal_access_token: None, bedrock_api_key: None, bedrock_access_keys: None, }, AuthCredentialsStoreMode::File, AuthKeyringBackendKind::default(), ) .expect("seed agent identity auth"); let config = AuthConfig { codex_home: codex_home.path().to_path_buf(), auth_credentials_store_mode: AuthCredentialsStoreMode::File, keyring_backend_kind: AuthKeyringBackendKind::Direct, forced_login_method: None, forced_chatgpt_workspace_id: Some(vec![WORKSPACE_ID_ALLOWED.to_string()]), managed_auth_policy: ManagedAuthPolicy::default(), chatgpt_base_url: Some(chatgpt_base_url), auth_route_config: crate::test_support::transport_default_auth_route_config(), }; let err = super::enforce_login_restrictions_with_agent_identity_authapi_base_url( &config, Some(&authapi_base_url), ) .await .expect_err("expected workspace mismatch to error"); let message = err.to_string(); assert!( message.contains(&format!( "current credentials belong to {WORKSPACE_ID_DISALLOWED}" )), "{message}" ); assert!( !codex_home.path().join("auth.json").exists(), "auth.json should be removed on mismatch" ); server.verify().await; } #[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(); let _access_token_guard = remove_access_token_env_var(); login_with_api_key( codex_home.path(), "sk-test", AuthCredentialsStoreMode::File, AuthKeyringBackendKind::default(), ) .expect("seed api key"); let config = build_config( codex_home.path(), /*forced_login_method*/ None, Some(vec![WORKSPACE_ID_ALLOWED.to_string()]), ) .await; super::enforce_login_restrictions(&config) .await .expect("matching workspace should succeed"); assert!( codex_home.path().join("auth.json").exists(), "auth.json should remain when restrictions pass" ); } #[tokio::test] #[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 _access_token_guard = remove_access_token_env_var(); let codex_home = tempdir().unwrap(); let config = build_config( codex_home.path(), Some(ForcedLoginMethod::Chatgpt), /*forced_chatgpt_workspace_id*/ None, ) .await; let err = super::enforce_login_restrictions(&config) .await .expect_err("environment API key should not satisfy forced ChatGPT login"); assert!( err.to_string() .contains("ChatGPT login is required, but an API key is currently being used.") ); } fn agent_identity_record(account_id: &str) -> AgentIdentityAuthRecord { let key_material = codex_agent_identity::generate_agent_key_material().expect("generate agent key material"); AgentIdentityAuthRecord { agent_runtime_id: "agent-runtime-id".to_string(), agent_private_key: key_material.private_key_pkcs8_base64, account_id: account_id.to_string(), chatgpt_user_id: "user-id".to_string(), email: Some("user@example.com".to_string()), plan_type: AccountPlanType::Pro, chatgpt_account_is_fedramp: false, task_id: None, } } async fn mock_agent_task_registration( server: &MockServer, path_prefix: &str, agent_runtime_id: &str, task_id: &str, ) { Mock::given(method("POST")) .and(path(format!( "{path_prefix}/v1/agent/{agent_runtime_id}/task/register" ))) .respond_with(ResponseTemplate::new(/*s*/ 200).set_body_json(json!({ "task_id": task_id, }))) .expect(/*r*/ 1) .mount(server) .await; } fn fake_agent_identity_jwt(record: &AgentIdentityAuthRecord) -> std::io::Result { fake_agent_identity_jwt_with_plan_type(record, serde_json::to_value(record.plan_type)?) } fn fake_agent_identity_jwt_with_plan_type( record: &AgentIdentityAuthRecord, plan_type: serde_json::Value, ) -> std::io::Result { 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!({ "iss": "https://chatgpt.com/codex-backend/agent-identity", "aud": "codex-app-server", "iat": 1_700_000_000usize, "exp": 4_000_000_000usize, "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": 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}")) } fn signed_agent_identity_jwt( record: &AgentIdentityAuthRecord, plan_type: serde_json::Value, ) -> jsonwebtoken::errors::Result { let mut header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::RS256); header.kid = Some("test-key".to_string()); jsonwebtoken::encode( &header, &json!({ "iss": "https://chatgpt.com/codex-backend/agent-identity", "aud": "codex-app-server", "iat": 1_700_000_000usize, "exp": 4_000_000_000usize, "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": plan_type, "chatgpt_account_is_fedramp": record.chatgpt_account_is_fedramp, }), &jsonwebtoken::EncodingKey::from_rsa_pem(TEST_AGENT_IDENTITY_RSA_PRIVATE_KEY_PEM)?, ) } fn test_jwks_body() -> serde_json::Value { json!({ "keys": [{ "kty": "RSA", "kid": "test-key", "use": "sig", "alg": "RS256", "n": "1qQF2MqTrGAMDm7wXbjJP5sWqGA83tAGUs2ksy7iJXLJdhCg4AtwGm4SFl4f6kxhCSzlN1QdXuZjvRT2wZZiGUi9xUE28rf4WLrTxSnwqLuTy5knMP08yC0t_0YU_FGPZMcWb14hG05IvZr8UbmRaVagxSR8H4rSIymRoVwwmFSrqz068XrWGSYNIfLEASyo5GdAaqmk1JALINHgYGQJVxMxtwcvDxoVKmC7eltUNymMNBZhsv4E8sx9YNLpBoEibznfEpDU_DGzrM5eZCsQzaqbhBOlGd427ifud_Nnd9cPqzgCUc23-0FXSPfpbgksCXAwAmD0OFjQWrgqVdKL6Q", "e": "AQAB", }] }) } fn personal_access_token_whoami(account_id: &str) -> serde_json::Value { json!({ "email": "user@example.com", "chatgpt_user_id": "user-123", "chatgpt_account_id": account_id, "chatgpt_plan_type": "business", "chatgpt_account_is_fedramp": true, }) } const TEST_AGENT_IDENTITY_RSA_PRIVATE_KEY_PEM: &[u8] = br#"-----BEGIN PRIVATE KEY----- MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDWpAXYypOsYAwO bvBduMk/mxaoYDze0AZSzaSzLuIlcsl2EKDgC3AabhIWXh/qTGEJLOU3VB1e5mO9 FPbBlmIZSL3FQTbyt/hYutPFKfCou5PLmScw/TzILS3/RhT8UY9kxxZvXiEbTki9 mvxRuZFpVqDFJHwfitIjKZGhXDCYVKurPTrxetYZJg0h8sQBLKjkZ0BqqaTUkAsg 0eBgZAlXEzG3By8PGhUqYLt6W1Q3KYw0FmGy/gTyzH1g0ukGgSJvOd8SkNT8MbOs zl5kKxDNqpuEE6UZ3jbuJ+5382d31w+rOAJRzbf7QVdI9+luCSwJcDACYPQ4WNBa uCpV0ovpAgMBAAECggEAVu84LwZdqYN9XpswX8VoPYrjMm9IODapWQBRpQFoNyK2 1ksF3bjEPvA2Azk8U/l7k+vLKw22l6lY3EyRZPcz5GnB8xLm3ogE3mtNOp4yCyVu RxhQ91aaN7mU17/a4BdorLi2LYVCg3zBmYociD1Q2AluNGsCmwPu+K7tfR2J0Sg8 NjqiTbDG1XDpR/icwgC9t6vh8lZpCHDhF4tbQfLLVLeA/OdcuzXDyMCXbmdVIdBQ rm4aIFmr2e1/2ctTbCg85S6AGFTH+pSLjrwTzyvf+F6NW5uNjLQAQLFj+EznBDxj Xdx90cySrjsKK6PVWQF4RiTvkSW8eWL7R6B2FZbGwQKBgQDuVQRj72hWloR7mbEL aUEEv3pIXTMXWEsoMBNczos/1L1RnAN1AI44TurznasPZAWvQj+kVbLDR+TAeZrL iA8HIWswQUI18hFmgKzSkwIXGtubcKVrgsKeS4lMDKCM/Ef6WAYdeq6ronoY5lCN YrJFmGp81W5zcV7lyiycgbSiGwKBgQDmjWYf6pZjrK7Z+OJ3X1AZfi2vss15SCvL 3fPgzIDbViztpGyQhc3DQZIsBNIu0xZp/veGce9TEeTds2ro9NfdJFeou8+fC7Pq sOsM3amGFFi+ZW/9BWyjZEM88bgWWAjqLHbpfHDxjAf5CSxddqxgHlbP0Ytyb1Vg gmPDn9YKSwKBgQDbTi3hC35WFuDHn0/zcSHcDZmnFuOZeqyFyV83yfMGhGrEuqvP sPgtRikajJ3IZsB4WZyYSidZXEFY/0z6NjOl2xF38MTNQPbT/FmK1q1Yt2UWrlv5 BvSwlk87RG9D7C0LZo4R+D7cPoDdgqjiwMvMEIkEX5zn641oI1ZTmWKuuwKBgQCD KF+3unnRvHRAVoFnTZbA2fJdqMeRvogD04GhGlYX8V9f1hFY6nXTJaNlXVzA/J8c r8ra9kgjJuPfZ+ljG58OFFW2DRohLcQtuHYPfK6rMzoFHqnl9EcIcMp7ijuionR3 29HOJFgQYgxLFXfit9d6WugiE+BTupiEbckZif13HwKBgE/lAlkVHP6YahOO2Ljc J1bwkqKZTB5dHolX9A58e/xXnfZ5P8f3Z83+Izap3FwqQulk7b1WO1MQcHuVg2NN 5da0D4h2rYOXnbYIg0BVu4spQbaM6ewsp66b8+MzLOBvj8SzWdt1Oyw0q/MRyQAR 8U4M2TSWCKUY/A6sT4W8+mT9 -----END PRIVATE KEY-----"#; #[tokio::test] #[serial(codex_auth_env)] async fn agent_identity_plan_type_maps_raw_enterprise_alias() { assert_agent_identity_plan_alias(json!("hc"), AccountPlanType::Enterprise).await; } #[tokio::test] #[serial(codex_auth_env)] async fn agent_identity_plan_type_maps_raw_education_alias() { assert_agent_identity_plan_alias(json!("education"), AccountPlanType::Edu).await; } async fn assert_agent_identity_plan_alias( plan_type: serde_json::Value, expected_plan_type: AccountPlanType, ) { let record = agent_identity_record("account-id"); let jwt = signed_agent_identity_jwt(&record, plan_type).expect("agent identity jwt"); let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/backend-api/wham/agent-identities/jwks")) .respond_with(ResponseTemplate::new(200).set_body_json(test_jwks_body())) .expect(1) .mount(&server) .await; Mock::given(method("POST")) .and(path("/v1/agent/agent-runtime-id/task/register")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "task_id": "task-123", }))) .expect(1) .mount(&server) .await; let authapi_base_url = server.uri(); let chatgpt_base_url = format!("{authapi_base_url}/backend-api"); let auth = CodexAuth::from_agent_identity_jwt_with_authapi_base_url( &jwt, Some(&chatgpt_base_url), &authapi_base_url, &crate::test_support::transport_default_auth_route_config(), ) .await .expect("agent identity auth"); pretty_assertions::assert_eq!(auth.account_plan_type(), Some(expected_plan_type)); server.verify().await; } #[tokio::test] #[serial(codex_auth_env)] async fn plan_type_maps_known_plan() { let codex_home = tempdir().unwrap(); let _access_token_guard = remove_access_token_env_var(); let _jwt = write_auth_file( AuthFileParams { openai_api_key: None, chatgpt_plan_type: Some("pro".to_string()), chatgpt_account_id: None, }, 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, /*allowed_login_methods*/ None, /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, /*agent_identity_authapi_base_url*/ None, &crate::test_support::transport_default_auth_route_config(), /*cached_auth*/ None, /*allowed_auth_modes*/ None, ) .await .expect("load auth") .expect("auth available"); pretty_assertions::assert_eq!(auth.account_plan_type(), Some(AccountPlanType::Pro)); } #[tokio::test] #[serial(codex_auth_env)] async fn plan_type_maps_self_serve_business_usage_based_plan() { let codex_home = tempdir().unwrap(); let _access_token_guard = remove_access_token_env_var(); let _jwt = write_auth_file( AuthFileParams { openai_api_key: None, chatgpt_plan_type: Some("self_serve_business_usage_based".to_string()), chatgpt_account_id: None, }, 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, /*allowed_login_methods*/ None, /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, /*agent_identity_authapi_base_url*/ None, &crate::test_support::transport_default_auth_route_config(), /*cached_auth*/ None, /*allowed_auth_modes*/ None, ) .await .expect("load auth") .expect("auth available"); pretty_assertions::assert_eq!( auth.account_plan_type(), Some(AccountPlanType::SelfServeBusinessUsageBased) ); } #[tokio::test] #[serial(codex_auth_env)] async fn plan_type_maps_enterprise_cbp_usage_based_plan() { let codex_home = tempdir().unwrap(); let _access_token_guard = remove_access_token_env_var(); let _jwt = write_auth_file( AuthFileParams { openai_api_key: None, chatgpt_plan_type: Some("enterprise_cbp_usage_based".to_string()), chatgpt_account_id: None, }, 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, /*allowed_login_methods*/ None, /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, /*agent_identity_authapi_base_url*/ None, &crate::test_support::transport_default_auth_route_config(), /*cached_auth*/ None, /*allowed_auth_modes*/ None, ) .await .expect("load auth") .expect("auth available"); pretty_assertions::assert_eq!( auth.account_plan_type(), Some(AccountPlanType::EnterpriseCbpUsageBased) ); } #[tokio::test] #[serial(codex_auth_env)] async fn plan_type_maps_unknown_to_unknown() { let codex_home = tempdir().unwrap(); let _access_token_guard = remove_access_token_env_var(); let _jwt = write_auth_file( AuthFileParams { openai_api_key: None, chatgpt_plan_type: Some("mystery-tier".to_string()), chatgpt_account_id: None, }, 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, /*allowed_login_methods*/ None, /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, /*agent_identity_authapi_base_url*/ None, &crate::test_support::transport_default_auth_route_config(), /*cached_auth*/ None, /*allowed_auth_modes*/ None, ) .await .expect("load auth") .expect("auth available"); pretty_assertions::assert_eq!(auth.account_plan_type(), Some(AccountPlanType::Unknown)); } #[tokio::test] #[serial(codex_auth_env)] async fn missing_plan_type_maps_to_unknown() { let codex_home = tempdir().unwrap(); let _access_token_guard = remove_access_token_env_var(); let _jwt = write_auth_file( AuthFileParams { openai_api_key: None, chatgpt_plan_type: None, chatgpt_account_id: None, }, 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, /*allowed_login_methods*/ None, /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, /*agent_identity_authapi_base_url*/ None, &crate::test_support::transport_default_auth_route_config(), /*cached_auth*/ None, /*allowed_auth_modes*/ None, ) .await .expect("load auth") .expect("auth available"); pretty_assertions::assert_eq!(auth.account_plan_type(), Some(AccountPlanType::Unknown)); }