diff --git a/codex-rs/login/src/lib.rs b/codex-rs/login/src/lib.rs index b6ea1e2b1b..ddb0264b2c 100644 --- a/codex-rs/login/src/lib.rs +++ b/codex-rs/login/src/lib.rs @@ -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 diff --git a/codex-rs/login/src/redeem.rs b/codex-rs/login/src/redeem.rs index cfd21b560e..8643dea336 100644 --- a/codex-rs/login/src/redeem.rs +++ b/codex-rs/login/src/redeem.rs @@ -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()) diff --git a/codex-rs/login/src/server.rs b/codex-rs/login/src/server.rs index 15b9941e98..93d86cef92 100644 --- a/codex-rs/login/src/server.rs +++ b/codex-rs/login/src/server.rs @@ -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, // account_id + Option, // org_id + Option, // project_id + bool, // needs_setup + Option, // 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 = 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::(&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::( - &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 = 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 +// diff --git a/codex-rs/login/tests/headless.rs b/codex-rs/login/tests/headless.rs index bdf4309075..6bcf12aa33 100644 --- a/codex-rs/login/tests/headless.rs +++ b/codex-rs/login/tests/headless.rs @@ -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})); diff --git a/codex-rs/login/tests/server.rs b/codex-rs/login/tests/server.rs index 16cc809256..709950fe83 100644 --- a/codex-rs/login/tests/server.rs +++ b/codex-rs/login/tests/server.rs @@ -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() {