use std::path::Path; use std::sync::Arc; use std::sync::atomic::AtomicUsize; use std::sync::atomic::Ordering; use anyhow::Context; use anyhow::Result; use app_test_support::ChatGptAuthFixture; use app_test_support::write_chatgpt_auth; use codex_config::types::AuthCredentialsStoreMode; use codex_login::AuthKeyringBackendKind; use codex_login::CLIENT_ID; use codex_login::CODEX_ACCESS_TOKEN_ENV_VAR; use codex_login::REVOKE_TOKEN_URL_OVERRIDE_ENV_VAR; use codex_login::login_with_bedrock_access_keys; use codex_protocol::shell_environment::OPENAI_FEDERATION_RULE_ID_ENV_VAR; use codex_protocol::shell_environment::OPENAI_IDENTITY_TOKEN_FILE_ENV_VAR; use predicates::str::contains; use pretty_assertions::assert_eq; use serde_json::Value; use serde_json::json; use tempfile::TempDir; use wiremock::Mock; use wiremock::MockServer; use wiremock::ResponseTemplate; use wiremock::matchers::header; use wiremock::matchers::method; use wiremock::matchers::path; fn codex_command(codex_home: &Path) -> Result { let mut cmd = assert_cmd::Command::new(codex_utils_cargo_bin::cargo_bin("codex")?); cmd.env("CODEX_HOME", codex_home); Ok(cmd) } fn write_file_auth_config(codex_home: &Path) -> Result<()> { std::fs::write( codex_home.join("config.toml"), "cli_auth_credentials_store = \"file\"\n", )?; Ok(()) } fn read_auth_json(codex_home: &Path) -> Result { let auth_json = std::fs::read_to_string(codex_home.join("auth.json"))?; Ok(serde_json::from_str(&auth_json)?) } const SITES_GIT_REQUEST: &str = "capability[]=authtype\nprotocol=https\nhost=chatgpt.com\npath=backend-api/codex/sites/git/fixture.git\n\n"; #[test] fn git_credential_advertises_authtype_before_login() -> Result<()> { let codex_home = TempDir::new()?; codex_command(codex_home.path())? .args(["git-credential", "capability"]) .assert() .success() .stdout(contains("capability authtype\n")); Ok(()) } #[test] fn git_credential_reads_only_the_first_record() -> Result<()> { let home = TempDir::new()?; codex_command(home.path())? .args(["git-credential", "get"]) .write_stdin(format!( "protocol=https\nhost=chatgpt.com\n\n{SITES_GIT_REQUEST}" )) .assert() .success() .stdout(""); Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn git_credential_declines_personal_access_tokens_without_a_lookup() -> Result<()> { let home = TempDir::new()?; write_file_auth_config(home.path())?; let auth_api = MockServer::start().await; codex_command(home.path())? .env_remove(OPENAI_FEDERATION_RULE_ID_ENV_VAR) .env_remove(OPENAI_IDENTITY_TOKEN_FILE_ENV_VAR) .env(CODEX_ACCESS_TOKEN_ENV_VAR, "at-fixture") .env("CODEX_AUTHAPI_BASE_URL", auth_api.uri()) .args(["git-credential", "get"]) .write_stdin(SITES_GIT_REQUEST) .assert() .success() .stdout("quit=true\n\n"); assert!(auth_api.received_requests().await.unwrap().is_empty()); Ok(()) } #[test] fn login_with_api_key_reads_stdin_and_writes_auth_json() -> Result<()> { let codex_home = TempDir::new()?; write_file_auth_config(codex_home.path())?; let mut cmd = codex_command(codex_home.path())?; let login = cmd .args([ "-c", "forced_login_method=\"api\"", "login", "--with-api-key", ]) .write_stdin("sk-test\n") .assert() .success(); insta::assert_snapshot!(String::from_utf8_lossy(&login.get_output().stderr), @r" Reading API key from stdin... API key saved. Authentication is checked when you send a request. "); let auth = read_auth_json(codex_home.path())?; assert_eq!(auth["OPENAI_API_KEY"], "sk-test"); assert!(auth.get("tokens").is_none()); assert!(auth.get("agent_identity").is_none()); let status = codex_command(codex_home.path())? .env_remove(CODEX_ACCESS_TOKEN_ENV_VAR) .env_remove(OPENAI_FEDERATION_RULE_ID_ENV_VAR) .env_remove(OPENAI_IDENTITY_TOKEN_FILE_ENV_VAR) .args(["login", "status"]) .assert() .success(); insta::assert_snapshot!(String::from_utf8_lossy(&status.get_output().stderr), @r" API key configured - *** Authentication is checked when you send a request. "); for args in [vec!["login"], vec!["login", "--device-auth"]] { codex_command(codex_home.path())? .args(["-c", "forced_login_method=\"api\""]) .args(args) .assert() .failure() .stderr(contains("ChatGPT login is disabled")); assert_eq!(read_auth_json(codex_home.path())?, auth); } Ok(()) } #[test] fn api_key_auth_commands_work_when_log_dir_is_unavailable() -> Result<()> { let codex_home = TempDir::new()?; let log_dir = codex_home.path().join("unavailable-logs"); std::fs::write(&log_dir, "not a directory")?; std::fs::write( codex_home.path().join("config.toml"), format!("cli_auth_credentials_store = \"file\"\nlog_dir = {log_dir:?}\n"), )?; codex_command(codex_home.path())? .args(["login", "--with-api-key"]) .write_stdin("sk-test\n") .assert() .success() .stderr(contains("Warning: failed to create login log directory")); assert_eq!( read_auth_json(codex_home.path())?["OPENAI_API_KEY"], "sk-test" ); codex_command(codex_home.path())? .args(["login", "status"]) .assert() .success() .stderr(contains("API key")); codex_command(codex_home.path())? .arg("logout") .assert() .success() .stderr(contains("Successfully logged out")); assert!(!codex_home.path().join("auth.json").exists()); Ok(()) } #[test] fn login_status_reports_auth_storage_errors() -> Result<()> { let codex_home = TempDir::new()?; write_file_auth_config(codex_home.path())?; std::fs::write(codex_home.path().join("auth.json"), "{invalid json")?; codex_command(codex_home.path())? .args(["login", "status"]) .assert() .failure() .stderr(contains("Error checking login status:")); Ok(()) } #[test] fn login_status_validates_configured_workload_identity() -> Result<()> { let codex_home = TempDir::new()?; write_file_auth_config(codex_home.path())?; let missing_assertion = codex_home.path().join("missing-identity-token"); for login_method in ["chatgpt", "api"] { let mut command = codex_command(codex_home.path())?; command .env_remove(CODEX_ACCESS_TOKEN_ENV_VAR) .env(OPENAI_IDENTITY_TOKEN_FILE_ENV_VAR, &missing_assertion) .env(OPENAI_FEDERATION_RULE_ID_ENV_VAR, "rule-test") .args([ "-c", &format!("forced_login_method=\"{login_method}\""), "login", "status", ]); command .assert() .failure() .stderr(contains("could not read workload identity assertion file")); } Ok(()) } #[test] fn logout_clears_only_the_selected_bedrock_provider() -> Result<()> { for (model_provider_id, managed_bedrock_auth, model) in [ ("amazon-bedrock", true, "openai.gpt-5.6-sol"), ("amazon-bedrock-runtime", true, "global.openai.gpt-5.6-sol"), ("openai", true, "gpt-5.6-sol"), ("amazon-bedrock", false, "gpt-5.6-sol"), ("amazon-bedrock-runtime", false, "us.openai.gpt-5.6-sol"), ("openai", false, "gpt-5.6-sol"), ] { let codex_home = TempDir::new()?; let config_path = codex_home.path().join("config.toml"); std::fs::write( &config_path, format!( "cli_auth_credentials_store = \"file\"\n\ model_provider = \"{model_provider_id}\"\n\ model = \"{model}\"\n\ model_reasoning_effort = \"high\"\n\ [model_providers.amazon-bedrock]\n\ base_url = \"https://mantle.example.com/v1\"\n\ [model_providers.amazon-bedrock.aws]\n\ profile = \"mantle-profile\"\n\ region = \"us-west-2\"\n\ auth_refresh = {{ command = \"aws\", args = [\"sso\", \"login\"] }}\n\ [model_providers.amazon-bedrock-runtime]\n\ base_url = \"https://runtime.example.com/v1\"\n\ [model_providers.amazon-bedrock-runtime.aws]\n\ profile = \"runtime-profile\"\n\ region = \"us-east-1\"\n\ auth_refresh = {{ command = \"aws\", args = [\"login\"] }}\n" ), )?; if managed_bedrock_auth { login_with_bedrock_access_keys( codex_home.path(), "managed-access-key-id", "managed-secret-access-key", Some("managed-session-token"), AuthCredentialsStoreMode::File, AuthKeyringBackendKind::default(), )?; } let mut expected_config: toml::Value = toml::from_str(&std::fs::read_to_string(&config_path)?)?; if model_provider_id != "openai" { let expected_root = expected_config .as_table_mut() .expect("config should be a table"); expected_root.remove("model_provider"); expected_root.remove("model"); expected_root["model_providers"][model_provider_id] .as_table_mut() .expect("selected Bedrock provider should be a table") .remove("aws"); } let expected_message = if managed_bedrock_auth || model_provider_id != "openai" { "Successfully logged out" } else { "Not logged in" }; codex_command(codex_home.path())? .env_remove(CODEX_ACCESS_TOKEN_ENV_VAR) .env("AWS_ACCESS_KEY_ID", "environment-access-key-id") .env("AWS_SECRET_ACCESS_KEY", "environment-secret-access-key") .args(["logout"]) .assert() .success() .stderr(contains(expected_message)); assert!(!codex_home.path().join("auth.json").exists()); let actual_config: toml::Value = toml::from_str(&std::fs::read_to_string(&config_path)?)?; assert_eq!(actual_config, expected_config); } Ok(()) } #[tokio::test] async fn logout_survives_enterprise_cleanup_failure_with_xaa_disabled() -> Result<()> { let server = MockServer::start().await; Mock::given(path("/backend-api/wham/config/bundle")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "config_toml": {"enterprise_managed": []}, "requirements_toml": {"enterprise_managed": []}, }))) .mount(&server) .await; Mock::given(method("POST")) .and(path("/oauth/revoke")) .respond_with(ResponseTemplate::new(200)) .expect(1) .mount(&server) .await; let home = TempDir::new()?; std::fs::write( home.path().join("config.toml"), format!( "cli_auth_credentials_store = \"file\"\nchatgpt_base_url = \"{}/backend-api\"\n[features]\nuse_xaa = false\n[mcp_enterprise_managed_auth.idp]\nissuer = \"https://idp.example\"\nclient_id = \"enterprise-client\"\n[mcp_servers.enterprise]\nurl = \"https://resource.example/mcp\"\nauth = \"ema_auth\"\nbearer_token_env_var = \"UNUSED_TOKEN\"\n", server.uri() ), )?; write_chatgpt_auth( home.path(), ChatGptAuthFixture::new("account-access") .account_id("workspace") .chatgpt_user_id("user"), AuthCredentialsStoreMode::File, )?; // Fail before touching the real keyring; the subprocess owns this isolated home. std::fs::write(home.path().join("mcp-oauth-locks"), "not a directory")?; for enabled in [false, true] { codex_command(home.path())? .current_dir(home.path()) .env("NO_PROXY", "127.0.0.1,localhost") .env("no_proxy", "127.0.0.1,localhost") .env_remove("CODEX_API_KEY") .env_remove("OPENAI_API_KEY") .env_remove(CODEX_ACCESS_TOKEN_ENV_VAR) .args([ "-c", &format!("features.use_xaa={enabled}"), "mcp", "logout", "enterprise", ]) .assert() .failure() .stderr(contains("failed to delete enterprise authorization")); assert!(home.path().join("auth.json").exists()); } codex_command(home.path())? .current_dir(home.path()) .env("NO_PROXY", "127.0.0.1,localhost") .env("no_proxy", "127.0.0.1,localhost") .env( REVOKE_TOKEN_URL_OVERRIDE_ENV_VAR, format!("{}/oauth/revoke", server.uri()), ) .env_remove("CODEX_API_KEY") .env_remove("OPENAI_API_KEY") .env_remove(CODEX_ACCESS_TOKEN_ENV_VAR) .args(["logout"]) .assert() .success() .stderr(contains("continuing account logout")) .stderr(contains("Successfully logged out")); assert!(!home.path().join("auth.json").exists()); Ok(()) } #[test] fn login_with_access_token_rejects_invalid_jwt() -> Result<()> { let codex_home = TempDir::new()?; write_file_auth_config(codex_home.path())?; let mut cmd = codex_command(codex_home.path())?; cmd.args(["login", "--with-access-token"]) .write_stdin("not-a-jwt\n") .assert() .failure() .stderr(contains("Error logging in with access token")); Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn debug_prompt_input_follows_authenticated_attribution_setting() -> Result<()> { let server = MockServer::start().await; let request_count = Arc::new(AtomicUsize::new(0)); Mock::given(method("GET")) .and(path("/backend-api/wham/settings/user")) .and(header("chatgpt-account-id", "workspace-123")) .respond_with(move |_request: &wiremock::Request| { ResponseTemplate::new(200).set_body_json(json!({ "commit_attribution_enabled": request_count.fetch_add(1, Ordering::SeqCst) == 0, })) }) .expect(2) .mount(&server) .await; let codex_home = TempDir::new()?; std::fs::write( codex_home.path().join("config.toml"), format!( "cli_auth_credentials_store = \"file\"\nchatgpt_base_url = \"{}/backend-api\"\n", server.uri() ), )?; write_chatgpt_auth( codex_home.path(), ChatGptAuthFixture::new("chatgpt-token") .account_id("workspace-123") .plan_type("enterprise"), AuthCredentialsStoreMode::File, )?; for enabled in [true, false] { let output = codex_command(codex_home.path())? .env("NO_PROXY", "127.0.0.1,localhost") .env("no_proxy", "127.0.0.1,localhost") .env_remove("CODEX_ACCESS_TOKEN") .env_remove("OPENAI_API_KEY") .args(["debug", "prompt-input"]) .output()?; assert!(output.status.success()); let prompt = String::from_utf8(output.stdout)?; assert_eq!( prompt.contains("Co-authored-by: Codex "), enabled ); assert!(!prompt.contains("attribution is disabled for the current workspace")); } server.verify().await; Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn device_login_revokes_existing_auth_before_requesting_new_tokens() -> Result<()> { let server = MockServer::start().await; Mock::given(method("POST")) .and(path("/oauth/revoke")) .respond_with(ResponseTemplate::new(200)) .expect(1) .mount(&server) .await; Mock::given(method("POST")) .and(path("/api/accounts/deviceauth/usercode")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "device_auth_id": "device-auth-123", "user_code": "CODE-12345", "interval": "0", }))) .expect(1) .mount(&server) .await; Mock::given(method("POST")) .and(path("/api/accounts/deviceauth/token")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "authorization_code": "authorization-code-123", "code_challenge": "code-challenge-123", "code_verifier": "code-verifier-123", }))) .expect(1) .mount(&server) .await; Mock::given(method("POST")) .and(path("/oauth/token")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "id_token": "eyJhbGciOiJub25lIn0.e30.c2ln", "access_token": "new-access", "refresh_token": "new-refresh", }))) .expect(1) .mount(&server) .await; let codex_home = TempDir::new()?; write_file_auth_config(codex_home.path())?; std::fs::write( codex_home.path().join("auth.json"), serde_json::to_vec(&json!({ "auth_mode": "chatgpt", "OPENAI_API_KEY": null, "tokens": { "id_token": "eyJhbGciOiJub25lIn0.e30.c2ln", "access_token": "old-access", "refresh_token": "old-refresh", "account_id": "old-account", }, }))?, )?; let issuer = server.uri(); let mut cmd = codex_command(codex_home.path())?; cmd.env( REVOKE_TOKEN_URL_OVERRIDE_ENV_VAR, format!("{issuer}/oauth/revoke"), ) .env("NO_PROXY", "127.0.0.1,localhost") .env("no_proxy", "127.0.0.1,localhost") .env_remove("CODEX_ACCESS_TOKEN") .env_remove("OPENAI_API_KEY") .args(["login", "--device-auth", "--experimental_issuer", &issuer]) .assert() .success() .stderr(contains("Successfully logged in")); let requests = server .received_requests() .await .context("failed to read mock OAuth requests")?; let paths: Vec<&str> = requests.iter().map(|request| request.url.path()).collect(); assert_eq!( paths, vec![ "/oauth/revoke", "/api/accounts/deviceauth/usercode", "/api/accounts/deviceauth/token", "/oauth/token", ] ); assert_eq!( requests[0] .body_json::() .context("revoke request should be JSON")?, json!({ "token": "old-refresh", "token_type_hint": "refresh_token", "client_id": CLIENT_ID, }) ); let auth = read_auth_json(codex_home.path())?; assert_eq!(auth["tokens"]["refresh_token"], "new-refresh"); Ok(()) }