This commit is contained in:
Eason Goodale
2025-08-08 17:23:36 -07:00
parent 0ce885cb98
commit 45514562d9
5 changed files with 88 additions and 563 deletions

View File

@@ -330,14 +330,11 @@ pub async fn login_with_chatgpt(
open_browser: bool,
verbose: bool,
) -> std::io::Result<()> {
// Prefer env override to match Python flow expectations.
let client_id = std::env::var("CODEX_CLIENT_ID")
.ok()
.filter(|s| !s.is_empty())
.unwrap_or_else(|| CLIENT_ID.to_string());
// Mirror Python's special-case exit for address-in-use by pre-binding the port.
// Tiny race window is acceptable for UX parity.
match TcpListener::bind(("127.0.0.1", server::DEFAULT_PORT)) {
Ok(_sock) => {
// release immediately; server will bind next

View File

@@ -89,7 +89,7 @@ pub(crate) fn maybe_redeem_credits(
.cloned()
.unwrap_or(serde_json::Value::Object(Default::default()));
// Subscription active > 7 days check (parity with Python script)
// Subscription active > 7 days check
if let Some(sub_start_str) = auth_claims
.get("chatgpt_subscription_active_start")
.and_then(|v| v.as_str())

View File

@@ -1,4 +1,4 @@
use chrono::Utc;
//
use rand::RngCore;
use reqwest::blocking::Client;
use serde::Deserialize;
@@ -23,11 +23,8 @@ use crate::success_url::build_success_url;
pub const DEFAULT_PORT: u16 = 1455;
pub const DEFAULT_ISSUER: &str = "https://auth.openai.com";
// Copied from the Python HTML to keep UX consistent.
pub const LOGIN_SUCCESS_HTML: &str = include_str!("./success_page.html");
// PKCE helpers are in crate::pkce
#[derive(Debug, Deserialize)]
struct CodeExchangeResponse {
id_token: String,
@@ -35,16 +32,7 @@ struct CodeExchangeResponse {
refresh_token: String,
}
#[derive(Debug, Deserialize)]
struct TokenExchangeResponse {
access_token: String,
}
// JWT helpers are in crate::jwt_utils
// Auth file writer is in crate::auth_file
// Credit redemption logic is in crate::redeem
//
#[derive(Debug, Clone)]
pub struct LoginServerOptions {
@@ -61,6 +49,72 @@ pub struct LoginServerOptions {
pub verbose: bool,
}
/// Extracts commonly used claims from ID and access tokens.
/// - account_id is taken from the ID token.
/// - org_id/project_id prefer ID token, falling back to access token.
/// - plan_type comes from the access token.
/// - needs_setup is computed from (completed_platform_onboarding, is_org_owner) with the same precedence as org/project.
fn extract_login_context(
id_token: &str,
access_token: &str,
) -> (
Option<String>, // account_id
Option<String>, // org_id
Option<String>, // project_id
bool, // needs_setup
Option<String>, // plan_type
) {
let id_claims = parse_jwt_claims(id_token);
let access_claims = parse_jwt_claims(access_token);
let id_auth_claims = id_claims
.get("https://api.openai.com/auth")
.cloned()
.unwrap_or(serde_json::Value::Object(Default::default()));
let access_auth_claims = access_claims
.get("https://api.openai.com/auth")
.cloned()
.unwrap_or(serde_json::Value::Object(Default::default()));
let account_id = id_auth_claims
.get("chatgpt_account_id")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let org_id = id_auth_claims
.get("organization_id")
.and_then(|v| v.as_str())
.or_else(|| access_auth_claims.get("organization_id").and_then(|v| v.as_str()))
.map(|s| s.to_string());
let project_id = id_auth_claims
.get("project_id")
.and_then(|v| v.as_str())
.or_else(|| access_auth_claims.get("project_id").and_then(|v| v.as_str()))
.map(|s| s.to_string());
let completed_onboarding = id_auth_claims
.get("completed_platform_onboarding")
.and_then(|v| v.as_bool())
.or_else(|| {
access_auth_claims
.get("completed_platform_onboarding")
.and_then(|v| v.as_bool())
})
.unwrap_or(false);
let is_org_owner = id_auth_claims
.get("is_org_owner")
.and_then(|v| v.as_bool())
.or_else(|| access_auth_claims.get("is_org_owner").and_then(|v| v.as_bool()))
.unwrap_or(false);
let needs_setup = !completed_onboarding && is_org_owner;
let plan_type = access_auth_claims
.get("chatgpt_plan_type")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
(account_id, org_id, project_id, needs_setup, plan_type)
}
fn default_url_base(port: u16) -> String {
format!("http://localhost:{port}")
}
@@ -250,206 +304,10 @@ pub fn run_local_login_server_with_options(opts: LoginServerOptions) -> std::io:
}
};
// Extract account_id from id_token claims
let id_claims = parse_jwt_claims(&tokens.id_token);
let auth_claims = id_claims
.get("https://api.openai.com/auth")
.cloned()
.unwrap_or(serde_json::Value::Object(Default::default()));
let account_id = auth_claims
.get("chatgpt_account_id")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let (account_id, org_id, project_id, needs_setup, plan_type) =
extract_login_context(&tokens.id_token, &tokens.access_token);
// Parse access token claims to compute redirect target
let access_claims = parse_jwt_claims(&tokens.access_token);
let access_auth_claims = access_claims
.get("https://api.openai.com/auth")
.cloned()
.unwrap_or(serde_json::Value::Object(Default::default()));
let id_auth_claims = id_claims
.get("https://api.openai.com/auth")
.cloned()
.unwrap_or(serde_json::Value::Object(Default::default()));
// Prefer ID-token claims (Python parity), fall back to access-token claims
let org_id = id_auth_claims
.get("organization_id")
.and_then(|v| v.as_str())
.or_else(|| {
access_auth_claims
.get("organization_id")
.and_then(|v| v.as_str())
});
let project_id = id_auth_claims
.get("project_id")
.and_then(|v| v.as_str())
.or_else(|| {
access_auth_claims
.get("project_id")
.and_then(|v| v.as_str())
});
let completed_onboarding = id_auth_claims
.get("completed_platform_onboarding")
.and_then(|v| v.as_bool())
.or_else(|| {
access_auth_claims
.get("completed_platform_onboarding")
.and_then(|v| v.as_bool())
})
.unwrap_or(false);
let is_org_owner = id_auth_claims
.get("is_org_owner")
.and_then(|v| v.as_bool())
.or_else(|| {
access_auth_claims
.get("is_org_owner")
.and_then(|v| v.as_bool())
})
.unwrap_or(false);
// Python uses access-token for plan type; match that
let plan_type = access_auth_claims
.get("chatgpt_plan_type")
.and_then(|v| v.as_str());
let needs_setup = !completed_onboarding && is_org_owner;
// 2) Token exchange for API key (Python parity: only if org and project are present)
let today = Utc::now().format("%Y-%m-%d").to_string();
let random_id = {
let mut bytes = [0u8; 6];
rand::thread_rng().fill_bytes(&mut bytes);
hex::encode(bytes)
};
let api_key_opt: Option<String> = if org_id.is_none() || project_id.is_none() {
if opts.verbose {
eprintln!(
"Skipping token exchange: missing org_id or project_id in token claims (Python parity)"
);
}
None
} else {
if opts.verbose {
eprintln!("POST {token_endpoint} (token-exchange, subject=id_token)");
}
let token_x_id = client
.post(&token_endpoint)
.form(&[
(
"grant_type",
"urn:ietf:params:oauth:grant-type:token-exchange",
),
("client_id", opts.client_id.as_str()),
("requested_token", "openai-api-key"),
("subject_token", tokens.id_token.as_str()),
(
"subject_token_type",
"urn:ietf:params:oauth:token-type:id_token",
),
(
"name",
format!("Codex CLI [auto-generated] ({today}) [{random_id}]")
.as_str(),
),
])
.send();
match token_x_id {
Ok(resp) if resp.status().is_success() => {
let body_text = resp.text().unwrap_or_default();
match serde_json::from_str::<TokenExchangeResponse>(&body_text) {
Ok(v) => Some(v.access_token),
Err(e) => {
if opts.verbose {
eprintln!(
"Token exchange failed: invalid JSON (id_token): {e} body={body_text}"
);
}
None
}
}
}
Ok(resp) => {
let status = resp.status();
let body = resp.text().unwrap_or_default();
if opts.verbose {
eprintln!(
"Token exchange failed (id_token): status={status} body={body}"
);
}
if status.as_u16() == 401 && body.contains("missing organization_id") {
if opts.verbose {
eprintln!("Retrying token exchange with access_token subject");
}
let retry = client
.post(&token_endpoint)
.form(&[
(
"grant_type",
"urn:ietf:params:oauth:grant-type:token-exchange",
),
("client_id", opts.client_id.as_str()),
("requested_token", "openai-api-key"),
("subject_token", tokens.access_token.as_str()),
(
"subject_token_type",
"urn:ietf:params:oauth:token-type:access_token",
),
(
"name",
format!(
"Codex CLI [auto-generated] ({today}) [{random_id}]"
)
.as_str(),
),
])
.send();
match retry {
Ok(retry_resp) if retry_resp.status().is_success() => {
let body_text = retry_resp.text().unwrap_or_default();
match serde_json::from_str::<TokenExchangeResponse>(
&body_text,
) {
Ok(v) => Some(v.access_token),
Err(e) => {
if opts.verbose {
eprintln!(
"Token exchange failed: invalid JSON (access_token): {e} body={body_text}"
);
}
None
}
}
}
Ok(retry_resp) => {
let status = retry_resp.status();
let body = retry_resp.text().unwrap_or_default();
if opts.verbose {
eprintln!(
"Token exchange failed (access_token): status={status} body={body}"
);
}
None
}
Err(_) => {
if opts.verbose {
eprintln!(
"Token exchange failed: network error (access_token)"
);
}
None
}
}
} else {
None
}
}
Err(_) => {
if opts.verbose {
eprintln!("Token exchange failed: network error (id_token)");
}
None
}
}
};
let api_key_opt: Option<String> = None;
// Persist auth.json
if let Err(e) = write_auth_file(
@@ -487,9 +345,9 @@ pub fn run_local_login_server_with_options(opts: LoginServerOptions) -> std::io:
let success_url = build_success_url(
&url_base,
Some(&tokens.id_token),
org_id,
project_id,
plan_type,
org_id.as_deref(),
project_id.as_deref(),
plan_type.as_deref(),
needs_setup,
platform_url,
)
@@ -614,103 +472,10 @@ pub fn process_callback_headless(
return Err(std::io::Error::other("token exchange failed"));
}
// Extract claims with Python-parity precedence: prefer ID-token claims; fall back to access-token
let id_claims = parse_jwt_claims(&id_token);
let id_auth = id_claims
.get("https://api.openai.com/auth")
.cloned()
.unwrap_or(serde_json::Value::Object(Default::default()));
let account_id = id_auth
.get("chatgpt_account_id")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let access_claims = parse_jwt_claims(&access_token);
let access_auth = access_claims
.get("https://api.openai.com/auth")
.cloned()
.unwrap_or(serde_json::Value::Object(Default::default()));
let org_id = id_auth
.get("organization_id")
.and_then(|v| v.as_str())
.or_else(|| access_auth.get("organization_id").and_then(|v| v.as_str()));
let project_id = id_auth
.get("project_id")
.and_then(|v| v.as_str())
.or_else(|| access_auth.get("project_id").and_then(|v| v.as_str()));
let completed = id_auth
.get("completed_platform_onboarding")
.and_then(|v| v.as_bool())
.or_else(|| {
access_auth
.get("completed_platform_onboarding")
.and_then(|v| v.as_bool())
})
.unwrap_or(false);
let is_owner = id_auth
.get("is_org_owner")
.and_then(|v| v.as_bool())
.or_else(|| access_auth.get("is_org_owner").and_then(|v| v.as_bool()))
.unwrap_or(false);
// Keep plan type from access token (Python behavior)
let plan_type = access_auth
.get("chatgpt_plan_type")
.and_then(|v| v.as_str())
.unwrap_or("");
let needs_setup = !completed && is_owner;
let (account_id, org_id, project_id, needs_setup, plan_type) =
extract_login_context(&id_token, &access_token);
// 2) Token exchange -> API key (Python parity: only if org and project are present)
let today = Utc::now().format("%Y-%m-%d").to_string();
let random_id = {
let mut bytes = [0u8; 6];
rand::thread_rng().fill_bytes(&mut bytes);
hex::encode(bytes)
};
let api_key = if org_id.is_none() || project_id.is_none() {
None
} else {
let exchange_form = vec![
(
"grant_type".to_string(),
"urn:ietf:params:oauth:grant-type:token-exchange".to_string(),
),
("client_id".to_string(), opts.client_id.clone()),
("requested_token".to_string(), "openai-api-key".to_string()),
("subject_token".to_string(), id_token.clone()),
(
"subject_token_type".to_string(),
"urn:ietf:params:oauth:token-type:id_token".to_string(),
),
(
"name".to_string(),
format!("Codex CLI [auto-generated] ({today}) [{random_id}]"),
),
];
let mut exchange_val = http.post_form(&token_endpoint, &exchange_form)?;
let mut api_key = exchange_val["access_token"].as_str().map(|s| s.to_string());
if api_key.is_none() {
// Fallback: retry with access_token as subject
let exchange_form2 = vec![
(
"grant_type".to_string(),
"urn:ietf:params:oauth:grant-type:token-exchange".to_string(),
),
("client_id".to_string(), opts.client_id.clone()),
("requested_token".to_string(), "openai-api-key".to_string()),
("subject_token".to_string(), access_token.clone()),
(
"subject_token_type".to_string(),
"urn:ietf:params:oauth:token-type:access_token".to_string(),
),
(
"name".to_string(),
format!("Codex CLI [auto-generated] ({today}) [{random_id}]"),
),
];
exchange_val = http.post_form(&token_endpoint, &exchange_form2)?;
api_key = exchange_val["access_token"].as_str().map(|s| s.to_string());
}
api_key
};
let api_key = None;
// Persist auth.json
write_auth_file(
@@ -743,13 +508,9 @@ pub fn process_callback_headless(
let success_url = build_success_url(
&base,
Some(&id_token),
org_id,
project_id,
if plan_type.is_empty() {
None
} else {
Some(plan_type)
},
org_id.as_deref(),
project_id.as_deref(),
plan_type.as_deref(),
needs_setup,
platform_url,
)
@@ -761,4 +522,4 @@ pub fn process_callback_headless(
})
}
// Success URL builder is in crate::success_url
//

View File

@@ -83,8 +83,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"
}));
// Token-exchange to API key
http.queue(json!({"access_token": "sk-xyz"}));
// Credits redeem
http.queue(json!({"granted_chatgpt_subscriber_api_credits": 5}));
@@ -93,7 +91,7 @@ fn headless_success_writes_auth_and_url() {
assert!(outcome.success_url.contains("/success"));
let contents = std::fs::read_to_string(tmp.path().join("auth.json")).unwrap();
let v: serde_json::Value = serde_json::from_str(&contents).unwrap();
assert_eq!(v["OPENAI_API_KEY"].as_str(), Some("sk-xyz"));
assert!(v["OPENAI_API_KEY"].is_null());
}
// 2) State mismatch errors
@@ -146,8 +144,6 @@ fn headless_credit_redemption_best_effort() {
"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"
}));
// Token exchange -> API key
http.queue(json!({"access_token": "sk-xyz"}));
// 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();
@@ -179,8 +175,6 @@ fn headless_id_token_fallback_for_org_and_project() {
})),
"refresh_token": "r1"
}));
// Token exchange -> API key
http.queue(json!({"access_token": "sk-xyz"}));
// Credits redeem
http.queue(json!({"granted_chatgpt_subscriber_api_credits": 0}));

