diff --git a/cli/tests/chatgpt_auth_failure_e2e.rs b/cli/tests/chatgpt_auth_failure_e2e.rs new file mode 100644 index 0000000..d9b75a8 --- /dev/null +++ b/cli/tests/chatgpt_auth_failure_e2e.rs @@ -0,0 +1,666 @@ +mod support; + +use std::fs; +use std::io::{Read, Write}; +use std::net::{TcpListener, TcpStream}; +#[cfg(unix)] +use std::os::unix::process::ExitStatusExt as _; +use std::time::Duration; + +use serde_json::json; +use support::chatgpt_auth::{ + BROWSER_TIMEOUT_MS_ENV, CliProcess, DEVICE_TIMEOUT_MS_ENV, ExpectedRequest, OpenAiTestEnv, + ScriptedServer, TokenCanaries, inspect_authorization_url, +}; + +const DEADLINE: Duration = Duration::from_secs(5); +const QUIET_PERIOD: Duration = Duration::from_millis(150); +const USER_CODE: &str = "FAIL-E2E"; +const DEVICE_AUTH_ID: &str = "failure-device-auth-id-canary"; +const AUTHORIZATION_CODE: &str = "failure-authorization-code-canary"; +const CODE_VERIFIER: &str = "failure-code-verifier-canary"; +const CODE_CHALLENGE: &str = "failure-code-challenge-canary"; +const CALLBACK_CODE: &str = "failure-callback-code-canary"; +const UNRELATED_AUTH_MARKER: &str = "unrelated-auth-entry-canary"; +const UNRELATED_AUTH: &[u8] = br#"{ + "entries": [ + { + "provider": "anthropic", + "method": "api_key", + "source": { + "kind": "stored_hint", + "hint": "unrelated-auth-entry-canary" + }, + "updated_at": "2026-08-18T00:00:00Z" + } + ] +} +"#; + +#[test] +fn browser_state_mismatch_returns_400_without_exchange_or_mutation() { + let server = ScriptedServer::start([]); + let env = OpenAiTestEnv::for_server(&server); + let mut cli = spawn_browser_login(&env); + let (state, listening_uri) = browser_ready(&cli); + assert_ne!(state, "wrong-state-canary"); + + let response = callback_with_pairs( + &listening_uri, + &[("code", CALLBACK_CODE), ("state", "wrong-state-canary")], + ); + assert_bad_callback(&response); + let output = assert_browser_failure(&mut cli, &listening_uri, "callback"); + assert!(output.contains("state mismatch"), "{output}"); + assert_absent(&output, &[CALLBACK_CODE, "wrong-state-canary"]); + server.assert_no_request(QUIET_PERIOD); + server.assert_finished(); +} + +#[test] +fn browser_rejection_is_actionable_bounded_and_terminal_safe() { + let server = ScriptedServer::start([]); + let env = OpenAiTestEnv::for_server(&server); + let mut cli = spawn_browser_login(&env); + let (state, listening_uri) = browser_ready(&cli); + let description = format!( + "access denied by organization\nINJECTED-LINE\u{1b}[31m \ + https://url-user-canary:url-password-canary@example.invalid/denied?code=url-code-canary&state=url-state-canary&code_verifier=url-verifier-canary&access_token=url-token-canary&refresh_token=url-refresh-canary {}", + "x".repeat(1000) + ); + + let response = callback_with_pairs( + &listening_uri, + &[ + ("error", "access_denied"), + ("error_description", &description), + ("state", &state), + ], + ); + assert_bad_callback(&response); + let output = assert_browser_failure(&mut cli, &listening_uri, "callback"); + assert!(output.contains("access denied by organization"), "{output}"); + assert!( + output.contains("https://example.invalid/denied"), + "{output}" + ); + assert!( + !output.contains("\u{1b}"), + "terminal escape leaked: {output:?}" + ); + assert!( + !output + .lines() + .any(|line| line.starts_with("[stderr] INJECTED-LINE")), + "multiline injection: {output:?}" + ); + assert_absent( + &output, + &[ + "url-user-canary", + "url-password-canary", + "url-code-canary", + "url-state-canary", + "url-verifier-canary", + "url-token-canary", + "url-refresh-canary", + ], + ); + let rejection_line = output + .lines() + .find(|line| line.contains("callback was rejected")) + .expect("actionable callback rejection line"); + assert!(rejection_line.chars().count() < 400, "{rejection_line}"); + server.assert_no_request(QUIET_PERIOD); + server.assert_finished(); +} + +#[test] +fn browser_callback_without_code_or_error_returns_400_without_exchange() { + let server = ScriptedServer::start([]); + let env = OpenAiTestEnv::for_server(&server); + let mut cli = spawn_browser_login(&env); + let (state, listening_uri) = browser_ready(&cli); + + let response = callback_with_pairs(&listening_uri, &[("state", &state)]); + assert_bad_callback(&response); + let output = assert_browser_failure(&mut cli, &listening_uri, "callback"); + assert!(output.contains("omitted the code"), "{output}"); + server.assert_no_request(QUIET_PERIOD); + server.assert_finished(); +} + +#[test] +fn browser_malformed_callback_target_returns_400_without_exchange() { + let server = ScriptedServer::start([]); + let env = OpenAiTestEnv::for_server(&server); + let mut cli = spawn_browser_login(&env); + let (state, listening_uri) = browser_ready(&cli); + let query = url::form_urlencoded::Serializer::new(String::new()) + .append_pair("code", CALLBACK_CODE) + .append_pair("state", &state) + .finish(); + + let response = raw_callback(&listening_uri, &format!("/wrong/callback?{query}")); + assert_bad_callback(&response); + let output = assert_browser_failure(&mut cli, &listening_uri, "callback"); + assert!(output.contains("request target"), "{output}"); + assert_absent(&output, &[CALLBACK_CODE]); + server.assert_no_request(QUIET_PERIOD); + server.assert_finished(); +} + +#[test] +fn browser_callback_timeout_exits_and_releases_listener() { + let server = ScriptedServer::start([]); + let env = OpenAiTestEnv::for_server(&server).set(BROWSER_TIMEOUT_MS_ENV, "150"); + let mut cli = spawn_browser_login(&env); + let (_, listening_uri) = browser_ready(&cli); + + let output = assert_browser_failure(&mut cli, &listening_uri, "timed out"); + assert!(output.contains("callback timed out"), "{output}"); + server.assert_no_request(QUIET_PERIOD); + server.assert_finished(); +} + +#[test] +fn browser_token_endpoint_status_errors_are_bounded_and_redacted() { + for status in [400, 500] { + let description = if status == 400 { + format!( + "token endpoint unavailable \ + https://body-user-canary:body-password-canary@example.invalid/token?access_token=body-token-canary&refresh_token=body-refresh-canary&code=body-code-canary&state=body-state-canary&code_verifier=body-verifier-canary {}", + "y".repeat(1000) + ) + } else { + format!("oversized-provider-body-canary {}", "z".repeat(12 * 1024)) + }; + let server = ScriptedServer::start([ExpectedRequest::json( + "POST", + "/oauth/token", + json!({ "error_description": description }), + ) + .responding_with_status(status)]); + let env = OpenAiTestEnv::for_server(&server); + let mut cli = spawn_browser_login(&env); + let (state, listening_uri) = browser_ready(&cli); + + let response = callback_with_pairs( + &listening_uri, + &[("code", CALLBACK_CODE), ("state", &state)], + ); + assert!(response.starts_with("HTTP/1.1 200 OK"), "{response}"); + let _ = server.wait_for_request(DEADLINE); + let output = assert_browser_failure(&mut cli, &listening_uri, "token exchange"); + assert!(output.contains(&status.to_string()), "{output}"); + assert_absent( + &output, + &[ + CALLBACK_CODE, + "body-user-canary", + "body-password-canary", + "body-token-canary", + "body-refresh-canary", + "body-code-canary", + "body-state-canary", + "body-verifier-canary", + ], + ); + assert!( + output.chars().count() < 1500, + "unbounded error output: {output}" + ); + server.assert_no_request(QUIET_PERIOD); + server.assert_finished(); + } +} + +#[test] +fn browser_malformed_or_incomplete_token_success_never_persists() { + let canaries = TokenCanaries::new("browser-failed-token"); + let cases = [ + ExpectedRequest::raw("POST", "/oauth/token", "application/json", "{not-json"), + ExpectedRequest::json( + "POST", + "/oauth/token", + json!({ + "id_token": canaries.id_token, + "access_token": canaries.access_token, + "expires_in": 3600 + }), + ), + ]; + + for step in cases { + let server = ScriptedServer::start([step]); + let env = OpenAiTestEnv::for_server(&server); + let mut cli = spawn_browser_login(&env); + let (state, listening_uri) = browser_ready(&cli); + let response = callback_with_pairs( + &listening_uri, + &[("code", CALLBACK_CODE), ("state", &state)], + ); + assert!(response.starts_with("HTTP/1.1 200 OK"), "{response}"); + let _ = server.wait_for_request(DEADLINE); + + let output = assert_browser_failure(&mut cli, &listening_uri, "token exchange"); + assert_absent(&output, &[CALLBACK_CODE]); + canaries.assert_secrets_absent(&output, server.requests()); + server.assert_no_request(QUIET_PERIOD); + server.assert_finished(); + } +} + +#[cfg(unix)] +#[test] +fn browser_sigint_while_waiting_reaps_child_and_releases_listener() { + let server = ScriptedServer::start([]); + let env = OpenAiTestEnv::for_server(&server); + let mut cli = spawn_browser_login(&env); + let (_, listening_uri) = browser_ready(&cli); + let pid = cli.id(); + + cli.interrupt(); + let status = cli + .wait_for_exit(DEADLINE) + .unwrap_or_else(|| panic!("SIGINT did not stop browser login\n{}", cli.output())); + assert!(!status.success(), "SIGINT must not report successful login"); + assert!(status.signal() == Some(libc::SIGINT) || status.code().is_some()); + assert_process_reaped(pid); + assert_seeded_auth_preserved(&cli); + assert_listener_released(&listening_uri); + server.assert_no_request(QUIET_PERIOD); + server.assert_finished(); +} + +#[test] +fn malformed_device_authorization_start_payload_fails_before_polling() { + let server = ScriptedServer::start([ExpectedRequest::json( + "POST", + "/api/accounts/deviceauth/usercode", + json!({ "device_auth_id": "", "user_code": USER_CODE, "interval": "1" }), + )]); + let env = OpenAiTestEnv::for_server(&server); + let mut cli = spawn_device_login(&env); + let _ = server.wait_for_request(DEADLINE); + + let output = assert_device_failure(&mut cli, "device authorization"); + assert!(output.contains("required fields were empty"), "{output}"); + server.assert_no_request(QUIET_PERIOD); + server.assert_finished(); +} + +#[test] +fn non_pending_device_poll_statuses_are_terminal() { + for status in [401, 429, 500] { + let server = ScriptedServer::start([ + ExpectedRequest::device_user_code(USER_CODE, DEVICE_AUTH_ID), + ExpectedRequest::json( + "POST", + "/api/accounts/deviceauth/token", + json!({ "error_description": format!("terminal status {status}") }), + ) + .requiring(USER_CODE) + .requiring(DEVICE_AUTH_ID) + .responding_with_status(status), + ]); + let env = OpenAiTestEnv::for_server(&server); + let mut cli = spawn_device_login(&env); + let requests = server.wait_for_request_count(2, DEADLINE); + assert_eq!(requests.len(), 2); + device_instructions_visible(&cli); + + let output = assert_device_failure(&mut cli, "device authorization"); + assert!(output.contains(&status.to_string()), "{output}"); + assert_eq!(server.requests().len(), 2, "status {status} was retried"); + server.assert_no_request(QUIET_PERIOD); + server.assert_finished(); + } +} + +#[test] +fn malformed_device_poll_success_does_not_exchange_tokens() { + let server = ScriptedServer::start([ + ExpectedRequest::device_user_code(USER_CODE, DEVICE_AUTH_ID), + ExpectedRequest::json( + "POST", + "/api/accounts/deviceauth/token", + json!({ + "authorization_code": AUTHORIZATION_CODE, + "code_challenge": CODE_CHALLENGE + }), + ) + .requiring(USER_CODE) + .requiring(DEVICE_AUTH_ID), + ]); + let env = OpenAiTestEnv::for_server(&server); + let mut cli = spawn_device_login(&env); + let _ = server.wait_for_request_count(2, DEADLINE); + device_instructions_visible(&cli); + + let output = assert_device_failure(&mut cli, "device authorization"); + assert_absent(&output, &[AUTHORIZATION_CODE, CODE_CHALLENGE]); + server.assert_no_request(QUIET_PERIOD); + server.assert_finished(); +} + +#[test] +fn continuous_pending_device_polls_stop_at_short_timeout() { + let server = ScriptedServer::start([ + ExpectedRequest::device_user_code(USER_CODE, DEVICE_AUTH_ID), + ExpectedRequest::device_poll_pending(USER_CODE, DEVICE_AUTH_ID).responding_with_status(403), + ExpectedRequest::device_poll_pending(USER_CODE, DEVICE_AUTH_ID).responding_with_status(404), + ]); + let env = OpenAiTestEnv::for_server(&server).set(DEVICE_TIMEOUT_MS_ENV, "1250"); + let mut cli = spawn_device_login(&env); + let requests = server.wait_for_request_count(3, DEADLINE); + assert_eq!(requests.len(), 3); + device_instructions_visible(&cli); + + let output = assert_device_failure(&mut cli, "timed out"); + assert!( + output.contains("device authorization timed out"), + "{output}" + ); + assert_eq!(server.requests().len(), 3); + server.assert_no_request(QUIET_PERIOD); + server.assert_finished(); +} + +#[test] +fn device_token_exchange_status_errors_are_terminal_and_redacted() { + for status in [400, 500] { + let description = format!( + "device token rejected \ + https://device-user-canary:device-password-canary@example.invalid/token?code=device-body-code-canary&state=device-body-state-canary&code_verifier=device-body-verifier-canary&access_token=device-body-token-canary&refresh_token=device-body-refresh-canary {}", + "q".repeat(1000) + ); + let server = ScriptedServer::start([ + ExpectedRequest::device_user_code(USER_CODE, DEVICE_AUTH_ID), + ExpectedRequest::device_poll( + USER_CODE, + DEVICE_AUTH_ID, + AUTHORIZATION_CODE, + CODE_VERIFIER, + CODE_CHALLENGE, + ), + ExpectedRequest::json( + "POST", + "/oauth/token", + json!({ "error_description": description }), + ) + .responding_with_status(status), + ]); + let env = OpenAiTestEnv::for_server(&server); + let mut cli = spawn_device_login(&env); + let _ = server.wait_for_request_count(3, DEADLINE); + device_instructions_visible(&cli); + + let output = assert_device_failure(&mut cli, "token exchange"); + assert!(output.contains(&status.to_string()), "{output}"); + assert_absent( + &output, + &[ + AUTHORIZATION_CODE, + CODE_VERIFIER, + CODE_CHALLENGE, + "device-user-canary", + "device-password-canary", + "device-body-code-canary", + "device-body-state-canary", + "device-body-verifier-canary", + "device-body-token-canary", + "device-body-refresh-canary", + ], + ); + server.assert_no_request(QUIET_PERIOD); + server.assert_finished(); + } +} + +#[test] +fn incomplete_device_token_success_never_persists() { + let canaries = TokenCanaries::new("device-failed-token"); + let server = ScriptedServer::start([ + ExpectedRequest::device_user_code(USER_CODE, DEVICE_AUTH_ID), + ExpectedRequest::device_poll( + USER_CODE, + DEVICE_AUTH_ID, + AUTHORIZATION_CODE, + CODE_VERIFIER, + CODE_CHALLENGE, + ), + ExpectedRequest::json( + "POST", + "/oauth/token", + json!({ + "id_token": canaries.id_token, + "access_token": canaries.access_token, + "expires_in": 3600 + }), + ), + ]); + let env = OpenAiTestEnv::for_server(&server); + let mut cli = spawn_device_login(&env); + let _ = server.wait_for_request_count(3, DEADLINE); + device_instructions_visible(&cli); + + let output = assert_device_failure(&mut cli, "token exchange"); + canaries.assert_secrets_absent(&output, server.requests()); + assert_absent( + &output, + &[AUTHORIZATION_CODE, CODE_VERIFIER, CODE_CHALLENGE], + ); + server.assert_no_request(QUIET_PERIOD); + server.assert_finished(); +} + +#[test] +fn issuer_connection_close_during_device_poll_is_terminal() { + let server = ScriptedServer::start([ + ExpectedRequest::device_user_code(USER_CODE, DEVICE_AUTH_ID), + ExpectedRequest::json( + "POST", + "/api/accounts/deviceauth/token", + json!({ "unused": true }), + ) + .requiring(USER_CODE) + .requiring(DEVICE_AUTH_ID) + .closing_connection(), + ]); + let env = OpenAiTestEnv::for_server(&server); + let mut cli = spawn_device_login(&env); + let _ = server.wait_for_request_count(2, DEADLINE); + device_instructions_visible(&cli); + + let output = assert_device_failure(&mut cli, "device authorization"); + assert!( + output.contains("poll") && output.contains("failed"), + "{output}" + ); + server.assert_no_request(QUIET_PERIOD); + server.assert_finished(); +} + +#[cfg(unix)] +#[test] +fn device_sigint_while_pending_reaps_child_without_another_poll() { + let server = ScriptedServer::start([ + ExpectedRequest::device_user_code(USER_CODE, DEVICE_AUTH_ID), + ExpectedRequest::device_poll_pending(USER_CODE, DEVICE_AUTH_ID), + ]); + let env = OpenAiTestEnv::for_server(&server).set(DEVICE_TIMEOUT_MS_ENV, "5000"); + let mut cli = spawn_device_login(&env); + let _ = server.wait_for_request_count(2, DEADLINE); + device_instructions_visible(&cli); + let pid = cli.id(); + + cli.interrupt(); + let status = cli + .wait_for_exit(DEADLINE) + .unwrap_or_else(|| panic!("SIGINT did not stop device login\n{}", cli.output())); + assert!(!status.success(), "SIGINT must not report successful login"); + assert!(status.signal() == Some(libc::SIGINT) || status.code().is_some()); + assert_process_reaped(pid); + assert_seeded_auth_preserved(&cli); + server.assert_no_request(QUIET_PERIOD); + server.assert_finished(); +} + +fn spawn_browser_login(env: &OpenAiTestEnv) -> CliProcess { + CliProcess::spawn_with_auth( + [ + "auth", + "login", + "--provider", + "openai", + "--method", + "chatgpt", + ], + env, + UNRELATED_AUTH, + ) +} + +fn spawn_device_login(env: &OpenAiTestEnv) -> CliProcess { + CliProcess::spawn_with_auth( + [ + "auth", + "login", + "--provider", + "openai", + "--method", + "chatgpt", + "--device-code", + ], + env, + UNRELATED_AUTH, + ) +} + +fn browser_ready(cli: &CliProcess) -> (String, String) { + let authorization_url = cli.wait_for_stdout_prefix("open ", DEADLINE); + let listening_uri = cli.wait_for_stdout_prefix("listening ", DEADLINE); + let metadata = inspect_authorization_url(&authorization_url); + assert_eq!(metadata.redirect_uri, listening_uri); + (metadata.state, listening_uri) +} + +fn device_instructions_visible(cli: &CliProcess) { + let _ = cli.wait_for_stdout_prefix("open ", DEADLINE); + assert_eq!(cli.wait_for_stdout_prefix("code ", DEADLINE), USER_CODE); + let _ = cli.wait_for_stdout_prefix("waiting for authorization", DEADLINE); +} + +fn callback_with_pairs(redirect_uri: &str, pairs: &[(&str, &str)]) -> String { + let url = url::Url::parse(redirect_uri).expect("parse callback URI"); + let mut query = url::form_urlencoded::Serializer::new(String::new()); + for (name, value) in pairs { + query.append_pair(name, value); + } + raw_callback(redirect_uri, &format!("{}?{}", url.path(), query.finish())) +} + +fn raw_callback(redirect_uri: &str, target: &str) -> String { + let url = url::Url::parse(redirect_uri).expect("parse callback URI"); + let port = url.port().expect("callback port"); + let mut stream = TcpStream::connect(("127.0.0.1", port)).expect("connect callback listener"); + stream + .set_read_timeout(Some(DEADLINE)) + .expect("bound callback read"); + stream + .set_write_timeout(Some(DEADLINE)) + .expect("bound callback write"); + let request = + format!("GET {target} HTTP/1.1\r\nHost: localhost:{port}\r\nConnection: close\r\n\r\n"); + stream + .write_all(request.as_bytes()) + .expect("write callback request"); + let mut response = String::new(); + stream + .read_to_string(&mut response) + .expect("read callback response"); + response +} + +fn assert_bad_callback(response: &str) { + assert!( + response.starts_with("HTTP/1.1 400 Bad Request"), + "{response}" + ); + assert!(response.contains("Return to the terminal"), "{response}"); +} + +fn assert_browser_failure(cli: &mut CliProcess, listening_uri: &str, phase: &str) -> String { + let output = assert_failure(cli, phase); + assert_listener_released(listening_uri); + output +} + +fn assert_device_failure(cli: &mut CliProcess, phase: &str) -> String { + assert_failure(cli, phase) +} + +fn assert_failure(cli: &mut CliProcess, phase: &str) -> String { + let pid = cli.id(); + let status = cli.wait_for_exit(DEADLINE).unwrap_or_else(|| { + panic!( + "failure path did not exit before deadline\n{}", + cli.output() + ) + }); + assert!(!status.success(), "failure path exited successfully"); + assert_process_reaped(pid); + let output = cli.output(); + assert!( + output.to_ascii_lowercase().contains(phase), + "error omitted phase {phase:?}: {output}" + ); + assert_seeded_auth_preserved(cli); + output +} + +fn assert_seeded_auth_preserved(cli: &CliProcess) { + let auth_path = cli.home().join("auth.json"); + let contents = fs::read(&auth_path).expect("read seeded auth store"); + assert_eq!(contents, UNRELATED_AUTH, "unrelated auth entry was mutated"); + assert!( + !cli.home().join("auth.json.tmp").exists(), + "failure left a temporary auth file" + ); + assert!( + !cli.output().contains(UNRELATED_AUTH_MARKER), + "failure output printed auth file contents" + ); +} + +fn assert_listener_released(listening_uri: &str) { + let port = url::Url::parse(listening_uri) + .expect("parse callback listener") + .port() + .expect("callback port"); + let listener = TcpListener::bind(("127.0.0.1", port)) + .unwrap_or_else(|error| panic!("callback listener {port} survived exit: {error}")); + drop(listener); +} + +#[cfg(unix)] +fn assert_process_reaped(pid: u32) { + // SAFETY: signal 0 only probes the PID that wait_for_exit just reaped. + let result = unsafe { libc::kill(pid as libc::pid_t, 0) }; + assert_eq!(result, -1, "child process survived expected termination"); + assert_eq!( + std::io::Error::last_os_error().raw_os_error(), + Some(libc::ESRCH), + "child PID should no longer exist" + ); +} + +#[cfg(not(unix))] +fn assert_process_reaped(_pid: u32) {} + +fn assert_absent(output: &str, values: &[&str]) { + for value in values { + assert!(!output.contains(value), "output leaked {value:?}: {output}"); + } +} diff --git a/cli/tests/support/chatgpt_auth.rs b/cli/tests/support/chatgpt_auth.rs index 891e720..cb5e3c4 100644 --- a/cli/tests/support/chatgpt_auth.rs +++ b/cli/tests/support/chatgpt_auth.rs @@ -144,6 +144,23 @@ impl CliProcess { Self::spawn_with_layout(args, env, root, home, cwd) } + /// Spawn against an auth store written before the child starts. Failure + /// scenarios use this to prove unrelated credentials are byte-preserved. + pub fn spawn_with_auth(args: I, env: &OpenAiTestEnv, auth: &[u8]) -> Self + where + I: IntoIterator, + S: AsRef, + { + let root = Arc::new(tempfile::tempdir().expect("create ChatGPT auth fixture root")); + let home = root.path().join("home"); + let cwd = root.path().join("cwd"); + fs::create_dir_all(&home).expect("create isolated Orb Code home"); + fs::create_dir_all(&cwd).expect("create isolated child cwd"); + fs::write(home.join("auth.json"), auth).expect("seed unrelated auth entry"); + + Self::spawn_with_layout(args, env, root, home, cwd) + } + /// Start another real CLI process against this fixture's isolated home and /// cwd. The shared temporary root remains alive until both children drop. pub fn spawn_again(&self, args: I, env: &OpenAiTestEnv) -> Self @@ -389,18 +406,29 @@ pub struct ExpectedRequest { response_content_type: String, response_headers: Vec<(String, String)>, response_body: String, + close_without_response: bool, } impl ExpectedRequest { pub fn json(method: &str, path: &str, response_body: Value) -> Self { + Self::raw(method, path, "application/json", response_body.to_string()) + } + + pub fn raw( + method: &str, + path: &str, + content_type: &str, + response_body: impl Into, + ) -> Self { Self { method: method.to_string(), path: path.to_string(), required_body_markers: Vec::new(), response_status: 200, - response_content_type: "application/json".to_string(), + response_content_type: content_type.to_string(), response_headers: Vec::new(), - response_body: response_body.to_string(), + response_body: response_body.into(), + close_without_response: false, } } @@ -414,6 +442,11 @@ impl ExpectedRequest { self } + pub fn closing_connection(mut self) -> Self { + self.close_without_response = true; + self + } + pub fn browser_token_exchange(canaries: &TokenCanaries) -> Self { Self::json("POST", "/oauth/token", canaries.token_response()) .requiring("grant_type=authorization_code") @@ -486,6 +519,7 @@ impl ExpectedRequest { response_content_type: "text/event-stream".to_string(), response_headers: Vec::new(), response_body: body, + close_without_response: false, } } } @@ -628,6 +662,26 @@ impl ScriptedServer { .clone() } + pub fn assert_no_request(&self, deadline: Duration) { + match self.recorded_requests.recv_timeout(deadline) { + Ok(request) => panic!( + "fake OpenAI service received an unexpected request after terminal failure: {} {}", + request.method, request.path + ), + Err(RecvTimeoutError::Timeout) => {} + Err(RecvTimeoutError::Disconnected) => { + panic!("fake OpenAI service stopped before the quiet period completed") + } + } + let state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(failure) = &state.failure { + panic!("fake OpenAI service failed: {failure}"); + } + } + pub fn assert_finished(mut self) { if let Err(error) = self.verify_finished(DEFAULT_DEADLINE) { panic!("fake OpenAI service failed: {error}"); @@ -741,6 +795,9 @@ fn serve_one_request( .unwrap_or_else(std::sync::PoisonError::into_inner) .failure .is_some(); + if step.close_without_response { + return; + } if failed { write_response(stream, 500, "text/plain", &[], "script mismatch"); } else { @@ -891,6 +948,8 @@ fn write_response( 401 => "Unauthorized", 403 => "Forbidden", 404 => "Not Found", + 429 => "Too Many Requests", + 500 => "Internal Server Error", _ => "Internal Server Error", }; let extra_headers = headers diff --git a/config/src/openai_oauth.rs b/config/src/openai_oauth.rs index c832a65..623bfba 100644 --- a/config/src/openai_oauth.rs +++ b/config/src/openai_oauth.rs @@ -25,6 +25,8 @@ pub const OPENAI_OAUTH_ISSUER: &str = "https://auth.openai.com"; pub const CHATGPT_CODEX_BASE_URL: &str = "https://chatgpt.com/backend-api/codex"; const OPENAI_OAUTH_SCOPES: &str = "openid profile email offline_access api.connectors.read api.connectors.invoke"; +const MAX_OAUTH_ERROR_BODY_BYTES: usize = 8 * 1024; +const MAX_PROVIDER_ERROR_CHARS: usize = 240; #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] pub struct ChatGptOAuthCredentials { @@ -195,33 +197,7 @@ pub(crate) async fn complete_browser_login( let (mut socket, _) = session.listener.accept().await?; let mut buffer = vec![0_u8; 16 * 1024]; let size = socket.read(&mut buffer).await?; - let request = String::from_utf8_lossy(&buffer[..size]); - let target = request - .lines() - .next() - .and_then(|line| line.split_whitespace().nth(1)) - .ok_or_else(|| ConfigError::Config("invalid OAuth callback request".to_string()))?; - let callback_url = Url::parse(&format!("http://localhost{target}")) - .map_err(|error| ConfigError::Config(format!("invalid OAuth callback URL: {error}")))?; - let query = callback_url - .query_pairs() - .into_owned() - .collect::>(); - let outcome = if query.get("state") != Some(&session.state) { - Err(ConfigError::Config( - "ChatGPT OAuth state mismatch; login was cancelled".to_string(), - )) - } else if let Some(error) = query.get("error") { - Err(ConfigError::Config(format!( - "ChatGPT login was rejected: {}", - query.get("error_description").unwrap_or(error) - ))) - } else { - query - .get("code") - .cloned() - .ok_or_else(|| ConfigError::Config("OAuth callback omitted the code".to_string())) - }; + let outcome = parse_browser_callback(&buffer[..size], &session.state); let (status, body) = if outcome.is_ok() { ("200 OK", "ChatGPT sign-in completed. You may close this window.") } else { @@ -246,6 +222,63 @@ pub(crate) async fn complete_browser_login( .await } +fn parse_browser_callback(request: &[u8], expected_state: &str) -> Result { + let request = String::from_utf8_lossy(request); + let mut parts = request + .lines() + .next() + .unwrap_or_default() + .split_whitespace(); + let method = parts.next(); + let target = parts.next(); + let version = parts.next(); + if method != Some("GET") + || !version.is_some_and(|version| version.starts_with("HTTP/")) + || parts.next().is_some() + { + return Err(ConfigError::Config( + "invalid ChatGPT OAuth callback request".to_string(), + )); + } + let Some(target) = target.filter(|target| target.starts_with('/')) else { + return Err(ConfigError::Config( + "invalid ChatGPT OAuth callback request target".to_string(), + )); + }; + let callback_url = Url::parse(&format!("http://localhost{target}")).map_err(|_| { + ConfigError::Config("invalid ChatGPT OAuth callback request target".to_string()) + })?; + if callback_url.path() != "/auth/callback" || callback_url.fragment().is_some() { + return Err(ConfigError::Config( + "invalid ChatGPT OAuth callback request target".to_string(), + )); + } + let query = callback_url + .query_pairs() + .into_owned() + .collect::>(); + if query.get("state").map(String::as_str) != Some(expected_state) { + return Err(ConfigError::Config( + "ChatGPT OAuth callback state mismatch; login was cancelled".to_string(), + )); + } + if let Some(error) = query.get("error") { + let reason = query + .get("error_description") + .map(String::as_str) + .unwrap_or(error); + return Err(ConfigError::Config(format!( + "ChatGPT OAuth callback was rejected: {}", + sanitize_provider_message(reason) + ))); + } + query + .get("code") + .filter(|code| !code.trim().is_empty()) + .cloned() + .ok_or_else(|| ConfigError::Config("ChatGPT OAuth callback omitted the code".to_string())) +} + pub(crate) async fn start_device_login( options: OpenAiOAuthOptions, ) -> Result { @@ -257,15 +290,31 @@ pub(crate) async fn start_device_login( .json(&serde_json::json!({ "client_id": options.client_id })) .send() .await - .map_err(|error| oauth_transport_error("request device code", error))?; - let response = checked_oauth_response("device code request", response).await?; + .map_err(|error| oauth_transport_error("ChatGPT device authorization request", error))?; + let response = checked_oauth_response("ChatGPT device authorization request", response).await?; let payload = response .json::() .await - .map_err(|error| ConfigError::Config(format!("invalid device code response: {error}")))?; + .map_err(|error| { + ConfigError::Config(format!( + "invalid ChatGPT device authorization response: {error}" + )) + })?; + if payload.device_auth_id.trim().is_empty() || payload.user_code.trim().is_empty() { + return Err(ConfigError::Config( + "invalid ChatGPT device authorization response: required fields were empty".to_string(), + )); + } let interval_secs = payload.interval.trim().parse::().map_err(|error| { - ConfigError::Config(format!("invalid device polling interval: {error}")) + ConfigError::Config(format!( + "invalid ChatGPT device authorization polling interval: {error}" + )) })?; + if interval_secs == 0 { + return Err(ConfigError::Config( + "invalid ChatGPT device authorization polling interval: must be positive".to_string(), + )); + } Ok(ChatGptDeviceLoginSession { verification_uri: format!("{base}/codex/device"), user_code: payload.user_code, @@ -281,35 +330,46 @@ pub(crate) async fn complete_device_login( let base = session.options.issuer.trim_end_matches('/'); let poll_url = format!("{base}/api/accounts/deviceauth/token"); let client = oauth_http_client(&session.options, &poll_url)?; - let started = tokio::time::Instant::now(); - let authorization = loop { - let response = client - .post(&poll_url) - .json(&serde_json::json!({ - "device_auth_id": session.device_auth_id, - "user_code": session.user_code, - })) - .send() - .await - .map_err(|error| oauth_transport_error("poll device authorization", error))?; - if response.status().is_success() { - break response - .json::() + let authorization = tokio::time::timeout(session.options.device_timeout, async { + loop { + let response = client + .post(&poll_url) + .json(&serde_json::json!({ + "device_auth_id": session.device_auth_id, + "user_code": session.user_code, + })) + .send() .await .map_err(|error| { - ConfigError::Config(format!("invalid device authorization response: {error}")) + oauth_transport_error("ChatGPT device authorization poll", error) })?; + if response.status().is_success() { + break response + .json::() + .await + .map_err(|error| { + ConfigError::Config(format!( + "invalid ChatGPT device authorization response: {error}" + )) + }); + } + if !matches!(response.status().as_u16(), 403 | 404) { + return Err(oauth_status_error("ChatGPT device authorization", response).await); + } + tokio::time::sleep(Duration::from_secs(session.interval_secs)).await; } - if !matches!(response.status().as_u16(), 403 | 404) { - return Err(oauth_status_error("device authorization", response).await); - } - if started.elapsed() >= session.options.device_timeout { - return Err(ConfigError::Config( - "ChatGPT device authorization timed out".to_string(), - )); - } - tokio::time::sleep(Duration::from_secs(session.interval_secs.max(1))).await; - }; + }) + .await + .map_err(|_| ConfigError::Config("ChatGPT device authorization timed out".to_string()))??; + + if authorization.authorization_code.trim().is_empty() + || authorization.code_verifier.trim().is_empty() + || authorization.code_challenge.trim().is_empty() + { + return Err(ConfigError::Config( + "invalid ChatGPT device authorization response: required fields were empty".to_string(), + )); + } exchange_authorization_code( &session.options, @@ -342,6 +402,7 @@ pub(crate) async fn refresh_credentials( .await .map_err(|error| ConfigError::Config(format!("invalid token refresh response: {error}")))?; credentials_from_response(tokens, Some(current)) + .map_err(|error| ConfigError::Config(format!("ChatGPT token refresh failed: {error}"))) } async fn exchange_authorization_code( @@ -363,12 +424,13 @@ async fn exchange_authorization_code( ]) .send() .await - .map_err(|error| oauth_transport_error("exchange ChatGPT authorization code", error))?; + .map_err(|error| oauth_transport_error("ChatGPT token exchange", error))?; let response = checked_oauth_response("ChatGPT token exchange", response).await?; let tokens = response.json::().await.map_err(|error| { ConfigError::Config(format!("invalid token exchange response: {error}")) })?; credentials_from_response(tokens, None) + .map_err(|error| ConfigError::Config(format!("ChatGPT token exchange failed: {error}"))) } fn credentials_from_response( @@ -528,7 +590,7 @@ async fn checked_oauth_response( async fn oauth_status_error(operation: &str, response: reqwest::Response) -> ConfigError { let status = response.status(); - let body = response.text().await.unwrap_or_default(); + let body = bounded_response_body(response).await; let message = serde_json::from_str::(&body) .ok() .and_then(|value| { @@ -536,6 +598,7 @@ async fn oauth_status_error(operation: &str, response: reqwest::Response) -> Con .or_else(|| string_claim(&value, "error")) .or_else(|| string_claim(&value, "message")) }) + .map(|message| sanitize_provider_message(&message)) .unwrap_or_else(|| "request rejected".to_string()); let proxy_hint = if status == reqwest::StatusCode::FORBIDDEN { "; if browser login works but this request is rejected, check settings.json env.https_proxy/env.http_proxy, process proxy variables, or the macOS system proxy" @@ -548,7 +611,99 @@ async fn oauth_status_error(operation: &str, response: reqwest::Response) -> Con } fn oauth_transport_error(operation: &str, error: reqwest::Error) -> ConfigError { - ConfigError::Config(format!("{operation} failed: {error}")) + ConfigError::Config(format!( + "{operation} failed: {}", + sanitize_provider_message(&error.to_string()) + )) +} + +async fn bounded_response_body(mut response: reqwest::Response) -> String { + let mut body = Vec::new(); + while body.len() < MAX_OAUTH_ERROR_BODY_BYTES { + let chunk = match response.chunk().await { + Ok(Some(chunk)) => chunk, + Ok(None) | Err(_) => break, + }; + let remaining = MAX_OAUTH_ERROR_BODY_BYTES - body.len(); + body.extend_from_slice(&chunk[..chunk.len().min(remaining)]); + } + String::from_utf8_lossy(&body).into_owned() +} + +fn sanitize_provider_message(message: &str) -> String { + let mut clean = String::new(); + let mut chars = message.chars().peekable(); + let mut pending_space = false; + while let Some(character) = chars.next() { + if character == '\u{1b}' { + if chars.next_if_eq(&'[').is_some() { + for sequence in chars.by_ref() { + if ('\u{40}'..='\u{7e}').contains(&sequence) { + break; + } + } + } + continue; + } + if character.is_control() || character.is_whitespace() { + pending_space = !clean.is_empty(); + continue; + } + if pending_space { + clean.push(' '); + pending_space = false; + } + clean.push(character); + } + + let sanitized = clean + .split_whitespace() + .map(sanitize_provider_word) + .collect::>() + .join(" "); + let mut bounded = sanitized + .chars() + .take(MAX_PROVIDER_ERROR_CHARS + 1) + .collect::(); + if bounded.chars().count() > MAX_PROVIDER_ERROR_CHARS { + bounded.pop(); + bounded.push('…'); + } + if bounded.is_empty() { + "request rejected".to_string() + } else { + bounded + } +} + +fn sanitize_provider_word(word: &str) -> String { + let lower = word.to_ascii_lowercase(); + let url_start = lower.find("https://").or_else(|| lower.find("http://")); + if let Some(start) = url_start + && let Ok(mut url) = Url::parse(&word[start..]) + { + let _ = url.set_username(""); + let _ = url.set_password(None); + url.set_query(None); + url.set_fragment(None); + return format!("{}{}", &word[..start], url); + } + if [ + "code=", + "state=", + "verifier=", + "token=", + "access_token=", + "refresh_token=", + "id_token=", + ] + .iter() + .any(|marker| lower.contains(marker)) + { + "[redacted]".to_string() + } else { + word.to_string() + } } #[cfg(test)]