diff --git a/codex-rs/app-server/src/request_processors/account_processor.rs b/codex-rs/app-server/src/request_processors/account_processor.rs index 72378bfb4e..ede9820baa 100644 --- a/codex-rs/app-server/src/request_processors/account_processor.rs +++ b/codex-rs/app-server/src/request_processors/account_processor.rs @@ -21,9 +21,9 @@ const ACCOUNT_TOKEN_USAGE_FETCH_TIMEOUT: Duration = Duration::from_secs(/*secs*/ const THREAD_USAGE_FETCH_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 60); const ACCOUNT_WORKSPACE_MESSAGES_FETCH_TIMEOUT: Duration = Duration::from_millis(/*millis*/ 1000); -// Login overrides are intentionally available only in debug builds. -#[cfg(debug_assertions)] +// Packaged clients use this together with the OAuth client ID override for staging login. const LOGIN_ISSUER_OVERRIDE_ENV_VAR: &str = "CODEX_APP_SERVER_LOGIN_ISSUER"; +// The development success-page redirect remains debug-only. #[cfg(debug_assertions)] const LOGIN_OPEN_APP_URL_OVERRIDE_ENV_VAR: &str = "CODEX_APP_SERVER_DEV_OPEN_APP_URL"; @@ -561,7 +561,7 @@ impl AccountRequestProcessor { )); } - let opts = LoginServerOptions { + let mut opts = LoginServerOptions { open_browser: false, codex_streamlined_login, login_success_page, @@ -574,24 +574,20 @@ impl AccountRequestProcessor { config.auth_route_config(), ) }; + if let Ok(issuer) = std::env::var(LOGIN_ISSUER_OVERRIDE_ENV_VAR) + && !issuer.trim().is_empty() + { + opts.issuer = issuer; + } #[cfg(debug_assertions)] - let opts = { - let mut opts = opts; - if let Ok(issuer) = std::env::var(LOGIN_ISSUER_OVERRIDE_ENV_VAR) - && !issuer.trim().is_empty() - { - opts.issuer = issuer; - } - if let LoginSuccessPage::Hosted { url, .. } = &mut opts.login_success_page - && let Ok(open_app_url) = std::env::var(LOGIN_OPEN_APP_URL_OVERRIDE_ENV_VAR) - && !open_app_url.trim().is_empty() - { - *url = open_app_url - .parse() - .map_err(|err| internal_error(format!("invalid Codex open app URL: {err}")))?; - } - opts - }; + if let LoginSuccessPage::Hosted { url, .. } = &mut opts.login_success_page + && let Ok(open_app_url) = std::env::var(LOGIN_OPEN_APP_URL_OVERRIDE_ENV_VAR) + && !open_app_url.trim().is_empty() + { + *url = open_app_url + .parse() + .map_err(|err| internal_error(format!("invalid Codex open app URL: {err}")))?; + } Ok(opts) } diff --git a/codex-rs/app-server/tests/suite/v2/account.rs b/codex-rs/app-server/tests/suite/v2/account.rs index 54ed7f3100..3b67408538 100644 --- a/codex-rs/app-server/tests/suite/v2/account.rs +++ b/codex-rs/app-server/tests/suite/v2/account.rs @@ -2337,16 +2337,27 @@ async fn login_account_chatgpt_start_can_be_cancelled() -> Result<()> { #[tokio::test] // Serialize tests that launch the login server since it binds to a fixed port. #[serial(login_port)] -async fn login_account_chatgpt_uses_debug_oauth_overrides() -> Result<()> { +async fn login_account_chatgpt_uses_oauth_overrides() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), CreateConfigTomlParams::default())?; + let mock_server = MockServer::start().await; + let id_token = encode_id_token( + &ChatGptIdTokenClaims::new() + .email("staging@example.com") + .plan_type("pro") + .chatgpt_account_id(WORKSPACE_ID_EMBEDDED), + )?; + mock_oauth_token(&mock_server, &id_token).await; + let issuer = mock_server.uri(); let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) .without_auto_env() + // Exercise packaged-build behavior without the debug-only startup flag. + .with_plugin_startup_tasks() .with_env_overrides(&[ (CLIENT_ID_OVERRIDE_ENV_VAR, Some("staging-client")), - (LOGIN_ISSUER_ENV_VAR, Some("https://auth.example.com")), + (LOGIN_ISSUER_ENV_VAR, Some(issuer.as_str())), ]) .build_initialized_with_timeout(DEFAULT_READ_TIMEOUT) .await?; @@ -2358,10 +2369,7 @@ async fn login_account_chatgpt_uses_debug_oauth_overrides() -> Result<()> { bail!("unexpected login response: {login:?}"); }; let auth_url = Url::parse(&auth_url)?; - assert_eq!( - auth_url.origin().ascii_serialization(), - "https://auth.example.com" - ); + assert_eq!(auth_url.origin().ascii_serialization(), issuer); assert_eq!( auth_url .query_pairs() @@ -2369,11 +2377,67 @@ async fn login_account_chatgpt_uses_debug_oauth_overrides() -> Result<()> { Some("staging-client".to_string()) ); - let cancel_id = mcp - .send_cancel_login_account_request(CancelLoginAccountParams { login_id }) - .await?; - let _: CancelLoginAccountResponse = - timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(cancel_id)).await??; + let callback_url = auth_url + .query_pairs() + .find_map(|(key, value)| (key == "redirect_uri").then_some(value.into_owned())) + .ok_or_else(|| anyhow::anyhow!("missing redirect_uri"))?; + let state = auth_url + .query_pairs() + .find_map(|(key, value)| (key == "state").then_some(value.into_owned())) + .ok_or_else(|| anyhow::anyhow!("missing state"))?; + let mut callback_url = Url::parse(&callback_url)?; + callback_url + .query_pairs_mut() + .append_pair("code", "test-code") + .append_pair("state", &state); + let client = HttpClientBuilder::new() + .without_redirects() + .build_direct()?; + let response = client.get(callback_url.clone()).send().await?; + assert_eq!(response.status(), 302); + let success_url = Url::parse(response.headers()["location"].to_str()?)?; + assert_eq!(success_url.origin(), callback_url.origin()); + let response = client.get(success_url).send().await?; + assert_eq!(response.status(), 200); + + let requests = mock_server + .received_requests() + .await + .ok_or_else(|| anyhow::anyhow!("failed to read OAuth requests"))?; + let token_request = requests + .iter() + .find(|request| { + request.url.path() == "/oauth/token" + && url::form_urlencoded::parse(&request.body) + .any(|(key, value)| key == "grant_type" && value == "authorization_code") + }) + .ok_or_else(|| anyhow::anyhow!("missing authorization-code token exchange"))?; + let token_form: std::collections::HashMap<_, _> = + url::form_urlencoded::parse(&token_request.body) + .into_owned() + .collect(); + assert_eq!( + token_form.get("client_id").map(String::as_str), + Some("staging-client") + ); + + let notification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("account/login/completed"), + ) + .await??; + let ServerNotification::AccountLoginCompleted(payload) = notification.try_into()? else { + bail!("unexpected notification") + }; + assert_eq!( + payload, + AccountLoginCompletedNotification { + login_id: Some(login_id), + success: true, + error: None, + onboarding_entrypoint: None, + } + ); Ok(()) }