diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index bbf7f39b8a..e9bc401f0c 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -814,6 +814,7 @@ dependencies = [ "base64 0.22.1", "chrono", "hex", + "html-escape", "pretty_assertions", "rand 0.8.5", "reqwest", @@ -824,6 +825,7 @@ dependencies = [ "thiserror 2.0.12", "tiny_http", "tokio", + "tracing", "ureq", "url", "urlencoding", @@ -1981,6 +1983,15 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "html-escape" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d1ad449764d627e22bfd7cd5e8868264fc9236e07c752972b4080cd351cb476" +dependencies = [ + "utf8-width", +] + [[package]] name = "http" version = "1.3.1" @@ -5295,6 +5306,12 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" +[[package]] +name = "utf8-width" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86bd8d4e895da8537e5315b8254664e6b769c4ff3db18321b297a1e7004392e3" + [[package]] name = "utf8_iter" version = "1.0.4" diff --git a/codex-rs/cli/src/login.rs b/codex-rs/cli/src/login.rs index b48fd2049a..832386eb59 100644 --- a/codex-rs/cli/src/login.rs +++ b/codex-rs/cli/src/login.rs @@ -13,13 +13,10 @@ use codex_login::logout; pub async fn run_login_with_chatgpt( cli_config_overrides: CliConfigOverrides, - no_browser: bool, - verbose: bool, ) -> ! { let config = load_config_or_exit(cli_config_overrides); - let open_browser = !no_browser; - match login_with_chatgpt(&config.codex_home, open_browser, verbose).await { + match login_with_chatgpt(&config.codex_home).await { Ok(_) => { eprintln!("Successfully logged in"); std::process::exit(0); diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 00d1c1c39a..aabb5a086b 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -100,14 +100,6 @@ struct LoginCommand { #[arg(long = "api-key", value_name = "API_KEY")] api_key: Option, - /// Do not automatically open the browser - #[arg(long = "no-browser")] - no_browser: bool, - - /// Enable verbose request logging during login - #[arg(long = "verbose")] - verbose: bool, - #[command(subcommand)] action: Option, } @@ -162,8 +154,6 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() } else { run_login_with_chatgpt( login_cli.config_overrides, - login_cli.no_browser, - login_cli.verbose, ) .await; } diff --git a/codex-rs/login/Cargo.toml b/codex-rs/login/Cargo.toml index 01cc3a1287..9eeec397b4 100644 --- a/codex-rs/login/Cargo.toml +++ b/codex-rs/login/Cargo.toml @@ -31,6 +31,8 @@ tiny_http = "0.12" url = "2" urlencoding = "2" webbrowser = "1" +tracing = "0.1" +html-escape = "0.2" [dev-dependencies] pretty_assertions = "1.4.1" diff --git a/codex-rs/login/src/auth_store.rs b/codex-rs/login/src/auth_store.rs index 729cb756f5..99051144c0 100644 --- a/codex-rs/login/src/auth_store.rs +++ b/codex-rs/login/src/auth_store.rs @@ -38,8 +38,8 @@ pub fn logout(codex_home: &Path) -> std::io::Result { } } -/// Attempt to read and refresh the `auth.json` file in the given `CODEX_HOME` directory. -/// Returns the full AuthDotJson structure after refreshing if necessary. +/// Attempt to read and deserialize the `auth.json` file at the given path. +/// Returns the full AuthDotJson structure. pub fn try_read_auth_json(auth_file: &Path) -> std::io::Result { let mut file = File::open(auth_file)?; let mut contents = String::new(); diff --git a/codex-rs/login/src/entrypoints.rs b/codex-rs/login/src/entrypoints.rs index 4e4674e33b..f9d57bf82c 100644 --- a/codex-rs/login/src/entrypoints.rs +++ b/codex-rs/login/src/entrypoints.rs @@ -81,8 +81,6 @@ pub fn spawn_login_with_chatgpt(codex_home: &Path) -> std::io::Result std::io::Result<()> { let client_id = std::env::var("CODEX_CLIENT_ID") .ok() @@ -97,11 +95,9 @@ pub async fn login_with_chatgpt( client_id: client_id_cloned, issuer: server::DEFAULT_ISSUER.to_string(), port: server::DEFAULT_PORT, - open_browser, - redeem_credits: true, + open_browser: true, expose_state_endpoint: false, testing_timeout_secs: None, - verbose, #[cfg(feature = "http-e2e-tests")] port_sender: None, }; diff --git a/codex-rs/login/src/error_page.html b/codex-rs/login/src/error_page.html new file mode 100644 index 0000000000..3d0b78267c --- /dev/null +++ b/codex-rs/login/src/error_page.html @@ -0,0 +1,100 @@ + + + + + Sign-in error ยท Codex CLI + + + + +
+
+
+ +
We couldn't complete sign-in
+
+
+
%%MESSAGE%%
+
+
Try restarting the sign-in from the Codex CLI. You can also copy the URL from the terminal and open it manually.
+
+
+ + + + diff --git a/codex-rs/login/src/lib_tests.rs b/codex-rs/login/src/lib_tests.rs index 4435762c2d..1ea680f9c1 100644 --- a/codex-rs/login/src/lib_tests.rs +++ b/codex-rs/login/src/lib_tests.rs @@ -19,14 +19,7 @@ use tempfile::tempdir; const LAST_REFRESH: &str = "2025-08-06T20:41:36.232376Z"; -#[test] -fn writes_api_key_and_loads_auth() { - let dir = tempdir().unwrap(); - crate::auth_store::login_with_api_key(dir.path(), "sk-test-key").unwrap(); - let auth = load_auth(dir.path(), false).unwrap().unwrap(); - assert_eq!(auth.mode, AuthMode::ApiKey); - assert_eq!(auth.api_key.as_deref(), Some("sk-test-key")); -} +// moved to integration tests in tests/api_key_login.rs #[test] fn loads_from_env_var_if_env_var_exists() { @@ -254,3 +247,39 @@ fn logout_removes_auth_file() -> Result<(), std::io::Error> { assert!(!dir.path().join("auth.json").exists()); Ok(()) } + +#[test] +fn update_tokens_preserves_id_token_as_string() { + let dir = tempdir().unwrap(); + let auth_file = crate::auth_store::get_auth_file(dir.path()); + + // Write an initial auth.json with a tokens object + let initial = serde_json::json!({ + "OPENAI_API_KEY": null, + "tokens": { + "id_token": "old-id-token", + "access_token": "a1", + "refresh_token": "r1" + }, + "last_refresh": LAST_REFRESH + }); + std::fs::write(&auth_file, serde_json::to_string_pretty(&initial).unwrap()).unwrap(); + + // Build a valid-looking JWT (URL-safe base64 header.payload.signature) + #[derive(Serialize)] + struct Header { alg: &'static str, typ: &'static str } + let header = Header { alg: "none", typ: "JWT" }; + let payload = serde_json::json!({}); + let b64 = |b: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b); + let header_b64 = b64(&serde_json::to_vec(&header).unwrap()); + let payload_b64 = b64(&serde_json::to_vec(&payload).unwrap()); + let signature_b64 = b64(b"sig"); + let new_id = format!("{header_b64}.{payload_b64}.{signature_b64}"); + // Call update_tokens with a new id_token + let _ = crate::auth_store::update_tokens(&auth_file, new_id.clone(), None, None).unwrap(); + + // Read raw file and ensure id_token is still a string, equal to what we wrote + let raw = std::fs::read_to_string(&auth_file).unwrap(); + let val: serde_json::Value = serde_json::from_str(&raw).unwrap(); + assert_eq!(val["tokens"]["id_token"].as_str(), Some(new_id.as_str())); +} diff --git a/codex-rs/login/src/server.rs b/codex-rs/login/src/server.rs index 3765f01f64..0f24103c8b 100644 --- a/codex-rs/login/src/server.rs +++ b/codex-rs/login/src/server.rs @@ -1,7 +1,6 @@ // use rand::RngCore; use reqwest::blocking::Client; -use serde_json::json; use std::collections::HashMap; use std::net::TcpListener; use std::path::Path; @@ -19,11 +18,20 @@ use crate::auth_store::write_new_auth_json; use crate::pkce::generate_pkce; use crate::success_url::build_success_url; use crate::token_data::extract_login_context_from_tokens; +use tracing::{error, trace}; pub const DEFAULT_PORT: u16 = 1455; pub const DEFAULT_ISSUER: &str = "https://auth.openai.com"; pub const LOGIN_SUCCESS_HTML: &str = include_str!("./success_page.html"); +pub const LOGIN_ERROR_HTML: &str = include_str!("./error_page.html"); + +fn render_error_html(message: &str) -> String { + LOGIN_ERROR_HTML.replace( + "%%MESSAGE%%", + &html_escape::encode_text(message).to_string(), + ) +} #[derive(Debug, Clone)] pub struct LoginServerOptions { @@ -32,18 +40,15 @@ pub struct LoginServerOptions { pub issuer: String, pub port: u16, pub open_browser: bool, - pub redeem_credits: bool, pub expose_state_endpoint: bool, /// timeout after x secs for e2e tests pub testing_timeout_secs: Option, - pub verbose: bool, #[cfg(feature = "http-e2e-tests")] pub port_sender: Option>, } // Only default issuer supported for platform/api bases const PLATFORM_BASE: &str = "https://platform.openai.com"; -const API_BASE: &str = "https://api.openai.com"; fn default_url_base(port: u16) -> String { format!("http://localhost:{port}") @@ -57,10 +62,8 @@ pub fn run_local_login_server(codex_home: &Path, client_id: &str) -> std::io::Re issuer: DEFAULT_ISSUER.to_string(), port: DEFAULT_PORT, open_browser: true, - redeem_credits: true, expose_state_endpoint: false, testing_timeout_secs: None, - verbose: false, #[cfg(feature = "http-e2e-tests")] port_sender: None, }; @@ -143,9 +146,7 @@ pub fn run_local_login_server_with_options(mut opts: LoginServerOptions) -> std: None => (full.clone(), None), }; - if opts.verbose { - eprintln!("{} {}", request.method().as_str(), request.url()); - } + trace!("{} {}", request.method().as_str(), request.url()); match (request.method().clone(), path.as_str()) { (Method::Get, "/success") => { @@ -155,11 +156,16 @@ pub fn run_local_login_server_with_options(mut opts: LoginServerOptions) -> std: { resp.add_header(h); } - let _ = request.respond(resp); + if let Err(e) = request.respond(resp) { + error!("failed to respond to /success: {e}"); + } break 'outer; } (Method::Get, "/__test/exit") => { - let _ = request.respond(Response::from_string("bye").with_status_code(200)); + if let Err(e) = request.respond(Response::from_string("bye").with_status_code(200)) + { + error!("failed to respond to /__test/exit: {e}"); + } break 'outer; } // Test-only helper to retrieve the current state, enabled via options. @@ -169,7 +175,9 @@ pub fn run_local_login_server_with_options(mut opts: LoginServerOptions) -> std: if let Ok(h) = Header::from_bytes(&b"Content-Type"[..], &b"text/plain"[..]) { resp.add_header(h); } - let _ = request.respond(resp); + if let Err(e) = request.respond(resp) { + error!("failed to respond to /__test/state: {e}"); + } } (Method::Get, "/auth/callback") => { // Parse query params @@ -180,16 +188,24 @@ pub fn run_local_login_server_with_options(mut opts: LoginServerOptions) -> std: // Preserve explicit error messages for tests if params.get("state").map(|s| s.as_str()) != Some(state.as_str()) { - let _ = request.respond( - Response::from_string("State parameter mismatch").with_status_code(400), - ); + let mut resp = Response::from_string(render_error_html("State parameter mismatch")).with_status_code(400); + if let Ok(h) = Header::from_bytes(&b"Content-Type"[..], &b"text/html; charset=utf-8"[..]) { + resp.add_header(h); + } + if let Err(e) = request.respond(resp) { + error!("failed to respond to state mismatch: {e}"); + } continue; } let code_opt = params.get("code").map(|s| s.as_str()); if code_opt.map(|s| s.is_empty()).unwrap_or(true) { - let _ = request.respond( - Response::from_string("Missing authorization code").with_status_code(400), - ); + let mut resp = Response::from_string(render_error_html("Missing authorization code")).with_status_code(400); + if let Ok(h) = Header::from_bytes(&b"Content-Type"[..], &b"text/html; charset=utf-8"[..]) { + resp.add_header(h); + } + if let Err(e) = request.respond(resp) { + error!("failed to respond to missing code: {e}"); + } continue; } @@ -210,18 +226,27 @@ pub fn run_local_login_server_with_options(mut opts: LoginServerOptions) -> std: { resp.add_header(h); } - let _ = request.respond(resp); + if let Err(e) = request.respond(resp) { + error!("failed to respond redirect to success: {e}"); + } } Err(_) => { - let _ = request.respond( - Response::from_string("Token exchange failed").with_status_code(500), - ); + let mut resp = Response::from_string(render_error_html("Token exchange failed")).with_status_code(500); + if let Ok(h) = Header::from_bytes(&b"Content-Type"[..], &b"text/html; charset=utf-8"[..]) { + resp.add_header(h); + } + if let Err(e) = request.respond(resp) { + error!("failed to respond to token exchange failure: {e}"); + } } } } _ => { - let _ = request - .respond(Response::from_string("Endpoint not supported").with_status_code(404)); + if let Err(e) = request.respond( + Response::from_string("Endpoint not supported").with_status_code(404), + ) { + error!("failed to respond 404: {e}"); + } } } } @@ -348,10 +373,7 @@ pub fn process_callback_headless( account_id, )?; - if opts.redeem_credits { - let redeem_url = format!("{API_BASE}/v1/billing/redeem_credits"); - let _ = http.post_json(&redeem_url, &json!({"id_token": id_token})); - } + // Intentionally not redeeming credits here let base = default_url_base(opts.port); let platform_url = PLATFORM_BASE; diff --git a/codex-rs/login/src/token_data.rs b/codex-rs/login/src/token_data.rs index f1213b5fbd..b957c1b321 100644 --- a/codex-rs/login/src/token_data.rs +++ b/codex-rs/login/src/token_data.rs @@ -83,19 +83,7 @@ pub(crate) enum KnownPlan { Edu, } -#[derive(Deserialize)] -struct IdClaims { - #[serde(default)] - email: Option, - #[serde(rename = "https://api.openai.com/auth", default)] - auth: Option, -} - -#[derive(Deserialize)] -struct AuthClaims { - #[serde(default)] - chatgpt_plan_type: Option, -} +// Removed duplicate IdClaims/AuthClaims in favor of unified helpers below #[derive(Debug, Error)] pub enum IdTokenInfoError { @@ -108,16 +96,10 @@ pub enum IdTokenInfoError { } pub(crate) fn parse_id_token(id_token: &str) -> Result { - // JWT format: header.payload.signature - let mut parts = id_token.split('.'); - let (_header_b64, payload_b64, _sig_b64) = match (parts.next(), parts.next(), parts.next()) { - (Some(h), Some(p), Some(s)) if !h.is_empty() && !p.is_empty() && !s.is_empty() => (h, p, s), - _ => return Err(IdTokenInfoError::InvalidFormat), - }; - - let payload_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(payload_b64)?; - let claims: IdClaims = serde_json::from_slice(&payload_bytes)?; - + // Reuse the generic JWT parsing helpers to extract fields + let payload = decode_jwt_payload(id_token).ok_or(IdTokenInfoError::InvalidFormat)?; + // Reuse AuthOuterClaims instead of a local struct to avoid duplication + let claims: AuthOuterClaims = serde_json::from_slice(&payload)?; Ok(IdTokenInfo { email: claims.email, chatgpt_plan_type: claims.auth.and_then(|a| a.chatgpt_plan_type), @@ -136,6 +118,8 @@ where #[derive(Default, Deserialize)] struct AuthOuterClaims { + #[serde(default)] + email: Option, #[serde(rename = "https://api.openai.com/auth", default)] auth: Option, } diff --git a/codex-rs/login/tests/api_key_login.rs b/codex-rs/login/tests/api_key_login.rs new file mode 100644 index 0000000000..e9e457e7e7 --- /dev/null +++ b/codex-rs/login/tests/api_key_login.rs @@ -0,0 +1,12 @@ +use tempfile::tempdir; + +#[tokio::test] +async fn writes_api_key_and_loads_auth() { + let dir = tempdir().unwrap(); + codex_login::login_with_api_key(dir.path(), "sk-test-key").unwrap(); + let auth = codex_login::CodexAuth::from_codex_home(dir.path()) + .unwrap() + .unwrap(); + assert_eq!(auth.mode, codex_login::AuthMode::ApiKey); + assert_eq!(auth.get_token().await.unwrap().as_str(), "sk-test-key"); +} diff --git a/codex-rs/login/tests/headless.rs b/codex-rs/login/tests/headless.rs index 3885892de7..783e7b6c73 100644 --- a/codex-rs/login/tests/headless.rs +++ b/codex-rs/login/tests/headless.rs @@ -57,10 +57,8 @@ fn default_opts(tmp: &TempDir) -> LoginServerOptions { issuer: "http://auth.local".into(), port: 1455, open_browser: false, - redeem_credits: true, expose_state_endpoint: false, testing_timeout_secs: None, - verbose: false, #[cfg(feature = "http-e2e-tests")] port_sender: None, } @@ -78,8 +76,6 @@ fn headless_success_writes_auth_and_url() { "access_token": make_fake_jwt(json!({"https://api.openai.com/auth": {"organization_id": "org","project_id": "proj","completed_platform_onboarding": true, "is_org_owner": false, "chatgpt_plan_type": "plus"}})), "refresh_token": "r1" })); - // Credits redeem - http.queue(json!({"granted_chatgpt_subscriber_api_credits": 5})); let outcome = process_callback_headless(&opts, "state", "state", Some("code"), "ver", &http).unwrap(); @@ -126,25 +122,7 @@ fn headless_token_endpoint_failure() { assert_eq!(err.kind(), std::io::ErrorKind::Other); } -// 5) Credit redemption best-effort: even if it errors, success persists -#[test] -fn headless_credit_redemption_best_effort() { - let tmp = TempDir::new().unwrap(); - let mut opts = default_opts(&tmp); - opts.redeem_credits = true; - let http = MockHttp::default(); - // Code exchange - http.queue(json!({ - "id_token": make_fake_jwt(json!({"https://api.openai.com/auth": {"chatgpt_account_id": "acc"}})), - "access_token": make_fake_jwt(json!({"https://api.openai.com/auth": {"organization_id": "org","project_id": "proj","completed_platform_onboarding": false, "is_org_owner": true, "chatgpt_plan_type": "pro"}})), - "refresh_token": "r1" - })); - // Credits redeem: simulate error by not queuing a third response; the mock will error internally - let outcome = - process_callback_headless(&opts, "state", "state", Some("code"), "ver", &http).unwrap(); - assert!(outcome.success_url.contains("needs_setup=true")); - assert!(tmp.path().join("auth.json").exists()); -} +// 5) (Removed) Credit redemption is no longer attempted // 6) ID-token fallback for org/project/flags #[test] @@ -170,8 +148,6 @@ fn headless_id_token_fallback_for_org_and_project() { })), "refresh_token": "r1" })); - // Credits redeem - http.queue(json!({"granted_chatgpt_subscriber_api_credits": 0})); let outcome = process_callback_headless(&opts, "state", "state", Some("code"), "ver", &http).unwrap(); diff --git a/codex-rs/login/tests/server.rs b/codex-rs/login/tests/server.rs index b389540a6f..abfc669e1e 100644 --- a/codex-rs/login/tests/server.rs +++ b/codex-rs/login/tests/server.rs @@ -198,7 +198,6 @@ use common::make_fake_jwt; fn spawn_login_server_and_wait( issuer: String, codex_home: &tempfile::TempDir, - redeem_credits: bool, ) -> (std::thread::JoinHandle>, u16) { let (tx, rx) = std::sync::mpsc::channel(); let opts = LoginServerOptions { @@ -207,10 +206,8 @@ fn spawn_login_server_and_wait( issuer, port: 0, open_browser: false, - redeem_credits, expose_state_endpoint: true, testing_timeout_secs: Some(5), - verbose: false, #[cfg(feature = "http-e2e-tests")] port_sender: Some(tx), }; @@ -239,6 +236,24 @@ fn http_get(url: &str) -> (u16, String, Option) { } } +fn http_get_with_ct(url: &str) -> (u16, String, Option) { + let agent = ureq::AgentBuilder::new().redirects(0).build(); + match agent.get(url).call() { + Ok(resp) => { + let status = resp.status(); + let content_type = resp.header("content-type").map(|s| s.to_string()); + let body = resp.into_string().unwrap_or_default(); + (status, body, content_type) + } + Err(ureq::Error::Status(code, resp)) => { + let content_type = resp.header("content-type").map(|s| s.to_string()); + let body = resp.into_string().unwrap_or_default(); + (code, body, content_type) + } + Err(err) => panic!("http error: {err}"), + } +} + fn http_get_follow_redirect(url: &str) -> (u16, String) { let agent = ureq::AgentBuilder::new().redirects(5).build(); match agent.get(url).call() { @@ -255,7 +270,7 @@ async fn login_server_happy_path() { let codex_home = TempDir::new().unwrap(); let issuer = server.uri(); - let (handle, port) = spawn_login_server_and_wait(issuer, &codex_home, true); + let (handle, port) = spawn_login_server_and_wait(issuer, &codex_home); // Get state via test-only endpoint let state_url = format!("http://127.0.0.1:{port}/__test/state"); @@ -294,7 +309,7 @@ async fn login_server_needs_setup_true_and_params_present() { let codex_home = TempDir::new().unwrap(); let issuer = server.uri(); - let (handle, port) = spawn_login_server_and_wait(issuer, &codex_home, true); + let (handle, port) = spawn_login_server_and_wait(issuer, &codex_home); let state_url = format!("http://127.0.0.1:{port}/__test/state"); let (_s, state, _) = http_get(&state_url); assert!(!state.is_empty()); @@ -317,7 +332,7 @@ async fn login_server_id_token_fallback_for_org_and_project() { let codex_home = TempDir::new().unwrap(); let issuer = server.uri(); - let (handle, port) = spawn_login_server_and_wait(issuer, &codex_home, true); + let (handle, port) = spawn_login_server_and_wait(issuer, &codex_home); let state_url = format!("http://127.0.0.1:{port}/__test/state"); let (_s, state, _) = http_get(&state_url); let cb_url = format!("http://127.0.0.1:{port}/auth/callback?code=abc&state={state}"); @@ -337,7 +352,7 @@ async fn login_server_skips_exchange_when_no_org_or_project() { let codex_home = TempDir::new().unwrap(); let issuer = server.uri(); - let (handle, port) = spawn_login_server_and_wait(issuer, &codex_home, true); + let (handle, port) = spawn_login_server_and_wait(issuer, &codex_home); let state_url = format!("http://127.0.0.1:{port}/__test/state"); let (_s, state, _) = http_get(&state_url); let cb_url = format!("http://127.0.0.1:{port}/auth/callback?code=abc&state={state}"); @@ -363,12 +378,13 @@ async fn login_server_state_mismatch() { let server = start_mock_oauth_server(MockBehavior::Noop).await; let codex_home = TempDir::new().unwrap(); let issuer = server.uri(); - let (handle, port) = spawn_login_server_and_wait(issuer, &codex_home, false); + let (handle, port) = spawn_login_server_and_wait(issuer, &codex_home); let cb_url = format!("http://127.0.0.1:{port}/auth/callback?code=abc&state=wrong"); - let (status, body) = http_get_follow_redirect(&cb_url); + let (status, body, content_type) = http_get_with_ct(&cb_url); assert_eq!(status, 400); - assert!(body.contains("State parameter mismatch") || body.is_empty()); + assert!(body.contains("State parameter mismatch")); + assert!(content_type.unwrap_or_default().to_ascii_lowercase().starts_with("text/html")); // Stop server let _ = ureq::get(&format!("http://127.0.0.1:{port}/success")).call(); @@ -381,7 +397,7 @@ async fn login_server_missing_code() { let server = start_mock_oauth_server(MockBehavior::Noop).await; let codex_home = TempDir::new().unwrap(); let issuer = server.uri(); - let (handle, port) = spawn_login_server_and_wait(issuer, &codex_home, false); + let (handle, port) = spawn_login_server_and_wait(issuer, &codex_home); // Fetch state let state = ureq::get(&format!("http://127.0.0.1:{port}/__test/state")) @@ -391,8 +407,10 @@ async fn login_server_missing_code() { .unwrap(); // Missing code let cb_url = format!("http://127.0.0.1:{port}/auth/callback?state={state}"); - let (status, _body) = http_get_follow_redirect(&cb_url); + let (status, body, content_type) = http_get_with_ct(&cb_url); assert_eq!(status, 400); + assert!(body.contains("Missing authorization code")); + assert!(content_type.unwrap_or_default().to_ascii_lowercase().starts_with("text/html")); let _ = ureq::get(&format!("http://127.0.0.1:{port}/success")).call(); handle.join().unwrap().unwrap(); } @@ -403,15 +421,17 @@ async fn login_server_token_exchange_error() { let server = start_mock_oauth_server(MockBehavior::TokenError).await; let codex_home = TempDir::new().unwrap(); let issuer = server.uri(); - let (handle, port) = spawn_login_server_and_wait(issuer, &codex_home, false); + let (handle, port) = spawn_login_server_and_wait(issuer, &codex_home); let state = ureq::get(&format!("http://127.0.0.1:{port}/__test/state")) .call() .expect("get state") .into_string() .unwrap(); let cb_url = format!("http://127.0.0.1:{port}/auth/callback?code=abc&state={state}"); - let (status, _body) = http_get_follow_redirect(&cb_url); + let (status, body, content_type) = http_get_with_ct(&cb_url); assert_eq!(status, 500); + assert!(body.contains("Token exchange failed")); + assert!(content_type.unwrap_or_default().to_ascii_lowercase().starts_with("text/html")); let _ = ureq::get(&format!("http://127.0.0.1:{port}/success")).call(); handle.join().unwrap().unwrap(); } @@ -423,7 +443,7 @@ async fn login_server_credit_redemption_best_effort() { let server = start_mock_oauth_server(MockBehavior::Success).await; let codex_home = TempDir::new().unwrap(); let issuer = server.uri(); - let (handle, port) = spawn_login_server_and_wait(issuer, &codex_home, true); + let (handle, port) = spawn_login_server_and_wait(issuer, &codex_home); let state = ureq::get(&format!("http://127.0.0.1:{port}/__test/state")) .call() .expect("get state")