View File

@@ -250,162 +250,8 @@ fn start_mock_oauth_server(port: u16, behavior: MockBehavior) {
);
}
}
MockBehavior::IdMissingOrgThenAccessSucceeds => {
if form.get("grant_type").map(|s| s.as_str()) == Some("authorization_code")
{
// Include org/project in access claims so server attempts exchange
let id_token = make_fake_jwt(serde_json::json!({
"https://api.openai.com/auth": {
"chatgpt_account_id": "acc-5"
}
}));
let access_token = make_fake_jwt(serde_json::json!({
"https://api.openai.com/auth": {
"organization_id": "org-x",
"project_id": "proj-x",
"completed_platform_onboarding": true,
"is_org_owner": false,
"chatgpt_plan_type": "plus"
}
}));
let payload = serde_json::json!({
"id_token": id_token,
"access_token": access_token,
"refresh_token": "refresh-5"
});
let _ = request.respond(
tiny_http::Response::from_string(payload.to_string())
.with_status_code(200)
.with_header(
tiny_http::Header::from_bytes(
&b"Content-Type"[..],
&b"application/json"[..],
)
.unwrap(),
),
);
} else {
// Distinguish by subject_token_type
match form.get("subject_token_type").map(|s| s.as_str()) {
Some("urn:ietf:params:oauth:token-type:id_token") => {
let body = serde_json::json!({
"error": {
"message": "Invalid ID token: missing organization_id",
"type": "invalid_request_error",
"param": null,
"code": "invalid_subject_token"
}
});
let _ = request.respond(
tiny_http::Response::from_string(body.to_string())
.with_status_code(401)
.with_header(
tiny_http::Header::from_bytes(
&b"Content-Type"[..],
&b"application/json"[..],
)
.unwrap(),
),
);
}
Some("urn:ietf:params:oauth:token-type:access_token") => {
let body =
serde_json::json!({"access_token": "sk-fallback-xyz"});
let _ = request.respond(
tiny_http::Response::from_string(body.to_string())
.with_status_code(200)
.with_header(
tiny_http::Header::from_bytes(
&b"Content-Type"[..],
&b"application/json"[..],
)
.unwrap(),
),
);
}
_ => {
let _ = request.respond(
tiny_http::Response::from_string("bad subject")
.with_status_code(400),
);
}
}
}
}
MockBehavior::IdMissingOrgThenAccessFails => {
if form.get("grant_type").map(|s| s.as_str()) == Some("authorization_code")
{
let id_token = make_fake_jwt(serde_json::json!({
"https://api.openai.com/auth": {"chatgpt_account_id": "acc-6"}
}));
let access_token = make_fake_jwt(serde_json::json!({
"https://api.openai.com/auth": {
"organization_id": "org-y",
"project_id": "proj-y",
"completed_platform_onboarding": true,
"is_org_owner": false,
"chatgpt_plan_type": "plus"
}
}));
let payload = serde_json::json!({
"id_token": id_token,
"access_token": access_token,
"refresh_token": "refresh-6"
});
let _ = request.respond(
tiny_http::Response::from_string(payload.to_string())
.with_status_code(200)
.with_header(
tiny_http::Header::from_bytes(
&b"Content-Type"[..],
&b"application/json"[..],
)
.unwrap(),
),
);
} else {
match form.get("subject_token_type").map(|s| s.as_str()) {
Some("urn:ietf:params:oauth:token-type:id_token") => {
let body = serde_json::json!({
"error": {"message": "Invalid ID token: missing organization_id", "type": "invalid_request_error", "param": null, "code": "invalid_subject_token"}
});
let _ = request.respond(
tiny_http::Response::from_string(body.to_string())
.with_status_code(401)
.with_header(
tiny_http::Header::from_bytes(
&b"Content-Type"[..],
&b"application/json"[..],
)
.unwrap(),
),
);
}
Some("urn:ietf:params:oauth:token-type:access_token") => {
let body = serde_json::json!({
"error": {"message": "Could not validate your subject token. Please try signing in again.", "type": "invalid_request_error", "param": null, "code": "invalid_subject_token"}
});
let _ = request.respond(
tiny_http::Response::from_string(body.to_string())
.with_status_code(401)
.with_header(
tiny_http::Header::from_bytes(
&b"Content-Type"[..],
&b"application/json"[..],
)
.unwrap(),
),
);
}
_ => {
let _ = request.respond(
tiny_http::Response::from_string("bad subject")
.with_status_code(400),
);
}
}
}
}
// Old token-exchange fallback behavior removed
// Old token-exchange fallback behavior removed
}
} else if request.method() == &tiny_http::Method::Post
&& url.starts_with("/v1/billing/redeem_credits")
@@ -437,8 +283,7 @@ enum MockBehavior {
SuccessIdClaimsOrgProject,
TokenError,
MissingOrgSkipExchange,
IdMissingOrgThenAccessSucceeds,
IdMissingOrgThenAccessFails,
// Old token-exchange fallback behaviors removed
}
fn make_fake_jwt(payload: serde_json::Value) -> String {
@@ -522,7 +367,7 @@ fn login_server_happy_path() {
let auth_path = codex_home.path().join("auth.json");
let contents = std::fs::read_to_string(&auth_path).unwrap();
let v: serde_json::Value = serde_json::from_str(&contents).unwrap();
assert_eq!(v["OPENAI_API_KEY"].as_str(), Some("sk-test-123"));
assert!(v["OPENAI_API_KEY"].is_null());
assert!(v["tokens"]["id_token"].as_str().is_some());
}
// 1b) needs_setup=true when onboarding incomplete and is_org_owner=true
@@ -643,79 +488,7 @@ fn login_server_skips_exchange_when_no_org_or_project() {
assert!(v["OPENAI_API_KEY"].is_null());
}
// 1e) Exchange: ID-token missing org -> retry access-token succeeds
#[test]
fn login_server_exchange_fallback_to_access_token() {
let oauth_port = find_free_port();
start_mock_oauth_server(oauth_port, MockBehavior::IdMissingOrgThenAccessSucceeds);
let codex_home = TempDir::new().unwrap();
let port = find_free_port();
let issuer = format!("http://127.0.0.1:{oauth_port}");
let opts = LoginServerOptions {
codex_home: codex_home.path().to_path_buf(),
client_id: "test-client".to_string(),
issuer: issuer.clone(),
port,
open_browser: false,
redeem_credits: true,
expose_state_endpoint: true,
testing_timeout_secs: Some(5),
verbose: false,
};
let handle = thread::spawn(move || run_local_login_server_with_options(opts).unwrap());
wait_for_state_endpoint(port, Duration::from_secs(5));
let state = ureq::get(&format!("http://127.0.0.1:{port}/__test/state"))
.call()
.unwrap()
.into_string()
.unwrap();
let cb_url = format!("http://127.0.0.1:{port}/auth/callback?code=abc&state={state}");
let (status, _body, _loc) = http_get(&cb_url);
assert_eq!(status, 302);
let _ = ureq::get(&format!("http://127.0.0.1:{port}/success")).call();
handle.join().unwrap();
let auth_path = codex_home.path().join("auth.json");
let v: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&auth_path).unwrap()).unwrap();
assert_eq!(v["OPENAI_API_KEY"].as_str(), Some("sk-fallback-xyz"));
}
// 1f) Exchange: ID-token missing org -> retry access-token fails; still success and tokens persisted w/o API key
#[test]
fn login_server_exchange_fallback_fails_persists_tokens() {
let oauth_port = find_free_port();
start_mock_oauth_server(oauth_port, MockBehavior::IdMissingOrgThenAccessFails);
let codex_home = TempDir::new().unwrap();
let port = find_free_port();
let issuer = format!("http://127.0.0.1:{oauth_port}");
let opts = LoginServerOptions {
codex_home: codex_home.path().to_path_buf(),
client_id: "test-client".to_string(),
issuer: issuer.clone(),
port,
open_browser: false,
redeem_credits: true,
expose_state_endpoint: true,
testing_timeout_secs: Some(5),
verbose: false,
};
let handle = thread::spawn(move || run_local_login_server_with_options(opts).unwrap());
wait_for_state_endpoint(port, Duration::from_secs(5));
let state = ureq::get(&format!("http://127.0.0.1:{port}/__test/state"))
.call()
.unwrap()
.into_string()
.unwrap();
let cb_url = format!("http://127.0.0.1:{port}/auth/callback?code=abc&state={state}");
let (status, _body, _loc) = http_get(&cb_url);
assert_eq!(status, 302);
let _ = ureq::get(&format!("http://127.0.0.1:{port}/success")).call();
handle.join().unwrap();
let auth_path = codex_home.path().join("auth.json");
let v: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&auth_path).unwrap()).unwrap();
assert!(v["OPENAI_API_KEY"].is_null());
}
//
// 2) State mismatch returns 400 and server stays up
#[test]
fn login_server_state_mismatch() {