Enable staging login issuer overrides in packaged builds (#42811)

## Why

Packaged clients need to pair a staging OAuth client ID with the matching
issuer. The issuer override was previously limited to debug builds.

## What changed

- Apply `CODEX_APP_SERVER_LOGIN_ISSUER` in all builds when it contains a
  non-empty value.
- Keep `CODEX_APP_SERVER_DEV_OPEN_APP_URL` debug-only.

## Testing

Exercise the complete app-server OAuth flow with the issuer and client ID
overrides, including authorization, token exchange, and login completion.

GitOrigin-RevId: 13d290b9dae689cd811361b1b549953a3b52db42
This commit is contained in:
alishobeiri-oai
2026-09-04 17:51:32 +00:00
committed by copyberry
parent 0ae02915bd
commit 86b1b359cf
2 changed files with 91 additions and 31 deletions
@@ -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)
}
+75 -11
View File
@@ -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(())
}