Files
codex/prs/bolinfest/PR-2294.md
2025-09-02 15:17:45 -07:00

124 KiB
Raw Blame History

PR #2294: Port login server to rust

Description

Port the login server to rust.

Full Diff

diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock
index 41392633be..8f077bc0ab 100644
--- a/codex-rs/Cargo.lock
+++ b/codex-rs/Cargo.lock
@@ -203,6 +203,12 @@ version = "0.7.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
 
+[[package]]
+name = "ascii"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"
+
 [[package]]
 name = "ascii-canvas"
 version = "3.0.0"
@@ -481,6 +487,12 @@ dependencies = [
  "shlex",
 ]
 
+[[package]]
+name = "cesu8"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
+
 [[package]]
 name = "cfg-expr"
 version = "0.15.8"
@@ -518,6 +530,12 @@ dependencies = [
  "windows-link",
 ]
 
+[[package]]
+name = "chunked_transfer"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901"
+
 [[package]]
 name = "clap"
 version = "4.5.43"
@@ -798,12 +816,18 @@ dependencies = [
  "base64 0.22.1",
  "chrono",
  "pretty_assertions",
+ "rand 0.8.5",
  "reqwest",
  "serde",
  "serde_json",
+ "sha2",
  "tempfile",
  "thiserror 2.0.12",
+ "tiny_http",
  "tokio",
+ "url",
+ "urlencoding",
+ "webbrowser",
 ]
 
 [[package]]
@@ -951,6 +975,16 @@ version = "1.0.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
 
+[[package]]
+name = "combine"
+version = "4.6.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
+dependencies = [
+ "bytes",
+ "memchr",
+]
+
 [[package]]
 name = "compact_str"
 version = "0.8.1"
@@ -1005,6 +1039,16 @@ dependencies = [
  "libc",
 ]
 
+[[package]]
+name = "core-foundation"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
 [[package]]
 name = "core-foundation-sys"
 version = "0.8.7"
@@ -2455,6 +2499,28 @@ dependencies = [
  "syn 2.0.104",
 ]
 
+[[package]]
+name = "jni"
+version = "0.21.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97"
+dependencies = [
+ "cesu8",
+ "cfg-if",
+ "combine",
+ "jni-sys",
+ "log",
+ "thiserror 1.0.69",
+ "walkdir",
+ "windows-sys 0.45.0",
+]
+
+[[package]]
+name = "jni-sys"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130"
+
 [[package]]
 name = "jobserver"
 version = "0.1.33"
@@ -2791,6 +2857,12 @@ dependencies = [
  "tempfile",
 ]
 
+[[package]]
+name = "ndk-context"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b"
+
 [[package]]
 name = "new_debug_unreachable"
 version = "1.0.6"
@@ -2944,6 +3016,31 @@ dependencies = [
  "libc",
 ]
 
+[[package]]
+name = "objc2"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "561f357ba7f3a2a61563a186a163d0a3a5247e1089524a3981d49adb775078bc"
+dependencies = [
+ "objc2-encode",
+]
+
+[[package]]
+name = "objc2-encode"
+version = "4.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"
+
+[[package]]
+name = "objc2-foundation"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "900831247d2fe1a09a683278e5384cfb8c80c79fe6b166f9d14bfdde0ea1b03c"
+dependencies = [
+ "bitflags 2.9.1",
+ "objc2",
+]
+
 [[package]]
 name = "object"
 version = "0.36.7"
@@ -3670,6 +3767,7 @@ dependencies = [
  "base64 0.22.1",
  "bytes",
  "encoding_rs",
+ "futures-channel",
  "futures-core",
  "futures-util",
  "h2",
@@ -3992,7 +4090,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
 dependencies = [
  "bitflags 2.9.1",
- "core-foundation",
+ "core-foundation 0.9.4",
  "core-foundation-sys",
  "libc",
  "security-framework-sys",
@@ -4151,6 +4249,17 @@ dependencies = [
  "digest",
 ]
 
+[[package]]
+name = "sha2"
+version = "0.10.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+]
+
 [[package]]
 name = "sharded-slab"
 version = "0.1.7"
@@ -4515,7 +4624,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b"
 dependencies = [
  "bitflags 2.9.1",
- "core-foundation",
+ "core-foundation 0.9.4",
  "system-configuration-sys",
 ]
 
@@ -4710,6 +4819,18 @@ dependencies = [
  "crunchy",
 ]
 
+[[package]]
+name = "tiny_http"
+version = "0.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82"
+dependencies = [
+ "ascii",
+ "chunked_transfer",
+ "httpdate",
+ "log",
+]
+
 [[package]]
 name = "tinystr"
 version = "0.8.1"
@@ -5162,6 +5283,12 @@ dependencies = [
  "serde",
 ]
 
+[[package]]
+name = "urlencoding"
+version = "2.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
+
 [[package]]
 name = "utf8_iter"
 version = "1.0.4"
@@ -5385,6 +5512,22 @@ dependencies = [
  "wasm-bindgen",
 ]
 
+[[package]]
+name = "webbrowser"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aaf4f3c0ba838e82b4e5ccc4157003fb8c324ee24c058470ffb82820becbde98"
+dependencies = [
+ "core-foundation 0.10.1",
+ "jni",
+ "log",
+ "ndk-context",
+ "objc2",
+ "objc2-foundation",
+ "url",
+ "web-sys",
+]
+
 [[package]]
 name = "weezl"
 version = "0.1.10"
@@ -5573,6 +5716,15 @@ dependencies = [
  "windows-link",
 ]
 
+[[package]]
+name = "windows-sys"
+version = "0.45.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0"
+dependencies = [
+ "windows-targets 0.42.2",
+]
+
 [[package]]
 name = "windows-sys"
 version = "0.52.0"
@@ -5600,6 +5752,21 @@ dependencies = [
  "windows-targets 0.53.2",
 ]
 
+[[package]]
+name = "windows-targets"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071"
+dependencies = [
+ "windows_aarch64_gnullvm 0.42.2",
+ "windows_aarch64_msvc 0.42.2",
+ "windows_i686_gnu 0.42.2",
+ "windows_i686_msvc 0.42.2",
+ "windows_x86_64_gnu 0.42.2",
+ "windows_x86_64_gnullvm 0.42.2",
+ "windows_x86_64_msvc 0.42.2",
+]
+
 [[package]]
 name = "windows-targets"
 version = "0.52.6"
@@ -5632,6 +5799,12 @@ dependencies = [
  "windows_x86_64_msvc 0.53.0",
 ]
 
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
+
 [[package]]
 name = "windows_aarch64_gnullvm"
 version = "0.52.6"
@@ -5644,6 +5817,12 @@ version = "0.53.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764"
 
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
+
 [[package]]
 name = "windows_aarch64_msvc"
 version = "0.52.6"
@@ -5656,6 +5835,12 @@ version = "0.53.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c"
 
+[[package]]
+name = "windows_i686_gnu"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
+
 [[package]]
 name = "windows_i686_gnu"
 version = "0.52.6"
@@ -5680,6 +5865,12 @@ version = "0.53.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11"
 
+[[package]]
+name = "windows_i686_msvc"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
+
 [[package]]
 name = "windows_i686_msvc"
 version = "0.52.6"
@@ -5692,6 +5883,12 @@ version = "0.53.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d"
 
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
+
 [[package]]
 name = "windows_x86_64_gnu"
 version = "0.52.6"
@@ -5704,6 +5901,12 @@ version = "0.53.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba"
 
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
+
 [[package]]
 name = "windows_x86_64_gnullvm"
 version = "0.52.6"
@@ -5716,6 +5919,12 @@ version = "0.53.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57"
 
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
+
 [[package]]
 name = "windows_x86_64_msvc"
 version = "0.52.6"
diff --git a/codex-rs/cli/src/login.rs b/codex-rs/cli/src/login.rs
index 1a70bd27b6..895eeb1089 100644
--- a/codex-rs/cli/src/login.rs
+++ b/codex-rs/cli/src/login.rs
@@ -1,20 +1,54 @@
-use std::env;
-
 use codex_common::CliConfigOverrides;
 use codex_core::config::Config;
 use codex_core::config::ConfigOverrides;
 use codex_login::AuthMode;
+use codex_login::CLIENT_ID;
 use codex_login::CodexAuth;
+use codex_login::LoginServerInfo;
 use codex_login::OPENAI_API_KEY_ENV_VAR;
+use codex_login::ServerOptions;
 use codex_login::login_with_api_key;
-use codex_login::login_with_chatgpt;
 use codex_login::logout;
+use codex_login::run_server_blocking_with_notify;
+use std::env;
+use std::path::Path;
+use std::sync::mpsc;
+
+pub async fn login_with_chatgpt(codex_home: &Path) -> std::io::Result<()> {
+    let (tx, rx) = mpsc::channel::<LoginServerInfo>();
+    let client_id = CLIENT_ID;
+    let codex_home = codex_home.to_path_buf();
+    tokio::spawn(async move {
+        match rx.recv() {
+            Ok(LoginServerInfo {
+                auth_url,
+                actual_port,
+            }) => {
+                eprintln!(
+                    "Starting local login server on http://localhost:{actual_port}.\nIf your browser did not open, navigate to this URL to authenticate:\n\n{auth_url}",
+                );
+            }
+            _ => {
+                tracing::error!("Failed to receive login server info");
+            }
+        }
+    });
+
+    tokio::task::spawn_blocking(move || {
+        let opts = ServerOptions::new(&codex_home, client_id);
+        run_server_blocking_with_notify(opts, Some(tx), None)
+    })
+    .await
+    .map_err(std::io::Error::other)??;
+
+    eprintln!("Successfully logged in");
+    Ok(())
+}
 
 pub async fn run_login_with_chatgpt(cli_config_overrides: CliConfigOverrides) -> ! {
     let config = load_config_or_exit(cli_config_overrides);
 
-    let capture_output = false;
-    match login_with_chatgpt(&config.codex_home, capture_output).await {
+    match login_with_chatgpt(&config.codex_home).await {
         Ok(_) => {
             eprintln!("Successfully logged in");
             std::process::exit(0);
diff --git a/codex-rs/login/Cargo.toml b/codex-rs/login/Cargo.toml
index 85c11505ec..c1e21ca627 100644
--- a/codex-rs/login/Cargo.toml
+++ b/codex-rs/login/Cargo.toml
@@ -9,11 +9,14 @@ workspace = true
 [dependencies]
 base64 = "0.22"
 chrono = { version = "0.4", features = ["serde"] }
-reqwest = { version = "0.12", features = ["json"] }
+rand = "0.8"
+reqwest = { version = "0.12", features = ["json", "blocking"] }
 serde = { version = "1", features = ["derive"] }
 serde_json = "1"
+sha2 = "0.10"
 tempfile = "3"
 thiserror = "2.0.12"
+tiny_http = "0.12"
 tokio = { version = "1", features = [
     "io-std",
     "macros",
@@ -21,6 +24,9 @@ tokio = { version = "1", features = [
     "rt-multi-thread",
     "signal",
 ] }
+url = "2"
+urlencoding = "2.1"
+webbrowser = "1.0"
 
 [dev-dependencies]
 pretty_assertions = "1.4.1"
diff --git a/codex-rs/login/src/assets/success.html b/codex-rs/login/src/assets/success.html
new file mode 100644
index 0000000000..eb2a0ee719
--- /dev/null
+++ b/codex-rs/login/src/assets/success.html
@@ -0,0 +1,198 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="utf-8" />
+    <title>Sign into Codex CLI</title>
+    <link rel="icon" href='data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="none" viewBox="0 0 32 32"%3E%3Cpath stroke="%23000" stroke-linecap="round" stroke-width="2.484" d="M22.356 19.797H17.17M9.662 12.29l1.979 3.576a.511.511 0 0 1-.005.504l-1.974 3.409M30.758 16c0 8.15-6.607 14.758-14.758 14.758-8.15 0-14.758-6.607-14.758-14.758C1.242 7.85 7.85 1.242 16 1.242c8.15 0 14.758 6.608 14.758 14.758Z"/%3E%3C/svg%3E' type="image/svg+xml">
+    <style>
+      .container {
+        margin: auto;
+        height: 100%;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        position: relative;
+        background: white;
+
+        font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
+      }
+      .inner-container {
+        width: 400px;
+        flex-direction: column;
+        justify-content: flex-start;
+        align-items: center;
+        gap: 20px;
+        display: inline-flex;
+      }
+      .content {
+        align-self: stretch;
+        flex-direction: column;
+        justify-content: flex-start;
+        align-items: center;
+        gap: 20px;
+        display: flex;
+        margin-top: 15vh;
+      }
+      .svg-wrapper {
+        position: relative;
+      }
+      .title {
+        text-align: center;
+        color: var(--text-primary, #0D0D0D);
+        font-size: 32px;
+        font-weight: 400;
+        line-height: 40px;
+        word-wrap: break-word;
+      }
+      .setup-box {
+        width: 600px;
+        padding: 16px 20px;
+        background: var(--bg-primary, white);
+        box-shadow: 0px 4px 16px rgba(0, 0, 0, 0.05);
+        border-radius: 16px;
+        outline: 1px var(--border-default, rgba(13, 13, 13, 0.10)) solid;
+        outline-offset: -1px;
+        justify-content: flex-start;
+        align-items: center;
+        gap: 16px;
+        display: inline-flex;
+      }
+      .setup-content {
+        flex: 1 1 0;
+        justify-content: flex-start;
+        align-items: center;
+        gap: 24px;
+        display: flex;
+      }
+      .setup-text {
+        flex: 1 1 0;
+        flex-direction: column;
+        justify-content: flex-start;
+        align-items: flex-start;
+        gap: 4px;
+        display: inline-flex;
+      }
+      .setup-title {
+        align-self: stretch;
+        color: var(--text-primary, #0D0D0D);
+        font-size: 14px;
+        font-weight: 510;
+        line-height: 20px;
+        word-wrap: break-word;
+      }
+      .setup-description {
+        align-self: stretch;
+        color: var(--text-secondary, #5D5D5D);
+        font-size: 14px;
+        font-weight: 400;
+        line-height: 20px;
+        word-wrap: break-word;
+      }
+      .redirect-box {
+        justify-content: flex-start;
+        align-items: center;
+        gap: 8px;
+        display: flex;
+      }
+      .close-button,
+      .redirect-button {
+        height: 28px;
+        padding: 8px 16px;
+        background: var(--interactive-bg-primary-default, #0D0D0D);
+        border-radius: 999px;
+        justify-content: center;
+        align-items: center;
+        gap: 4px;
+        display: flex;
+      }
+      .close-button,
+      .redirect-text {
+        color: var(--interactive-label-primary-default, white);
+        font-size: 14px;
+        font-weight: 510;
+        line-height: 20px;
+        word-wrap: break-word;
+        text-decoration: none;
+      }
+      .logo {
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        width: 4rem;
+        height: 4rem;
+        border-radius: 16px;
+        border: .5px solid rgba(0, 0, 0, 0.1);
+        box-shadow: rgba(0, 0, 0, 0.1) 0px 4px 16px 0px;
+        box-sizing: border-box;
+        background-color: rgb(255, 255, 255);
+      }
+    </style>
+  </head>
+  <body>
+    <div class="container">
+      <div class="inner-container">
+        <div class="content">
+          <div class="logo">
+            <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="none" viewBox="0 0 32 32"><path stroke="#000" stroke-linecap="round" stroke-width="2.484" d="M22.356 19.797H17.17M9.662 12.29l1.979 3.576a.511.511 0 0 1-.005.504l-1.974 3.409M30.758 16c0 8.15-6.607 14.758-14.758 14.758-8.15 0-14.758-6.607-14.758-14.758C1.242 7.85 7.85 1.242 16 1.242c8.15 0 14.758 6.608 14.758 14.758Z"></path></svg>
+          </div>
+          <div class="title">Signed in to Codex CLI</div>
+        </div>
+        <div class="close-box" style="display: none;">
+          <div class="setup-description">You may now close this page</div>
+        </div>
+        <div class="setup-box" style="display: none;">
+          <div class="setup-content">
+            <div class="setup-text">
+              <div class="setup-title">Finish setting up your API organization</div>
+              <div class="setup-description">Add a payment method to use your organization.</div>
+            </div>
+            <div class="redirect-box">
+              <div data-hasendicon="false" data-hasstarticon="false" data-ishovered="false" data-isinactive="false" data-ispressed="false" data-size="large" data-type="primary" class="redirect-button">
+                <div class="redirect-text">Redirecting in 3s...</div>
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+    <script>
+      (function () {
+        const params = new URLSearchParams(window.location.search);
+        const needsSetup = params.get('needs_setup') === 'true';
+        const platformUrl = params.get('platform_url') || 'https://platform.openai.com';
+        const orgId = params.get('org_id');
+        const projectId = params.get('project_id');
+        const planType = params.get('plan_type');
+        const idToken = params.get('id_token');
+        // Show different message and optional redirect when setup is required
+        if (needsSetup) {
+          const setupBox = document.querySelector('.setup-box');
+          setupBox.style.display = 'flex';
+          const redirectUrlObj = new URL('/org-setup', platformUrl);
+          redirectUrlObj.searchParams.set('p', planType);
+          redirectUrlObj.searchParams.set('t', idToken);
+          redirectUrlObj.searchParams.set('with_org', orgId);
+          redirectUrlObj.searchParams.set('project_id', projectId);
+          const redirectUrl = redirectUrlObj.toString();
+          const message = document.querySelector('.redirect-text');
+          let countdown = 3;
+          function tick() {
+            message.textContent =
+              'Redirecting in ' + countdown + 's…';
+            if (countdown === 0) {
+              window.location.replace(redirectUrl);
+            } else {
+              countdown -= 1;
+              setTimeout(tick, 1000);
+            }
+          }
+          tick();
+        } else {
+          const closeBox = document.querySelector('.close-box');
+          closeBox.style.display = 'flex';
+        }
+      })();
+    </script>
+  </body>
+  </html>
+  
\ No newline at end of file
diff --git a/codex-rs/login/src/lib.rs b/codex-rs/login/src/lib.rs
index a1dad79e9d..d4358d2735 100644
--- a/codex-rs/login/src/lib.rs
+++ b/codex-rs/login/src/lib.rs
@@ -1,5 +1,4 @@
 use chrono::DateTime;
-
 use chrono::Utc;
 use serde::Deserialize;
 use serde::Serialize;
@@ -9,27 +8,26 @@ use std::fs::OpenOptions;
 use std::fs::remove_file;
 use std::io::Read;
 use std::io::Write;
-use std::io::{self};
 #[cfg(unix)]
 use std::os::unix::fs::OpenOptionsExt;
 use std::path::Path;
 use std::path::PathBuf;
-use std::process::Child;
-use std::process::Stdio;
 use std::sync::Arc;
 use std::sync::Mutex;
 use std::time::Duration;
-use tempfile::NamedTempFile;
-use tokio::process::Command;
 
+pub use crate::server::LoginServerInfo;
+pub use crate::server::ServerOptions;
+pub use crate::server::run_server_blocking;
+pub use crate::server::run_server_blocking_with_notify;
 pub use crate::token_data::TokenData;
 use crate::token_data::parse_id_token;
 
+mod pkce;
+mod server;
 mod token_data;
 
-const SOURCE_FOR_PYTHON_SERVER: &str = include_str!("./login_with_chatgpt.py");
-
-const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
+pub const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
 pub const OPENAI_API_KEY_ENV_VAR: &str = "OPENAI_API_KEY";
 
 #[derive(Clone, Debug, PartialEq, Copy)]
@@ -254,139 +252,65 @@ pub fn logout(codex_home: &Path) -> std::io::Result<bool> {
     }
 }
 
-/// Represents a running login subprocess. The child can be killed by holding
-/// the mutex and calling `kill()`.
+/// Represents a running login server. The server can be stopped by calling `cancel()` on SpawnedLogin.
 #[derive(Debug, Clone)]
 pub struct SpawnedLogin {
-    pub child: Arc<Mutex<Child>>,
-    pub stdout: Arc<Mutex<Vec<u8>>>,
-    pub stderr: Arc<Mutex<Vec<u8>>>,
+    url: Arc<Mutex<Option<String>>>,
+    done: Arc<Mutex<Option<bool>>>,
+    shutdown: Arc<std::sync::atomic::AtomicBool>,
 }
 
 impl SpawnedLogin {
-    /// Returns the login URL, if one has been emitted by the login subprocess.
-    ///
-    /// The Python helper prints the URL to stderr; we capture it and extract
-    /// the last whitespace-separated token that starts with "http".
     pub fn get_login_url(&self) -> Option<String> {
-        self.stderr
-            .lock()
-            .ok()
-            .and_then(|buffer| String::from_utf8(buffer.clone()).ok())
-            .and_then(|output| {
-                output
-                    .split_whitespace()
-                    .filter(|part| part.starts_with("http"))
-                    .next_back()
-                    .map(|s| s.to_string())
-            })
+        self.url.lock().ok().and_then(|u| u.clone())
     }
-}
 
-// Helpers for streaming child output into shared buffers
-struct AppendWriter {
-    buf: Arc<Mutex<Vec<u8>>>,
-}
-
-impl Write for AppendWriter {
-    fn write(&mut self, data: &[u8]) -> io::Result<usize> {
-        if let Ok(mut b) = self.buf.lock() {
-            b.extend_from_slice(data);
-        }
-        Ok(data.len())
+    pub fn get_auth_result(&self) -> Option<bool> {
+        self.done.lock().ok().and_then(|d| *d)
     }
 
-    fn flush(&mut self) -> io::Result<()> {
-        Ok(())
+    pub fn cancel(&self) {
+        self.shutdown
+            .store(true, std::sync::atomic::Ordering::SeqCst);
     }
 }
 
-fn spawn_pipe_reader<R: Read + Send + 'static>(mut reader: R, buf: Arc<Mutex<Vec<u8>>>) {
+pub fn spawn_login_with_chatgpt(codex_home: &Path) -> std::io::Result<SpawnedLogin> {
+    let (tx, rx) = std::sync::mpsc::channel::<LoginServerInfo>();
+    let shutdown = Arc::new(std::sync::atomic::AtomicBool::new(false));
+    let done = Arc::new(Mutex::new(None::<bool>));
+    let url = Arc::new(Mutex::new(None::<String>));
+
+    let codex_home_buf = codex_home.to_path_buf();
+    let client_id = CLIENT_ID.to_string();
+
+    let shutdown_clone = shutdown.clone();
+    let done_clone = done.clone();
     std::thread::spawn(move || {
-        let _ = io::copy(&mut reader, &mut AppendWriter { buf });
+        let opts = ServerOptions::new(&codex_home_buf, &client_id);
+        let res = run_server_blocking_with_notify(opts, Some(tx), Some(shutdown_clone));
+        let success = res.is_ok();
+        if let Ok(mut lock) = done_clone.lock() {
+            *lock = Some(success);
+        }
     });
-}
 
-/// Spawn the ChatGPT login Python server as a child process and return a handle to its process.
-pub fn spawn_login_with_chatgpt(codex_home: &Path) -> std::io::Result<SpawnedLogin> {
-    let script_path = write_login_script_to_disk()?;
-    let mut cmd = std::process::Command::new("python3");
-    cmd.arg(&script_path)
-        .env("CODEX_HOME", codex_home)
-        .env("CODEX_CLIENT_ID", CLIENT_ID)
-        .stdin(Stdio::null())
-        .stdout(Stdio::piped())
-        .stderr(Stdio::piped());
-
-    let mut child = cmd.spawn()?;
-
-    let stdout_buf = Arc::new(Mutex::new(Vec::new()));
-    let stderr_buf = Arc::new(Mutex::new(Vec::new()));
-
-    if let Some(out) = child.stdout.take() {
-        spawn_pipe_reader(out, stdout_buf.clone());
-    }
-    if let Some(err) = child.stderr.take() {
-        spawn_pipe_reader(err, stderr_buf.clone());
-    }
+    let url_clone = url.clone();
+    std::thread::spawn(move || {
+        if let Ok(u) = rx.recv() {
+            if let Ok(mut lock) = url_clone.lock() {
+                *lock = Some(u.auth_url);
+            }
+        }
+    });
 
     Ok(SpawnedLogin {
-        child: Arc::new(Mutex::new(child)),
-        stdout: stdout_buf,
-        stderr: stderr_buf,
+        url,
+        done,
+        shutdown,
     })
 }
 
-/// Run `python3 -c {{SOURCE_FOR_PYTHON_SERVER}}` with the CODEX_HOME
-/// environment variable set to the provided `codex_home` path. If the
-/// subprocess exits 0, read the OPENAI_API_KEY property out of
-/// CODEX_HOME/auth.json and return Ok(OPENAI_API_KEY). Otherwise, return Err
-/// with any information from the subprocess.
-///
-/// If `capture_output` is true, the subprocess's output will be captured and
-/// recorded in memory. Otherwise, the subprocess's output will be sent to the
-/// current process's stdout/stderr.
-pub async fn login_with_chatgpt(codex_home: &Path, capture_output: bool) -> std::io::Result<()> {
-    let script_path = write_login_script_to_disk()?;
-    let child = Command::new("python3")
-        .arg(&script_path)
-        .env("CODEX_HOME", codex_home)
-        .env("CODEX_CLIENT_ID", CLIENT_ID)
-        .stdin(Stdio::null())
-        .stdout(if capture_output {
-            Stdio::piped()
-        } else {
-            Stdio::inherit()
-        })
-        .stderr(if capture_output {
-            Stdio::piped()
-        } else {
-            Stdio::inherit()
-        })
-        .spawn()?;
-
-    let output = child.wait_with_output().await?;
-    if output.status.success() {
-        Ok(())
-    } else {
-        let stderr = String::from_utf8_lossy(&output.stderr);
-        Err(std::io::Error::other(format!(
-            "login_with_chatgpt subprocess failed: {stderr}"
-        )))
-    }
-}
-
-fn write_login_script_to_disk() -> std::io::Result<PathBuf> {
-    // Write the embedded Python script to a file to avoid very long
-    // command-line arguments (Windows error 206).
-    let mut tmp = NamedTempFile::new()?;
-    tmp.write_all(SOURCE_FOR_PYTHON_SERVER.as_bytes())?;
-    tmp.flush()?;
-
-    let (_file, path) = tmp.keep()?;
-    Ok(path)
-}
-
 pub fn login_with_api_key(codex_home: &Path, api_key: &str) -> std::io::Result<()> {
     let auth_dot_json = AuthDotJson {
         openai_api_key: Some(api_key.to_string()),
@@ -538,7 +462,7 @@ mod tests {
     }
 
     #[tokio::test]
-    async fn pro_account_with_no_api_key_uses_chatgpt_auth() {
+    async fn roundtrip_auth_dot_json() {
         let codex_home = tempdir().unwrap();
         write_auth_file(
             AuthFileParams {
@@ -549,6 +473,26 @@ mod tests {
         )
         .expect("failed to write auth file");
 
+        let file = get_auth_file(codex_home.path());
+        let auth_dot_json = try_read_auth_json(&file).unwrap();
+        write_auth_json(&file, &auth_dot_json).unwrap();
+
+        let same_auth_dot_json = try_read_auth_json(&file).unwrap();
+        assert_eq!(auth_dot_json, same_auth_dot_json);
+    }
+
+    #[tokio::test]
+    async fn pro_account_with_no_api_key_uses_chatgpt_auth() {
+        let codex_home = tempdir().unwrap();
+        let fake_jwt = write_auth_file(
+            AuthFileParams {
+                openai_api_key: None,
+                chatgpt_plan_type: "pro".to_string(),
+            },
+            codex_home.path(),
+        )
+        .expect("failed to write auth file");
+
         let CodexAuth {
             api_key,
             mode,
@@ -567,6 +511,7 @@ mod tests {
                     id_token: IdTokenInfo {
                         email: Some("user@example.com".to_string()),
                         chatgpt_plan_type: Some(PlanType::Known(KnownPlan::Pro)),
+                        raw_jwt: fake_jwt,
                     },
                     access_token: "test-access-token".to_string(),
                     refresh_token: "test-refresh-token".to_string(),
@@ -588,7 +533,7 @@ mod tests {
     #[tokio::test]
     async fn pro_account_with_api_key_still_uses_chatgpt_auth() {
         let codex_home = tempdir().unwrap();
-        write_auth_file(
+        let fake_jwt = write_auth_file(
             AuthFileParams {
                 openai_api_key: Some("sk-test-key".to_string()),
                 chatgpt_plan_type: "pro".to_string(),
@@ -615,6 +560,7 @@ mod tests {
                     id_token: IdTokenInfo {
                         email: Some("user@example.com".to_string()),
                         chatgpt_plan_type: Some(PlanType::Known(KnownPlan::Pro)),
+                        raw_jwt: fake_jwt,
                     },
                     access_token: "test-access-token".to_string(),
                     refresh_token: "test-refresh-token".to_string(),
@@ -662,7 +608,7 @@ mod tests {
         chatgpt_plan_type: String,
     }
 
-    fn write_auth_file(params: AuthFileParams, codex_home: &Path) -> std::io::Result<()> {
+    fn write_auth_file(params: AuthFileParams, codex_home: &Path) -> std::io::Result<String> {
         let auth_file = get_auth_file(codex_home);
         // Create a minimal valid JWT for the id_token field.
         #[derive(Serialize)]
@@ -700,7 +646,9 @@ mod tests {
             "last_refresh": LAST_REFRESH,
         });
         let auth_json = serde_json::to_string_pretty(&auth_json_data)?;
-        std::fs::write(auth_file, auth_json)
+        std::fs::write(auth_file, auth_json)?;
+
+        Ok(fake_jwt)
     }
 
     #[test]
diff --git a/codex-rs/login/src/login_with_chatgpt.py b/codex-rs/login/src/login_with_chatgpt.py
deleted file mode 100644
index 252c4e06ae..0000000000
--- a/codex-rs/login/src/login_with_chatgpt.py
+++ /dev/null
@@ -1,933 +0,0 @@
-"""Script that spawns a local webserver for retrieving an OpenAI API key.
-
-- Listens on 127.0.0.1:1455
-- Opens http://localhost:1455/auth/callback in the browser
-- If the user successfully navigates the auth flow,
-  $CODEX_HOME/auth.json will be written with the API key.
-- User will be redirected to http://localhost:1455/success upon success.
-
-The script should exit with a non-zero code if the user fails to navigate the
-auth flow.
-
-To test this script locally without overwriting your existing auth.json file:
-
-```
-rm -rf /tmp/codex_home && mkdir /tmp/codex_home
-CODEX_HOME=/tmp/codex_home python3 codex-rs/login/src/login_with_chatgpt.py
-```
-"""
-
-from __future__ import annotations
-
-import argparse
-import base64
-import datetime
-import errno
-import hashlib
-import http.server
-import json
-import os
-import secrets
-import sys
-import threading
-import time
-import urllib.parse
-import urllib.request
-import webbrowser
-from dataclasses import dataclass
-from typing import Any, Dict  # for type hints
-
-# Required port for OAuth client.
-REQUIRED_PORT = 1455
-URL_BASE = f"http://localhost:{REQUIRED_PORT}"
-DEFAULT_ISSUER = "https://auth.openai.com"
-
-EXIT_CODE_WHEN_ADDRESS_ALREADY_IN_USE = 13
-
-CA_CONTEXT = None
-CODEX_LOGIN_TRACE = os.environ.get("CODEX_LOGIN_TRACE", "false") in ["true", "1"]
-
-try:
-
-    def trace(msg: str) -> None:
-        if CODEX_LOGIN_TRACE:
-            print(msg)
-
-    def attempt_request(method: str) -> bool:
-        try:
-            with urllib.request.urlopen(
-                urllib.request.Request(
-                    f"{DEFAULT_ISSUER}/.well-known/openid-configuration",
-                    method="GET",
-                ),
-                context=CA_CONTEXT,
-            ) as resp:
-                if resp.status != 200:
-                    trace(f"Request using {method} failed: {resp.status}")
-                    return False
-
-                trace(f"Request using {method} succeeded")
-                return True
-        except Exception as e:
-            trace(f"Request using {method} failed: {e}")
-            return False
-
-    status = attempt_request("default settings")
-    if not status:
-        try:
-            import truststore
-
-            truststore.inject_into_ssl()
-            status = attempt_request("truststore")
-        except Exception as e:
-            trace(f"Failed to use truststore: {e}")
-
-    if not status:
-        try:
-            import ssl
-            import certifi as _certifi
-
-            CA_CONTEXT = ssl.create_default_context(cafile=_certifi.where())
-            status = attempt_request("certify")
-        except Exception as e:
-            trace(f"Failed to use certify: {e}")
-
-
-except Exception:
-    pass
-
-
-@dataclass
-class TokenData:
-    id_token: str
-    access_token: str
-    refresh_token: str
-    account_id: str
-
-
-@dataclass
-class AuthBundle:
-    """Aggregates authentication data produced after successful OAuth flow."""
-
-    api_key: str | None
-    token_data: TokenData
-    last_refresh: str
-
-
-def main() -> None:
-    parser = argparse.ArgumentParser(description="Retrieve API key via local HTTP flow")
-    parser.add_argument(
-        "--no-browser",
-        action="store_true",
-        help="Do not automatically open the browser",
-    )
-    parser.add_argument("--verbose", action="store_true", help="Enable request logging")
-    args = parser.parse_args()
-
-    codex_home = os.environ.get("CODEX_HOME")
-    if not codex_home:
-        eprint("ERROR: CODEX_HOME environment variable is not set")
-        sys.exit(1)
-
-    client_id = os.getenv("CODEX_CLIENT_ID")
-    if not client_id:
-        eprint("ERROR: CODEX_CLIENT_ID environment variable is not set")
-        sys.exit(1)
-
-    # Spawn server.
-    try:
-        httpd = _ApiKeyHTTPServer(
-            ("127.0.0.1", REQUIRED_PORT),
-            _ApiKeyHTTPHandler,
-            codex_home=codex_home,
-            client_id=client_id,
-            verbose=args.verbose,
-        )
-    except OSError as e:
-        eprint(f"ERROR: {e}")
-        if e.errno == errno.EADDRINUSE:
-            # Caller might want to handle this case specially.
-            sys.exit(EXIT_CODE_WHEN_ADDRESS_ALREADY_IN_USE)
-        else:
-            sys.exit(1)
-
-    auth_url = httpd.auth_url()
-
-    with httpd:
-        eprint(f"Starting local login server on {URL_BASE}")
-        if not args.no_browser:
-            try:
-                webbrowser.open(auth_url, new=1, autoraise=True)
-            except Exception as e:
-                eprint(f"Failed to open browser: {e}")
-
-        eprint(
-            f". If your browser did not open, navigate to this URL to authenticate: \n\n{auth_url}"
-        )
-
-        # Run the server in the main thread until `shutdown()` is called by the
-        # request handler.
-        try:
-            httpd.serve_forever()
-        except KeyboardInterrupt:
-            eprint("\nKeyboard interrupt received, exiting.")
-
-        # Server has been shut down by the request handler. Exit with the code
-        # it set (0 on success, non-zero on failure).
-        sys.exit(httpd.exit_code)
-
-
-class _ApiKeyHTTPHandler(http.server.BaseHTTPRequestHandler):
-    """A minimal request handler that captures an *api key* from query/post."""
-
-    # We store the result in the server instance itself.
-    server: "_ApiKeyHTTPServer"  # type: ignore[override]  - helpful annotation
-
-    def do_GET(self) -> None:  # noqa: N802  required by BaseHTTPRequestHandler
-        path = urllib.parse.urlparse(self.path).path
-
-        if path == "/success":
-            # Serve confirmation page then gracefully shut down the server so
-            # the main thread can exit with the previously captured exit code.
-            self._send_html(LOGIN_SUCCESS_HTML)
-
-            # Ensure the data is flushed to the client before we stop.
-            try:
-                self.wfile.flush()
-            except Exception as e:
-                eprint(f"Failed to flush response: {e}")
-
-            self.request_shutdown()
-        elif path == "/auth/callback":
-            query = urllib.parse.urlparse(self.path).query
-            params = urllib.parse.parse_qs(query)
-
-            # Validate state -------------------------------------------------
-            if params.get("state", [None])[0] != self.server.state:
-                self.send_error(400, "State parameter mismatch")
-                return
-
-            # Standard OAuth flow -----------------------------------------
-            code = params.get("code", [None])[0]
-            if not code:
-                self.send_error(400, "Missing authorization code")
-                return
-
-            try:
-                auth_bundle, success_url = self._exchange_code(code)
-            except Exception as exc:  # noqa: BLE001  propagate to client
-                self.send_error(500, f"Token exchange failed: {exc}")
-                return
-
-            # Persist API key along with additional token metadata.
-            if _write_auth_file(
-                auth=auth_bundle,
-                codex_home=self.server.codex_home,
-            ):
-                self.server.exit_code = 0
-                self._send_redirect(success_url)
-            else:
-                self.send_error(500, "Unable to persist auth file")
-        else:
-            self.send_error(404, "Endpoint not supported")
-
-    def do_POST(self) -> None:  # noqa: N802  required by BaseHTTPRequestHandler
-        self.send_error(404, "Endpoint not supported")
-
-    def send_error(self, code, message=None, explain=None) -> None:
-        """Send an error response and stop the server.
-
-        We avoid calling `sys.exit()` directly from the request-handling thread
-        so that the response has a chance to be written to the socket. Instead
-        we shut the server down; the main thread will then exit with the
-        appropriate status code.
-        """
-        super().send_error(code, message, explain)
-        try:
-            self.wfile.flush()
-        except Exception as e:
-            eprint(f"Failed to flush response: {e}")
-
-        self.request_shutdown()
-
-    def _send_redirect(self, url: str) -> None:
-        self.send_response(302)
-        self.send_header("Location", url)
-        self.end_headers()
-
-    def _send_html(self, body: str) -> None:
-        encoded = body.encode()
-        self.send_response(200)
-        self.send_header("Content-Type", "text/html; charset=utf-8")
-        self.send_header("Content-Length", str(len(encoded)))
-        self.end_headers()
-        self.wfile.write(encoded)
-
-    # Silence logging for cleanliness unless --verbose flag is used.
-    def log_message(self, fmt: str, *args):  # type: ignore[override]
-        if getattr(self.server, "verbose", False):  # type: ignore[attr-defined]
-            super().log_message(fmt, *args)
-
-    def _obtain_api_key(
-        self,
-        token_claims: Dict[str, Any],
-        access_claims: Dict[str, Any],
-        token_data: TokenData,
-    ) -> tuple[str | None, str | None]:
-        """Obtain an API key from the auth service.
-
-        Returns (api_key, success_url) if successful, None otherwise.
-        """
-
-        org_id = token_claims.get("organization_id")
-        project_id = token_claims.get("project_id")
-
-        if not org_id or not project_id:
-            return (None, None)
-
-        random_id = secrets.token_hex(6)
-
-        # 2. Token exchange to obtain API key
-        today = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d")
-        exchange_data = urllib.parse.urlencode(
-            {
-                "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
-                "client_id": self.server.client_id,
-                "requested_token": "openai-api-key",
-                "subject_token": token_data.id_token,
-                "subject_token_type": "urn:ietf:params:oauth:token-type:id_token",
-                "name": f"Codex CLI [auto-generated] ({today}) [{random_id}]",
-            }
-        ).encode()
-
-        exchanged_access_token: str
-        with urllib.request.urlopen(
-            urllib.request.Request(
-                self.server.token_endpoint,
-                data=exchange_data,
-                method="POST",
-                headers={"Content-Type": "application/x-www-form-urlencoded"},
-            ),
-            context=CA_CONTEXT,
-        ) as resp:
-            exchange_payload = json.loads(resp.read().decode())
-            exchanged_access_token = exchange_payload["access_token"]
-
-        # Determine whether the organization still requires additional
-        # setup (e.g., adding a payment method) based on the ID-token
-        # claim provided by the auth service.
-        completed_onboarding = token_claims.get("completed_platform_onboarding") == True
-        chatgpt_plan_type = access_claims.get("chatgpt_plan_type")
-        is_org_owner = token_claims.get("is_org_owner") == True
-        needs_setup = not completed_onboarding and is_org_owner
-
-        # Build the success URL on the same host/port as the callback and
-        # include the required query parameters for the front-end page.
-        success_url_query = {
-            "id_token": token_data.id_token,
-            "needs_setup": "true" if needs_setup else "false",
-            "org_id": org_id,
-            "project_id": project_id,
-            "plan_type": chatgpt_plan_type,
-            "platform_url": (
-                "https://platform.openai.com"
-                if self.server.issuer == "https://auth.openai.com"
-                else "https://platform.api.openai.org"
-            ),
-        }
-        success_url = f"{URL_BASE}/success?{urllib.parse.urlencode(success_url_query)}"
-
-        # Attempt to redeem complimentary API credits for eligible ChatGPT
-        # Plus / Pro subscribers. Any errors are logged but do not interrupt
-        # the login flow.
-
-        try:
-            maybe_redeem_credits(
-                issuer=self.server.issuer,
-                client_id=self.server.client_id,
-                id_token=token_data.id_token,
-                refresh_token=token_data.refresh_token,
-                codex_home=self.server.codex_home,
-            )
-        except Exception as exc:  # pragma: no cover  best-effort only
-            eprint(f"Unable to redeem ChatGPT subscriber API credits: {exc}")
-
-        return (exchanged_access_token, success_url)
-
-    def _exchange_code(self, code: str) -> tuple[AuthBundle, str]:
-        """Perform token + token-exchange to obtain an OpenAI API key.
-
-        Returns (AuthBundle, success_url).
-        """
-
-        # 1. Authorization-code -> (id_token, access_token, refresh_token)
-        data = urllib.parse.urlencode(
-            {
-                "grant_type": "authorization_code",
-                "code": code,
-                "redirect_uri": self.server.redirect_uri,
-                "client_id": self.server.client_id,
-                "code_verifier": self.server.pkce.code_verifier,
-            }
-        ).encode()
-
-        token_data: TokenData
-
-        with urllib.request.urlopen(
-            urllib.request.Request(
-                self.server.token_endpoint,
-                data=data,
-                method="POST",
-                headers={"Content-Type": "application/x-www-form-urlencoded"},
-            ),
-            context=CA_CONTEXT,
-        ) as resp:
-            payload = json.loads(resp.read().decode())
-
-            # Extract chatgpt_account_id from id_token
-            id_token_parts = payload["id_token"].split(".")
-            if len(id_token_parts) != 3:
-                raise ValueError("Invalid ID token")
-            id_token_claims = _decode_jwt_segment(id_token_parts[1])
-            auth_claims = id_token_claims.get("https://api.openai.com/auth", {})
-            chatgpt_account_id = auth_claims.get("chatgpt_account_id", "")
-
-            token_data = TokenData(
-                id_token=payload["id_token"],
-                access_token=payload["access_token"],
-                refresh_token=payload["refresh_token"],
-                account_id=chatgpt_account_id,
-            )
-
-        access_token_parts = token_data.access_token.split(".")
-        if len(access_token_parts) != 3:
-            raise ValueError("Invalid access token")
-
-        access_token_claims = _decode_jwt_segment(access_token_parts[1])
-
-        token_claims = id_token_claims.get("https://api.openai.com/auth", {})
-        access_claims = access_token_claims.get("https://api.openai.com/auth", {})
-
-        exchanged_access_token, success_url = self._obtain_api_key(
-            token_claims, access_claims, token_data
-        )
-
-        # Persist refresh_token/id_token for future use (redeem credits etc.)
-        last_refresh_str = (
-            datetime.datetime.now(datetime.timezone.utc)
-            .isoformat()
-            .replace("+00:00", "Z")
-        )
-
-        auth_bundle = AuthBundle(
-            api_key=exchanged_access_token,
-            token_data=token_data,
-            last_refresh=last_refresh_str,
-        )
-
-        return (auth_bundle, success_url or f"{URL_BASE}/success")
-
-    def request_shutdown(self) -> None:
-        # shutdown() must be invoked from another thread to avoid
-        # deadlocking the serve_forever() loop, which is running in this
-        # same thread. A short-lived helper thread does the trick.
-        threading.Thread(target=self.server.shutdown, daemon=True).start()
-
-
-def _write_auth_file(*, auth: AuthBundle, codex_home: str) -> bool:
-    """Persist *api_key* to $CODEX_HOME/auth.json.
-
-    Returns True on success, False otherwise.  Any error is printed to
-    *stderr* so that the Rust layer can surface the problem.
-    """
-    if not os.path.isdir(codex_home):
-        try:
-            os.makedirs(codex_home, exist_ok=True)
-        except Exception as exc:  # pragma: no cover  unlikely
-            eprint(f"ERROR: unable to create CODEX_HOME directory: {exc}")
-            return False
-
-    auth_path = os.path.join(codex_home, "auth.json")
-    auth_json_contents = {
-        "OPENAI_API_KEY": auth.api_key,
-        "tokens": {
-            "id_token": auth.token_data.id_token,
-            "access_token": auth.token_data.access_token,
-            "refresh_token": auth.token_data.refresh_token,
-            "account_id": auth.token_data.account_id,
-        },
-        "last_refresh": auth.last_refresh,
-    }
-    try:
-        with open(auth_path, "w", encoding="utf-8") as fp:
-            if hasattr(os, "fchmod"):  # POSIX-safe
-                os.fchmod(fp.fileno(), 0o600)
-            json.dump(auth_json_contents, fp, indent=2)
-    except Exception as exc:  # pragma: no cover  permissions/filesystem
-        eprint(f"ERROR: unable to write auth file: {exc}")
-        return False
-
-    return True
-
-
-@dataclass
-class PkceCodes:
-    code_verifier: str
-    code_challenge: str
-
-
-class _ApiKeyHTTPServer(http.server.HTTPServer):
-    """HTTPServer with shutdown helper & self-contained OAuth configuration."""
-
-    def __init__(
-        self,
-        server_address: tuple[str, int],
-        request_handler_class: type[http.server.BaseHTTPRequestHandler],
-        *,
-        codex_home: str,
-        client_id: str,
-        verbose: bool = False,
-    ) -> None:
-        super().__init__(server_address, request_handler_class, bind_and_activate=True)
-
-        self.exit_code = 1
-        self.codex_home = codex_home
-        self.verbose: bool = verbose
-
-        self.issuer: str = DEFAULT_ISSUER
-        self.token_endpoint: str = f"{self.issuer}/oauth/token"
-        self.client_id: str = client_id
-        port = server_address[1]
-        self.redirect_uri: str = f"http://localhost:{port}/auth/callback"
-        self.pkce: PkceCodes = _generate_pkce()
-        self.state: str = secrets.token_hex(32)
-
-    def auth_url(self) -> str:
-        """Return fully-formed OpenID authorization URL."""
-        params = {
-            "response_type": "code",
-            "client_id": self.client_id,
-            "redirect_uri": self.redirect_uri,
-            "scope": "openid profile email offline_access",
-            "code_challenge": self.pkce.code_challenge,
-            "code_challenge_method": "S256",
-            "id_token_add_organizations": "true",
-            "codex_cli_simplified_flow": "true",
-            "state": self.state,
-        }
-        return f"{self.issuer}/oauth/authorize?" + urllib.parse.urlencode(params)
-
-
-def maybe_redeem_credits(
-    *,
-    issuer: str,
-    client_id: str,
-    id_token: str | None,
-    refresh_token: str,
-    codex_home: str,
-) -> None:
-    """Attempt to redeem complimentary API credits for ChatGPT subscribers.
-
-    The operation is best-effort: any error results in a warning being printed
-    and the function returning early without raising.
-    """
-    id_claims: Dict[str, Any] | None = parse_id_token_claims(id_token or "")
-
-    # Refresh expired ID token, if possible
-    token_expired = True
-    if id_claims and isinstance(id_claims.get("exp"), int):
-        token_expired = _current_timestamp_ms() >= int(id_claims["exp"]) * 1000
-
-    if token_expired:
-        eprint("Refreshing credentials...")
-        new_refresh_token: str | None = None
-        new_id_token: str | None = None
-
-        try:
-            payload = json.dumps(
-                {
-                    "client_id": client_id,
-                    "grant_type": "refresh_token",
-                    "refresh_token": refresh_token,
-                    "scope": "openid profile email",
-                }
-            ).encode()
-
-            req = urllib.request.Request(
-                url="https://auth.openai.com/oauth/token",
-                data=payload,
-                method="POST",
-                headers={"Content-Type": "application/json"},
-            )
-
-            with urllib.request.urlopen(req, context=CA_CONTEXT) as resp:
-                refresh_data = json.loads(resp.read().decode())
-                new_id_token = refresh_data.get("id_token")
-                new_id_claims = parse_id_token_claims(new_id_token or "")
-                new_refresh_token = refresh_data.get("refresh_token")
-        except Exception as err:
-            eprint("Unable to refresh ID token via token-exchange:", err)
-            return
-
-        if not new_id_token or not new_refresh_token:
-            return
-
-        # Update auth.json with new tokens.
-        try:
-            auth_dir = codex_home
-            auth_path = os.path.join(auth_dir, "auth.json")
-            with open(auth_path, "r", encoding="utf-8") as fp:
-                existing = json.load(fp)
-
-            tokens = existing.setdefault("tokens", {})
-            tokens["id_token"] = new_id_token
-            # Note this does not touch the access_token?
-            tokens["refresh_token"] = new_refresh_token
-            tokens["last_refresh"] = (
-                datetime.datetime.now(datetime.timezone.utc)
-                .isoformat()
-                .replace("+00:00", "Z")
-            )
-
-            with open(auth_path, "w", encoding="utf-8") as fp:
-                if hasattr(os, "fchmod"):
-                    os.fchmod(fp.fileno(), 0o600)
-                json.dump(existing, fp, indent=2)
-        except Exception as err:
-            eprint("Unable to update refresh token in auth file:", err)
-
-        if not new_id_claims:
-            # Still couldn't parse claims.
-            return
-
-        id_token = new_id_token
-        id_claims = new_id_claims
-
-    # Done refreshing credentials: now try to redeem credits.
-    if not id_token:
-        eprint("No ID token available, cannot redeem credits.")
-        return
-
-    auth_claims = id_claims.get("https://api.openai.com/auth", {})
-
-    # Subscription eligibility check (Plus or Pro, >7 days active)
-    sub_start_str = auth_claims.get("chatgpt_subscription_active_start")
-    if isinstance(sub_start_str, str):
-        try:
-            sub_start_ts = datetime.datetime.fromisoformat(sub_start_str.rstrip("Z"))
-            if datetime.datetime.now(
-                datetime.timezone.utc
-            ) - sub_start_ts < datetime.timedelta(days=7):
-                eprint(
-                    "Sorry, your subscription must be active for more than 7 days to redeem credits."
-                )
-                return
-        except ValueError:
-            # Malformed; ignore
-            pass
-
-    completed_onboarding = bool(auth_claims.get("completed_platform_onboarding"))
-    is_org_owner = bool(auth_claims.get("is_org_owner"))
-    needs_setup = not completed_onboarding and is_org_owner
-    plan_type = auth_claims.get("chatgpt_plan_type")
-
-    if needs_setup or plan_type not in {"plus", "pro"}:
-        eprint("Only users with Plus or Pro subscriptions can redeem free API credits.")
-        return
-
-    api_host = (
-        "https://api.openai.com"
-        if issuer == "https://auth.openai.com"
-        else "https://api.openai.org"
-    )
-
-    try:
-        redeem_payload = json.dumps({"id_token": id_token}).encode()
-        req = urllib.request.Request(
-            url=f"{api_host}/v1/billing/redeem_credits",
-            data=redeem_payload,
-            method="POST",
-            headers={"Content-Type": "application/json"},
-        )
-
-        with urllib.request.urlopen(req, context=CA_CONTEXT) as resp:
-            redeem_data = json.loads(resp.read().decode())
-
-        granted = redeem_data.get("granted_chatgpt_subscriber_api_credits", 0)
-        if granted and granted > 0:
-            eprint(
-                f"""Thanks for being a ChatGPT {"Plus" if plan_type == "plus" else "Pro"} subscriber!
-If you haven't already redeemed, you should receive {"$5" if plan_type == "plus" else "$50"} in API credits.
-
-Credits: https://platform.openai.com/settings/organization/billing/credit-grants
-More info: https://help.openai.com/en/articles/11381614""",
-            )
-        else:
-            eprint(
-                f"""It looks like no credits were granted:
-
-{json.dumps(redeem_data, indent=2)}
-
-Credits: https://platform.openai.com/settings/organization/billing/credit-grants
-More info: https://help.openai.com/en/articles/11381614"""
-            )
-    except Exception as err:
-        eprint("Credit redemption request failed:", err)
-
-
-def _generate_pkce() -> PkceCodes:
-    """Generate PKCE *code_verifier* and *code_challenge* (S256)."""
-    code_verifier = secrets.token_hex(64)
-    digest = hashlib.sha256(code_verifier.encode()).digest()
-    code_challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
-    return PkceCodes(code_verifier, code_challenge)
-
-
-def eprint(*args, **kwargs) -> None:
-    print(*args, file=sys.stderr, **kwargs)
-
-
-# Parse ID-token claims (if provided)
-#
-# interface IDTokenClaims {
-#   "exp": number; // specifically, an int
-#   "https://api.openai.com/auth": {
-#     organization_id: string;
-#     project_id: string;
-#     completed_platform_onboarding: boolean;
-#     is_org_owner: boolean;
-#     chatgpt_subscription_active_start: string;
-#     chatgpt_subscription_active_until: string;
-#     chatgpt_plan_type: string;
-#   };
-# }
-def parse_id_token_claims(id_token: str) -> Dict[str, Any] | None:
-    if id_token:
-        parts = id_token.split(".")
-        if len(parts) == 3:
-            return _decode_jwt_segment(parts[1])
-    return None
-
-
-def _decode_jwt_segment(segment: str) -> Dict[str, Any]:
-    """Return the decoded JSON payload from a JWT segment.
-
-    Adds required padding for urlsafe_b64decode.
-    """
-    padded = segment + "=" * (-len(segment) % 4)
-    try:
-        data = base64.urlsafe_b64decode(padded.encode())
-        return json.loads(data.decode())
-    except Exception:
-        return {}
-
-
-def _current_timestamp_ms() -> int:
-    return int(time.time() * 1000)
-
-
-LOGIN_SUCCESS_HTML = """<!DOCTYPE html>
-<html lang="en">
-  <head>
-    <meta charset="utf-8" />
-    <title>Sign into Codex CLI</title>
-    <link rel="icon" href='data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="none" viewBox="0 0 32 32"%3E%3Cpath stroke="%23000" stroke-linecap="round" stroke-width="2.484" d="M22.356 19.797H17.17M9.662 12.29l1.979 3.576a.511.511 0 0 1-.005.504l-1.974 3.409M30.758 16c0 8.15-6.607 14.758-14.758 14.758-8.15 0-14.758-6.607-14.758-14.758C1.242 7.85 7.85 1.242 16 1.242c8.15 0 14.758 6.608 14.758 14.758Z"/%3E%3C/svg%3E' type="image/svg+xml">
-    <style>
-      .container {
-        margin: auto;
-        height: 100%;
-        display: flex;
-        align-items: center;
-        justify-content: center;
-        position: relative;
-        background: white;
-
-        font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
-      }
-      .inner-container {
-        width: 400px;
-        flex-direction: column;
-        justify-content: flex-start;
-        align-items: center;
-        gap: 20px;
-        display: inline-flex;
-      }
-      .content {
-        align-self: stretch;
-        flex-direction: column;
-        justify-content: flex-start;
-        align-items: center;
-        gap: 20px;
-        display: flex;
-        margin-top: 15vh;
-      }
-      .svg-wrapper {
-        position: relative;
-      }
-      .title {
-        text-align: center;
-        color: var(--text-primary, #0D0D0D);
-        font-size: 32px;
-        font-weight: 400;
-        line-height: 40px;
-        word-wrap: break-word;
-      }
-      .setup-box {
-        width: 600px;
-        padding: 16px 20px;
-        background: var(--bg-primary, white);
-        box-shadow: 0px 4px 16px rgba(0, 0, 0, 0.05);
-        border-radius: 16px;
-        outline: 1px var(--border-default, rgba(13, 13, 13, 0.10)) solid;
-        outline-offset: -1px;
-        justify-content: flex-start;
-        align-items: center;
-        gap: 16px;
-        display: inline-flex;
-      }
-      .setup-content {
-        flex: 1 1 0;
-        justify-content: flex-start;
-        align-items: center;
-        gap: 24px;
-        display: flex;
-      }
-      .setup-text {
-        flex: 1 1 0;
-        flex-direction: column;
-        justify-content: flex-start;
-        align-items: flex-start;
-        gap: 4px;
-        display: inline-flex;
-      }
-      .setup-title {
-        align-self: stretch;
-        color: var(--text-primary, #0D0D0D);
-        font-size: 14px;
-        font-weight: 510;
-        line-height: 20px;
-        word-wrap: break-word;
-      }
-      .setup-description {
-        align-self: stretch;
-        color: var(--text-secondary, #5D5D5D);
-        font-size: 14px;
-        font-weight: 400;
-        line-height: 20px;
-        word-wrap: break-word;
-      }
-      .redirect-box {
-        justify-content: flex-start;
-        align-items: center;
-        gap: 8px;
-        display: flex;
-      }
-      .close-button,
-      .redirect-button {
-        height: 28px;
-        padding: 8px 16px;
-        background: var(--interactive-bg-primary-default, #0D0D0D);
-        border-radius: 999px;
-        justify-content: center;
-        align-items: center;
-        gap: 4px;
-        display: flex;
-      }
-      .close-button,
-      .redirect-text {
-        color: var(--interactive-label-primary-default, white);
-        font-size: 14px;
-        font-weight: 510;
-        line-height: 20px;
-        word-wrap: break-word;
-        text-decoration: none;
-      }
-      .logo {
-        display: flex;
-        align-items: center;
-        justify-content: center;
-        width: 4rem;
-        height: 4rem;
-        border-radius: 16px;
-        border: .5px solid rgba(0, 0, 0, 0.1);
-        box-shadow: rgba(0, 0, 0, 0.1) 0px 4px 16px 0px;
-        box-sizing: border-box;
-        background-color: rgb(255, 255, 255);
-      }
-    </style>
-  </head>
-  <body>
-    <div class="container">
-      <div class="inner-container">
-        <div class="content">
-          <div class="logo">
-            <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="none" viewBox="0 0 32 32"><path stroke="#000" stroke-linecap="round" stroke-width="2.484" d="M22.356 19.797H17.17M9.662 12.29l1.979 3.576a.511.511 0 0 1-.005.504l-1.974 3.409M30.758 16c0 8.15-6.607 14.758-14.758 14.758-8.15 0-14.758-6.607-14.758-14.758C1.242 7.85 7.85 1.242 16 1.242c8.15 0 14.758 6.608 14.758 14.758Z"></path></svg>
-          </div>
-          <div class="title">Signed in to Codex CLI</div>
-        </div>
-        <div class="close-box" style="display: none;">
-          <div class="setup-description">You may now close this page</div>
-        </div>
-        <div class="setup-box" style="display: none;">
-          <div class="setup-content">
-            <div class="setup-text">
-              <div class="setup-title">Finish setting up your API organization</div>
-              <div class="setup-description">Add a payment method to use your organization.</div>
-            </div>
-            <div class="redirect-box">
-              <div data-hasendicon="false" data-hasstarticon="false" data-ishovered="false" data-isinactive="false" data-ispressed="false" data-size="large" data-type="primary" class="redirect-button">
-                <div class="redirect-text">Redirecting in 3s...</div>
-              </div>
-            </div>
-          </div>
-        </div>
-      </div>
-    </div>
-    <script>
-      (function () {
-        const params = new URLSearchParams(window.location.search);
-        const needsSetup = params.get('needs_setup') === 'true';
-        const platformUrl = params.get('platform_url') || 'https://platform.openai.com';
-        const orgId = params.get('org_id');
-        const projectId = params.get('project_id');
-        const planType = params.get('plan_type');
-        const idToken = params.get('id_token');
-        // Show different message and optional redirect when setup is required
-        if (needsSetup) {
-          const setupBox = document.querySelector('.setup-box');
-          setupBox.style.display = 'flex';
-          const redirectUrlObj = new URL('/org-setup', platformUrl);
-          redirectUrlObj.searchParams.set('p', planType);
-          redirectUrlObj.searchParams.set('t', idToken);
-          redirectUrlObj.searchParams.set('with_org', orgId);
-          redirectUrlObj.searchParams.set('project_id', projectId);
-          const redirectUrl = redirectUrlObj.toString();
-          const message = document.querySelector('.redirect-text');
-          let countdown = 3;
-          function tick() {
-            message.textContent =
-              'Redirecting in ' + countdown + 's…';
-            if (countdown === 0) {
-              window.location.replace(redirectUrl);
-            } else {
-              countdown -= 1;
-              setTimeout(tick, 1000);
-            }
-          }
-          tick();
-        } else {
-          const closeBox = document.querySelector('.close-box');
-          closeBox.style.display = 'flex';
-        }
-      })();
-    </script>
-  </body>
-</html>"""
-
-# Unconditionally call `main()` instead of gating it behind
-# `if __name__ == "__main__"` because this script is either:
-#
-# - invoked as a string passed to `python3 -c`
-# - run via `python3 login_with_chatgpt.py` for testing as part of local
-#   development
-main()
diff --git a/codex-rs/login/src/pkce.rs b/codex-rs/login/src/pkce.rs
new file mode 100644
index 0000000000..3c413b11f1
--- /dev/null
+++ b/codex-rs/login/src/pkce.rs
@@ -0,0 +1,27 @@
+use base64::Engine;
+use rand::RngCore;
+use sha2::Digest;
+use sha2::Sha256;
+
+#[derive(Debug, Clone)]
+pub struct PkceCodes {
+    pub code_verifier: String,
+    pub code_challenge: String,
+}
+
+pub fn generate_pkce() -> PkceCodes {
+    let mut bytes = [0u8; 64];
+    rand::thread_rng().fill_bytes(&mut bytes);
+
+    // Verifier: URL-safe base64 without padding (43..128 chars)
+    let code_verifier = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes);
+
+    // Challenge (S256): BASE64URL-ENCODE(SHA256(verifier)) without padding
+    let digest = Sha256::digest(code_verifier.as_bytes());
+    let code_challenge = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest);
+
+    PkceCodes {
+        code_verifier,
+        code_challenge,
+    }
+}
diff --git a/codex-rs/login/src/server.rs b/codex-rs/login/src/server.rs
new file mode 100644
index 0000000000..550ee703a2
--- /dev/null
+++ b/codex-rs/login/src/server.rs
@@ -0,0 +1,443 @@
+use std::io::{self};
+use std::path::Path;
+use std::sync::Arc;
+use std::sync::atomic::AtomicBool;
+use std::sync::atomic::Ordering;
+
+use base64::Engine;
+use chrono::Utc;
+use rand::RngCore;
+use tiny_http::Response;
+use tiny_http::Server;
+
+use crate::AuthDotJson;
+use crate::get_auth_file;
+use crate::pkce::PkceCodes;
+use crate::pkce::generate_pkce;
+
+const DEFAULT_ISSUER: &str = "https://auth.openai.com";
+const DEFAULT_PORT: u16 = 1455;
+
+#[derive(Debug, Clone)]
+pub struct ServerOptions<'a> {
+    pub codex_home: &'a Path,
+    pub client_id: &'a str,
+    pub issuer: &'a str,
+    pub port: u16,
+    pub open_browser: bool,
+    pub force_state: Option<String>,
+}
+
+impl<'a> ServerOptions<'a> {
+    pub fn new(codex_home: &'a Path, client_id: &'a str) -> Self {
+        Self {
+            codex_home,
+            client_id,
+            issuer: DEFAULT_ISSUER,
+            port: DEFAULT_PORT,
+            open_browser: true,
+            force_state: None,
+        }
+    }
+}
+
+#[allow(dead_code)]
+pub fn run_server_blocking(opts: ServerOptions) -> io::Result<()> {
+    run_server_blocking_with_notify(opts, None, None)
+}
+
+pub struct LoginServerInfo {
+    pub auth_url: String,
+    pub actual_port: u16,
+}
+
+pub fn run_server_blocking_with_notify(
+    opts: ServerOptions,
+    notify_started: Option<std::sync::mpsc::Sender<LoginServerInfo>>,
+    shutdown_flag: Option<Arc<AtomicBool>>,
+) -> io::Result<()> {
+    let pkce = generate_pkce();
+    let state = opts.force_state.clone().unwrap_or_else(generate_state);
+
+    let server = Server::http(format!("127.0.0.1:{}", opts.port)).map_err(io::Error::other)?;
+    let actual_port = match server.server_addr().to_ip() {
+        Some(addr) => addr.port(),
+        None => {
+            return Err(io::Error::new(
+                io::ErrorKind::AddrInUse,
+                "Unable to determine the server port",
+            ));
+        }
+    };
+
+    let redirect_uri = format!("http://localhost:{actual_port}/auth/callback");
+    let auth_url = build_authorize_url(opts.issuer, opts.client_id, &redirect_uri, &pkce, &state);
+
+    if let Some(tx) = &notify_started {
+        let _ = tx.send(LoginServerInfo {
+            auth_url: auth_url.clone(),
+            actual_port,
+        });
+    }
+
+    if opts.open_browser {
+        let _ = webbrowser::open(&auth_url);
+    }
+
+    let shutdown_flag = shutdown_flag.unwrap_or_else(|| Arc::new(AtomicBool::new(false)));
+    while !shutdown_flag.load(Ordering::SeqCst) {
+        let req = match server.recv() {
+            Ok(r) => r,
+            Err(e) => return Err(io::Error::other(e)),
+        };
+
+        let url_raw = req.url().to_string();
+        let parsed_url = match url::Url::parse(&format!("http://localhost{url_raw}")) {
+            Ok(u) => u,
+            Err(e) => {
+                eprintln!("URL parse error: {e}");
+                let _ = req.respond(Response::from_string("Bad Request").with_status_code(400));
+                continue;
+            }
+        };
+        let path = parsed_url.path().to_string();
+
+        match path.as_str() {
+            "/auth/callback" => {
+                let params: std::collections::HashMap<String, String> =
+                    parsed_url.query_pairs().into_owned().collect();
+                if params.get("state").map(String::as_str) != Some(state.as_str()) {
+                    let _ =
+                        req.respond(Response::from_string("State mismatch").with_status_code(400));
+                    continue;
+                }
+                let code = match params.get("code") {
+                    Some(c) if !c.is_empty() => c.clone(),
+                    _ => {
+                        let _ = req.respond(
+                            Response::from_string("Missing authorization code")
+                                .with_status_code(400),
+                        );
+                        continue;
+                    }
+                };
+
+                match exchange_code_for_tokens(
+                    opts.issuer,
+                    opts.client_id,
+                    &redirect_uri,
+                    &pkce,
+                    &code,
+                ) {
+                    Ok(tokens) => {
+                        // Obtain API key via token-exchange and persist
+                        let api_key =
+                            obtain_api_key(opts.issuer, opts.client_id, &tokens.id_token).ok();
+                        if let Err(err) = persist_tokens(
+                            opts.codex_home,
+                            api_key.clone(),
+                            tokens.id_token.clone(),
+                            Some(tokens.access_token.clone()),
+                            Some(tokens.refresh_token.clone()),
+                        ) {
+                            eprintln!("Persist error: {err}");
+                            let _ = req.respond(
+                                Response::from_string(format!(
+                                    "Unable to persist auth file: {err}"
+                                ))
+                                .with_status_code(500),
+                            );
+                            continue;
+                        }
+
+                        let success_url = compose_success_url(
+                            actual_port,
+                            opts.issuer,
+                            &tokens.id_token,
+                            &tokens.access_token,
+                        );
+                        match tiny_http::Header::from_bytes(
+                            &b"Location"[..],
+                            success_url.as_bytes(),
+                        ) {
+                            Ok(h) => {
+                                let response = tiny_http::Response::empty(302).with_header(h);
+                                let _ = req.respond(response);
+                            }
+                            Err(_) => {
+                                let _ = req.respond(
+                                    Response::from_string("Internal Server Error")
+                                        .with_status_code(500),
+                                );
+                            }
+                        }
+                    }
+                    Err(err) => {
+                        eprintln!("Token exchange error: {err}");
+                        let _ = req.respond(
+                            Response::from_string(format!("Token exchange failed: {err}"))
+                                .with_status_code(500),
+                        );
+                    }
+                }
+            }
+            "/success" => {
+                let body = include_str!("assets/success.html");
+                let mut resp = Response::from_data(body.as_bytes());
+                if let Ok(h) = tiny_http::Header::from_bytes(
+                    &b"Content-Type"[..],
+                    &b"text/html; charset=utf-8"[..],
+                ) {
+                    resp.add_header(h);
+                }
+                let _ = req.respond(resp);
+                shutdown_flag.store(true, Ordering::SeqCst);
+            }
+            _ => {
+                let _ = req.respond(Response::from_string("Not Found").with_status_code(404));
+            }
+        }
+    }
+
+    Ok(())
+}
+
+fn build_authorize_url(
+    issuer: &str,
+    client_id: &str,
+    redirect_uri: &str,
+    pkce: &PkceCodes,
+    state: &str,
+) -> String {
+    let query = vec![
+        ("response_type", "code"),
+        ("client_id", client_id),
+        ("redirect_uri", redirect_uri),
+        ("scope", "openid profile email offline_access"),
+        ("code_challenge", &pkce.code_challenge),
+        ("code_challenge_method", "S256"),
+        ("id_token_add_organizations", "true"),
+        ("codex_cli_simplified_flow", "true"),
+        ("state", state),
+    ];
+    let qs = query
+        .into_iter()
+        .map(|(k, v)| format!("{}={}", k, urlencoding::encode(v)))
+        .collect::<Vec<_>>()
+        .join("&");
+    format!("{issuer}/oauth/authorize?{qs}")
+}
+
+fn generate_state() -> String {
+    let mut bytes = [0u8; 32];
+    rand::thread_rng().fill_bytes(&mut bytes);
+    base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
+}
+
+struct ExchangedTokens {
+    id_token: String,
+    access_token: String,
+    refresh_token: String,
+}
+
+fn exchange_code_for_tokens(
+    issuer: &str,
+    client_id: &str,
+    redirect_uri: &str,
+    pkce: &PkceCodes,
+    code: &str,
+) -> io::Result<ExchangedTokens> {
+    #[derive(serde::Deserialize)]
+    struct TokenResponse {
+        id_token: String,
+        access_token: String,
+        refresh_token: String,
+    }
+
+    let client = reqwest::blocking::Client::new();
+    let resp = client
+        .post(format!("{issuer}/oauth/token"))
+        .header("Content-Type", "application/x-www-form-urlencoded")
+        .body(format!(
+            "grant_type=authorization_code&code={}&redirect_uri={}&client_id={}&code_verifier={}",
+            urlencoding::encode(code),
+            urlencoding::encode(redirect_uri),
+            urlencoding::encode(client_id),
+            urlencoding::encode(&pkce.code_verifier)
+        ))
+        .send()
+        .map_err(io::Error::other)?;
+
+    if !resp.status().is_success() {
+        return Err(io::Error::other(format!(
+            "token endpoint returned status {}",
+            resp.status()
+        )));
+    }
+
+    let tokens: TokenResponse = resp.json().map_err(io::Error::other)?;
+    Ok(ExchangedTokens {
+        id_token: tokens.id_token,
+        access_token: tokens.access_token,
+        refresh_token: tokens.refresh_token,
+    })
+}
+
+fn persist_tokens(
+    codex_home: &Path,
+    api_key: Option<String>,
+    id_token: String,
+    access_token: Option<String>,
+    refresh_token: Option<String>,
+) -> io::Result<()> {
+    let auth_file = get_auth_file(codex_home);
+    if let Some(parent) = auth_file.parent() {
+        if !parent.exists() {
+            std::fs::create_dir_all(parent).map_err(io::Error::other)?;
+        }
+    }
+
+    let mut auth = read_or_default(&auth_file);
+    if let Some(key) = api_key {
+        auth.openai_api_key = Some(key);
+    }
+    let tokens = auth
+        .tokens
+        .get_or_insert_with(crate::token_data::TokenData::default);
+    tokens.id_token = crate::token_data::parse_id_token(&id_token).map_err(io::Error::other)?;
+    // Persist chatgpt_account_id if present in claims
+    if let Some(acc) = jwt_auth_claims(&id_token)
+        .get("chatgpt_account_id")
+        .and_then(|v| v.as_str())
+    {
+        tokens.account_id = Some(acc.to_string());
+    }
+    if let Some(at) = access_token {
+        tokens.access_token = at;
+    }
+    if let Some(rt) = refresh_token {
+        tokens.refresh_token = rt;
+    }
+    auth.last_refresh = Some(Utc::now());
+    super::write_auth_json(&auth_file, &auth)
+}
+
+fn read_or_default(path: &Path) -> AuthDotJson {
+    match super::try_read_auth_json(path) {
+        Ok(auth) => auth,
+        Err(_) => AuthDotJson {
+            openai_api_key: None,
+            tokens: None,
+            last_refresh: None,
+        },
+    }
+}
+
+fn compose_success_url(port: u16, issuer: &str, id_token: &str, access_token: &str) -> String {
+    let token_claims = jwt_auth_claims(id_token);
+    let access_claims = jwt_auth_claims(access_token);
+
+    let org_id = token_claims
+        .get("organization_id")
+        .and_then(|v| v.as_str())
+        .unwrap_or("");
+    let project_id = token_claims
+        .get("project_id")
+        .and_then(|v| v.as_str())
+        .unwrap_or("");
+    let completed_onboarding = token_claims
+        .get("completed_platform_onboarding")
+        .and_then(|v| v.as_bool())
+        .unwrap_or(false);
+    let is_org_owner = token_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_claims
+        .get("chatgpt_plan_type")
+        .and_then(|v| v.as_str())
+        .unwrap_or("");
+
+    let platform_url = if issuer == DEFAULT_ISSUER {
+        "https://platform.openai.com"
+    } else {
+        "https://platform.api.openai.org"
+    };
+
+    let mut params = vec![
+        ("id_token", id_token.to_string()),
+        ("needs_setup", needs_setup.to_string()),
+        ("org_id", org_id.to_string()),
+        ("project_id", project_id.to_string()),
+        ("plan_type", plan_type.to_string()),
+        ("platform_url", platform_url.to_string()),
+    ];
+    let qs = params
+        .drain(..)
+        .map(|(k, v)| format!("{}={}", k, urlencoding::encode(&v)))
+        .collect::<Vec<_>>()
+        .join("&");
+    format!("http://localhost:{port}/success?{qs}")
+}
+
+fn jwt_auth_claims(jwt: &str) -> serde_json::Map<String, serde_json::Value> {
+    let mut parts = jwt.split('.');
+    let (_h, payload_b64, _s) = 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),
+        _ => {
+            eprintln!("Invalid JWT format while extracting claims");
+            return serde_json::Map::new();
+        }
+    };
+    match base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(payload_b64) {
+        Ok(bytes) => match serde_json::from_slice::<serde_json::Value>(&bytes) {
+            Ok(mut v) => {
+                if let Some(obj) = v
+                    .get_mut("https://api.openai.com/auth")
+                    .and_then(|x| x.as_object_mut())
+                {
+                    return obj.clone();
+                }
+                eprintln!("JWT payload missing expected 'https://api.openai.com/auth' object");
+            }
+            Err(e) => {
+                eprintln!("Failed to parse JWT JSON payload: {e}");
+            }
+        },
+        Err(e) => {
+            eprintln!("Failed to base64url-decode JWT payload: {e}");
+        }
+    }
+    serde_json::Map::new()
+}
+
+fn obtain_api_key(issuer: &str, client_id: &str, id_token: &str) -> io::Result<String> {
+    // Token exchange for an API key access token
+    #[derive(serde::Deserialize)]
+    struct ExchangeResp {
+        access_token: String,
+    }
+    let client = reqwest::blocking::Client::new();
+    let resp = client
+        .post(format!("{issuer}/oauth/token"))
+        .header("Content-Type", "application/x-www-form-urlencoded")
+        .body(format!(
+            "grant_type={}&client_id={}&requested_token={}&subject_token={}&subject_token_type={}",
+            urlencoding::encode("urn:ietf:params:oauth:grant-type:token-exchange"),
+            urlencoding::encode(client_id),
+            urlencoding::encode("openai-api-key"),
+            urlencoding::encode(id_token),
+            urlencoding::encode("urn:ietf:params:oauth:token-type:id_token")
+        ))
+        .send()
+        .map_err(io::Error::other)?;
+    if !resp.status().is_success() {
+        return Err(io::Error::other(format!(
+            "api key exchange failed with status {}",
+            resp.status()
+        )));
+    }
+    let body: ExchangeResp = resp.json().map_err(io::Error::other)?;
+    Ok(body.access_token)
+}
diff --git a/codex-rs/login/src/token_data.rs b/codex-rs/login/src/token_data.rs
index fb4d83950f..1cb537fa24 100644
--- a/codex-rs/login/src/token_data.rs
+++ b/codex-rs/login/src/token_data.rs
@@ -6,7 +6,10 @@ use thiserror::Error;
 #[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Default)]
 pub struct TokenData {
     /// Flat info parsed from the JWT in auth.json.
-    #[serde(deserialize_with = "deserialize_id_token")]
+    #[serde(
+        deserialize_with = "deserialize_id_token",
+        serialize_with = "serialize_id_token"
+    )]
     pub id_token: IdTokenInfo,
 
     /// This is a JWT.
@@ -29,13 +32,14 @@ impl TokenData {
 }
 
 /// Flat subset of useful claims in id_token from auth.json.
-#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize)]
+#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
 pub struct IdTokenInfo {
     pub email: Option<String>,
     /// The ChatGPT subscription plan type
     /// (e.g., "free", "plus", "pro", "business", "enterprise", "edu").
     /// (Note: ae has not verified that those are the exact values.)
     pub(crate) chatgpt_plan_type: Option<PlanType>,
+    pub raw_jwt: String,
 }
 
 impl IdTokenInfo {
@@ -126,6 +130,7 @@ pub(crate) fn parse_id_token(id_token: &str) -> Result<IdTokenInfo, IdTokenInfoE
     Ok(IdTokenInfo {
         email: claims.email,
         chatgpt_plan_type: claims.auth.and_then(|a| a.chatgpt_plan_type),
+        raw_jwt: id_token.to_string(),
     })
 }
 
@@ -137,6 +142,13 @@ where
     parse_id_token(&s).map_err(serde::de::Error::custom)
 }
 
+fn serialize_id_token<S>(id_token: &IdTokenInfo, serializer: S) -> Result<S::Ok, S::Error>
+where
+    S: serde::Serializer,
+{
+    serializer.serialize_str(&id_token.raw_jwt)
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
@@ -145,7 +157,6 @@ mod tests {
     #[test]
     #[expect(clippy::expect_used, clippy::unwrap_used)]
     fn id_token_info_parses_email_and_plan() {
-        // Build a fake JWT with a URL-safe base64 payload containing email and plan.
         #[derive(Serialize)]
         struct Header {
             alg: &'static str,
diff --git a/codex-rs/login/tests/login_server_e2e.rs b/codex-rs/login/tests/login_server_e2e.rs
new file mode 100644
index 0000000000..3fe0e32041
--- /dev/null
+++ b/codex-rs/login/tests/login_server_e2e.rs
@@ -0,0 +1,192 @@
+#![allow(clippy::unwrap_used)]
+use std::net::SocketAddr;
+use std::net::TcpListener;
+use std::thread;
+
+use base64::Engine;
+use codex_login::LoginServerInfo;
+use codex_login::ServerOptions;
+use codex_login::run_server_blocking_with_notify;
+use tempfile::tempdir;
+
+// See spawn.rs for details
+pub const CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR: &str = "CODEX_SANDBOX_NETWORK_DISABLED";
+
+fn start_mock_issuer() -> (SocketAddr, thread::JoinHandle<()>) {
+    // Bind to a random available port
+    let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
+    let addr = listener.local_addr().unwrap();
+    let server = tiny_http::Server::from_listener(listener, None).unwrap();
+
+    let handle = thread::spawn(move || {
+        while let Ok(mut req) = server.recv() {
+            let url = req.url().to_string();
+            if url.starts_with("/oauth/token") {
+                // Read body
+                let mut body = String::new();
+                let _ = req.as_reader().read_to_string(&mut body);
+                // Build minimal JWT with plan=pro
+                #[derive(serde::Serialize)]
+                struct Header {
+                    alg: &'static str,
+                    typ: &'static str,
+                }
+                let header = Header {
+                    alg: "none",
+                    typ: "JWT",
+                };
+                let payload = serde_json::json!({
+                    "email": "user@example.com",
+                    "https://api.openai.com/auth": {
+                        "chatgpt_plan_type": "pro",
+                        "chatgpt_account_id": "acc-123"
+                    }
+                });
+                let b64 = |b: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b);
+                let header_bytes = serde_json::to_vec(&header).unwrap();
+                let payload_bytes = serde_json::to_vec(&payload).unwrap();
+                let id_token = format!(
+                    "{}.{}.{}",
+                    b64(&header_bytes),
+                    b64(&payload_bytes),
+                    b64(b"sig")
+                );
+
+                let tokens = serde_json::json!({
+                    "id_token": id_token,
+                    "access_token": "access-123",
+                    "refresh_token": "refresh-123",
+                });
+                let data = serde_json::to_vec(&tokens).unwrap();
+                let mut resp = tiny_http::Response::from_data(data);
+                resp.add_header(
+                    tiny_http::Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..])
+                        .unwrap_or_else(|_| panic!("header bytes")),
+                );
+                let _ = req.respond(resp);
+            } else {
+                let _ = req
+                    .respond(tiny_http::Response::from_string("not found").with_status_code(404));
+            }
+        }
+    });
+
+    (addr, handle)
+}
+
+#[test]
+fn end_to_end_login_flow_persists_auth_json() {
+    if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() {
+        println!(
+            "Skipping test because it cannot execute when network is disabled in a Codex sandbox."
+        );
+        return;
+    }
+
+    let (issuer_addr, issuer_handle) = start_mock_issuer();
+    let issuer = format!("http://{}:{}", issuer_addr.ip(), issuer_addr.port());
+
+    let tmp = tempdir().unwrap();
+    let codex_home = tmp.path().to_path_buf();
+
+    let state = "test_state_123".to_string();
+
+    // Run server in background
+    let server_home = codex_home.clone();
+
+    let (tx, rx) = std::sync::mpsc::channel::<LoginServerInfo>();
+    let server_thread = thread::spawn(move || {
+        let opts = ServerOptions {
+            codex_home: &server_home,
+            client_id: codex_login::CLIENT_ID,
+            issuer: &issuer,
+            port: 0,
+            open_browser: false,
+            force_state: Some(state),
+        };
+        run_server_blocking_with_notify(opts, Some(tx), None).unwrap();
+    });
+
+    let server_info = rx.recv().unwrap();
+    let login_port = server_info.actual_port;
+
+    // Simulate browser callback, and follow redirect to /success
+    let client = reqwest::blocking::Client::builder()
+        .redirect(reqwest::redirect::Policy::limited(5))
+        .build()
+        .unwrap();
+    let url = format!("http://127.0.0.1:{login_port}/auth/callback?code=abc&state=test_state_123");
+    let resp = client.get(&url).send().unwrap();
+    assert!(resp.status().is_success());
+
+    // Wait for server shutdown
+    server_thread
+        .join()
+        .unwrap_or_else(|_| panic!("server thread panicked"));
+
+    // Validate auth.json
+    let auth_path = codex_home.join("auth.json");
+    let data = std::fs::read_to_string(&auth_path).unwrap();
+    let json: serde_json::Value = serde_json::from_str(&data).unwrap();
+    assert!(
+        !json["OPENAI_API_KEY"].is_null(),
+        "OPENAI_API_KEY should be set"
+    );
+    assert_eq!(json["tokens"]["access_token"], "access-123");
+    assert_eq!(json["tokens"]["refresh_token"], "refresh-123");
+    assert_eq!(json["tokens"]["account_id"], "acc-123");
+
+    // Stop mock issuer
+    drop(issuer_handle);
+}
+
+#[test]
+fn creates_missing_codex_home_dir() {
+    if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() {
+        println!(
+            "Skipping test because it cannot execute when network is disabled in a Codex sandbox."
+        );
+        return;
+    }
+
+    let (issuer_addr, _issuer_handle) = start_mock_issuer();
+    let issuer = format!("http://{}:{}", issuer_addr.ip(), issuer_addr.port());
+
+    let tmp = tempdir().unwrap();
+    let codex_home = tmp.path().join("missing-subdir"); // does not exist
+
+    let state = "state2".to_string();
+
+    // Run server in background
+    let server_home = codex_home.clone();
+    let (tx, rx) = std::sync::mpsc::channel::<LoginServerInfo>();
+    let server_thread = thread::spawn(move || {
+        let opts = ServerOptions {
+            codex_home: &server_home,
+            client_id: codex_login::CLIENT_ID,
+            issuer: &issuer,
+            port: 0,
+            open_browser: false,
+            force_state: Some(state),
+        };
+        run_server_blocking_with_notify(opts, Some(tx), None).unwrap()
+    });
+
+    let server_info = rx.recv().unwrap();
+    let login_port = server_info.actual_port;
+
+    let client = reqwest::blocking::Client::new();
+    let url = format!("http://127.0.0.1:{login_port}/auth/callback?code=abc&state=state2");
+    let resp = client.get(&url).send().unwrap();
+    assert!(resp.status().is_success());
+
+    server_thread
+        .join()
+        .unwrap_or_else(|_| panic!("server thread panicked"));
+
+    let auth_path = codex_home.join("auth.json");
+    assert!(
+        auth_path.exists(),
+        "auth.json should be created even if parent dir was missing"
+    );
+}
diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs
index fb45ecfd19..d6c2e8bf2f 100644
--- a/codex-rs/tui/src/app.rs
+++ b/codex-rs/tui/src/app.rs
@@ -506,6 +506,10 @@ impl App<'_> {
     }
 
     fn draw_next_frame(&mut self, terminal: &mut tui::Tui) -> Result<()> {
+        if matches!(self.app_state, AppState::Onboarding { .. }) {
+            terminal.clear()?;
+        }
+
         let screen_size = terminal.size()?;
         let last_known_screen_size = terminal.last_known_screen_size;
         if screen_size != last_known_screen_size {
diff --git a/codex-rs/tui/src/onboarding/auth.rs b/codex-rs/tui/src/onboarding/auth.rs
index 6276552849..0e91f37685 100644
--- a/codex-rs/tui/src/onboarding/auth.rs
+++ b/codex-rs/tui/src/onboarding/auth.rs
@@ -46,11 +46,7 @@ pub(crate) struct ContinueInBrowserState {
 impl Drop for ContinueInBrowserState {
     fn drop(&mut self) {
         if let Some(child) = &self.login_child {
-            if let Ok(mut locked) = child.child.lock() {
-                // Best-effort terminate and reap the child to avoid zombies.
-                let _ = locked.kill();
-                let _ = locked.wait();
-            }
+            child.cancel();
         }
     }
 }
@@ -320,32 +316,16 @@ impl AuthModeWidget {
     }
 
     fn spawn_completion_poller(&self, child: codex_login::SpawnedLogin) {
-        let child_arc = child.child.clone();
-        let stderr_buf = child.stderr.clone();
         let event_tx = self.event_tx.clone();
         std::thread::spawn(move || {
             loop {
-                let done = {
-                    if let Ok(mut locked) = child_arc.lock() {
-                        match locked.try_wait() {
-                            Ok(Some(status)) => Some(status.success()),
-                            Ok(None) => None,
-                            Err(_) => Some(false),
-                        }
-                    } else {
-                        Some(false)
-                    }
-                };
-                if let Some(success) = done {
+                if let Some(success) = child.get_auth_result() {
                     if success {
                         event_tx.send(AppEvent::OnboardingAuthComplete(Ok(())));
                     } else {
-                        let err = stderr_buf
-                            .lock()
-                            .ok()
-                            .and_then(|b| String::from_utf8(b.clone()).ok())
-                            .unwrap_or_else(|| "login_with_chatgpt subprocess failed".to_string());
-                        event_tx.send(AppEvent::OnboardingAuthComplete(Err(err)));
+                        event_tx.send(AppEvent::OnboardingAuthComplete(Err(
+                            "login failed".to_string()
+                        )));
                     }
                     break;
                 }
diff --git a/codex-rs/tui/src/onboarding/onboarding_screen.rs b/codex-rs/tui/src/onboarding/onboarding_screen.rs
index a104f777c2..a481c8c768 100644
--- a/codex-rs/tui/src/onboarding/onboarding_screen.rs
+++ b/codex-rs/tui/src/onboarding/onboarding_screen.rs
@@ -2,6 +2,8 @@ use codex_core::util::is_inside_git_repo;
 use crossterm::event::KeyEvent;
 use ratatui::buffer::Buffer;
 use ratatui::layout::Rect;
+use ratatui::prelude::Widget;
+use ratatui::widgets::Clear;
 use ratatui::widgets::WidgetRef;
 
 use codex_login::AuthMode;
@@ -113,6 +115,14 @@ impl OnboardingScreen {
                 Ok(()) => {
                     state.sign_in_state = SignInState::ChatGptSuccessMessage;
                     self.event_tx.send(AppEvent::RequestRedraw);
+                    let tx1 = self.event_tx.clone();
+                    let tx2 = self.event_tx.clone();
+                    std::thread::spawn(move || {
+                        std::thread::sleep(std::time::Duration::from_millis(150));
+                        tx1.send(AppEvent::RequestRedraw);
+                        std::thread::sleep(std::time::Duration::from_millis(200));
+                        tx2.send(AppEvent::RequestRedraw);
+                    });
                 }
                 Err(e) => {
                     state.sign_in_state = SignInState::PickMode;
@@ -171,6 +181,7 @@ impl KeyboardHandler for OnboardingScreen {
 
 impl WidgetRef for &OnboardingScreen {
     fn render_ref(&self, area: Rect, buf: &mut Buffer) {
+        Clear.render(area, buf);
         // Render steps top-to-bottom, measuring each step's height dynamically.
         let mut y = area.y;
         let bottom = area.y.saturating_add(area.height);
@@ -218,6 +229,7 @@ impl WidgetRef for &OnboardingScreen {
                     width,
                     height: h,
                 };
+                Clear.render(target, buf);
                 step.render_ref(target, buf);
                 y = y.saturating_add(h);
             }

Review Comments

codex-rs/cli/src/login.rs

@@ -1,20 +1,55 @@
-use std::env;
-
 use codex_common::CliConfigOverrides;
 use codex_core::config::Config;
 use codex_core::config::ConfigOverrides;
 use codex_login::AuthMode;
+use codex_login::CLIENT_ID;
 use codex_login::CodexAuth;
+use codex_login::LoginServerInfo;
 use codex_login::OPENAI_API_KEY_ENV_VAR;
+use codex_login::ServerOptions;
 use codex_login::login_with_api_key;
-use codex_login::login_with_chatgpt;
 use codex_login::logout;
+use codex_login::run_server_blocking_with_notify;
+use std::env;
+use std::path::Path;
+use std::sync::mpsc;
+
+pub async fn login_with_chatgpt(codex_home: &Path) -> std::io::Result<()> {
+    let (tx, rx) = mpsc::channel::<LoginServerInfo>();
+    let client_id = CLIENT_ID;
+    let codex_home = codex_home.to_path_buf();
+    let url_printer = tokio::spawn(async move {
+        match rx.recv() {
+            Ok(LoginServerInfo {
+                auth_url,
+                actual_port,
+            }) => {
+                eprintln!(
+                    "Starting local login server on http://localhost:{actual_port}.\nIf your browser did not open, navigate to this URL to authenticate:\n\n{auth_url}",
+                );
+            }
+            _ => {
+                tracing::error!("Failed to receive login server info");
+            }
+        }
+    });
+
+    tokio::task::spawn_blocking(move || {
+        let opts = ServerOptions::new(&codex_home, client_id);
+        run_server_blocking_with_notify(opts, Some(tx), None)
+    })
+    .await
+    .map_err(std::io::Error::other)??;
+
+    eprintln!("Successfully logged in");
+    drop(url_printer);

Why the explicit drop()?

@@ -1,20 +1,55 @@
-use std::env;
-
 use codex_common::CliConfigOverrides;
 use codex_core::config::Config;
 use codex_core::config::ConfigOverrides;
 use codex_login::AuthMode;
+use codex_login::CLIENT_ID;
 use codex_login::CodexAuth;
+use codex_login::LoginServerInfo;
 use codex_login::OPENAI_API_KEY_ENV_VAR;
+use codex_login::ServerOptions;
 use codex_login::login_with_api_key;
-use codex_login::login_with_chatgpt;
 use codex_login::logout;
+use codex_login::run_server_blocking_with_notify;
+use std::env;
+use std::path::Path;
+use std::sync::mpsc;
+
+pub async fn login_with_chatgpt(codex_home: &Path) -> std::io::Result<()> {
+    let (tx, rx) = mpsc::channel::<LoginServerInfo>();
+    let client_id = CLIENT_ID;
+    let codex_home = codex_home.to_path_buf();
+    let url_printer = tokio::spawn(async move {
+        match rx.recv() {
+            Ok(LoginServerInfo {
+                auth_url,
+                actual_port,
+            }) => {
+                eprintln!(
+                    "Starting local login server on http://localhost:{actual_port}.\nIf your browser did not open, navigate to this URL to authenticate:\n\n{auth_url}",
+                );
+            }
+            _ => {
+                tracing::error!("Failed to receive login server info");
+            }
+        }
+    });
+
+    tokio::task::spawn_blocking(move || {
+        let opts = ServerOptions::new(&codex_home, client_id);
+        run_server_blocking_with_notify(opts, Some(tx), None)

I expected this function to share more logic from the codex-login crate directly, but I guess this is the main thing?

codex-rs/login/src/assets/success.html

@@ -0,0 +1,199 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="utf-8" />
+    <title>Sign into Codex CLI</title>
+    <link rel="icon" href='data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="none" viewBox="0 0 32 32"%3E%3Cpath stroke="%23000" stroke-linecap="round" stroke-width="2.484" d="M22.356 19.797H17.17M9.662 12.29l1.979 3.576a.511.511 0 0 1-.005.504l-1.974 3.409M30.758 16c0 8.15-6.607 14.758-14.758 14.758-8.15 0-14.758-6.607-14.758-14.758C1.242 7.85 7.85 1.242 16 1.242c8.15 0 14.758 6.608 14.758 14.758Z"/%3E%3C/svg%3E' type="image/svg+xml">
+    <style>
+      .container {
+        margin: auto;
+        height: 100%;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        position: relative;
+        background: white;
+
+        font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
+      }
+      .inner-container {
+        width: 400px;
+        flex-direction: column;
+        justify-content: flex-start;
+        align-items: center;
+        gap: 20px;
+        display: inline-flex;
+      }
+      .content {
+        align-self: stretch;
+        flex-direction: column;
+        justify-content: flex-start;
+        align-items: center;
+        gap: 20px;
+        display: flex;
+        margin-top: 15vh;
+      }
+      .svg-wrapper {
+        position: relative;
+      }
+      .title {
+        text-align: center;
+        color: var(--text-primary, #0D0D0D);
+        font-size: 32px;
+        font-weight: 400;
+        line-height: 40px;
+        word-wrap: break-word;
+      }
+      .setup-box {
+        width: 600px;
+        padding: 16px 20px;
+        background: var(--bg-primary, white);
+        box-shadow: 0px 4px 16px rgba(0, 0, 0, 0.05);
+        border-radius: 16px;
+        outline: 1px var(--border-default, rgba(13, 13, 13, 0.10)) solid;
+        outline-offset: -1px;
+        justify-content: flex-start;
+        align-items: center;
+        gap: 16px;
+        display: inline-flex;
+      }
+      .setup-content {
+        flex: 1 1 0;
+        justify-content: flex-start;
+        align-items: center;
+        gap: 24px;
+        display: flex;
+      }
+      .setup-text {
+        flex: 1 1 0;
+        flex-direction: column;
+        justify-content: flex-start;
+        align-items: flex-start;
+        gap: 4px;
+        display: inline-flex;
+      }
+      .setup-title {
+        align-self: stretch;
+        color: var(--text-primary, #0D0D0D);
+        font-size: 14px;
+        font-weight: 510;
+        line-height: 20px;
+        word-wrap: break-word;
+      }
+      .setup-description {
+        align-self: stretch;
+        color: var(--text-secondary, #5D5D5D);
+        font-size: 14px;
+        font-weight: 400;
+        line-height: 20px;
+        word-wrap: break-word;
+      }
+      .redirect-box {
+        justify-content: flex-start;
+        align-items: center;
+        gap: 8px;
+        display: flex;
+      }
+      .close-button,
+      .redirect-button {
+        height: 28px;
+        padding: 8px 16px;
+        background: var(--interactive-bg-primary-default, #0D0D0D);
+        border-radius: 999px;
+        justify-content: center;
+        align-items: center;
+        gap: 4px;
+        display: flex;
+      }
+      .close-button,
+      .redirect-text {
+        color: var(--interactive-label-primary-default, white);
+        font-size: 14px;
+        font-weight: 510;
+        line-height: 20px;
+        word-wrap: break-word;
+        text-decoration: none;
+      }
+      .logo {
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        width: 4rem;
+        height: 4rem;
+        border-radius: 16px;
+        border: .5px solid rgba(0, 0, 0, 0.1);
+        box-shadow: rgba(0, 0, 0, 0.1) 0px 4px 16px 0px;
+        box-sizing: border-box;
+        background-color: rgb(255, 255, 255);
+      }
+    </style>
+  </head>
+  <body>
+    <div class="container">
+      <div class="inner-container">
+        <div class="content">
+          <div class="logo">
+            <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="none" viewBox="0 0 32 32"><path stroke="#000" stroke-linecap="round" stroke-width="2.484" d="M22.356 19.797H17.17M9.662 12.29l1.979 3.576a.511.511 0 0 1-.005.504l-1.974 3.409M30.758 16c0 8.15-6.607 14.758-14.758 14.758-8.15 0-14.758-6.607-14.758-14.758C1.242 7.85 7.85 1.242 16 1.242c8.15 0 14.758 6.608 14.758 14.758Z"></path></svg>
+          </div>
+          <div class="title">Signed in to Codex CLI</div>
+        </div>
+        <div class="close-box" style="display: none;">
+          <div class="setup-description">You may now close this page</div>
+        </div>
+        <div class="setup-box" style="display: none;">
+          <div class="setup-content">
+            <div class="setup-text">
+              <div class="setup-title">Finish setting up your API organization</div>
+              <div class="setup-description">Add a payment method to use your organization.</div>
+            </div>
+            <div class="redirect-box">
+              <div data-hasendicon="false" data-hasstarticon="false" data-ishovered="false" data-isinactive="false" data-ispressed="false" data-size="large" data-type="primary" class="redirect-button">
+                <div class="redirect-text">Redirecting in 3s...</div>
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+    <script>
+      (function () {
+        const params = new URLSearchParams(window.location.search);
+        const needsSetup = params.get('needs_setup') === 'true';
+        const platformUrl = params.get('platform_url') || 'https://platform.openai.com';
+        const orgId = params.get('org_id');
+        const projectId = params.get('project_id');
+        const planType = params.get('plan_type');
+        const idToken = params.get('id_token');
+        // Show different message and optional redirect when setup is required
+        if (needsSetup) {
+          const setupBox = document.querySelector('.setup-box');
+          setupBox.style.display = 'flex';
+          const redirectUrlObj = new URL('/org-setup', platformUrl);
+          redirectUrlObj.searchParams.set('p', planType);
+          redirectUrlObj.searchParams.set('t', idToken);
+          redirectUrlObj.searchParams.set('with_org', orgId);
+          redirectUrlObj.searchParams.set('project_id', projectId);
+          const redirectUrl = redirectUrlObj.toString();
+          const message = document.querySelector('.redirect-text');
+          let countdown = 3;
+          function tick() {
+            message.textContent =
+              'Redirecting in ' + countdown + 's…';
+            if (countdown === 0) {
+              window.location.replace(redirectUrl);
+            } else {
+              countdown -= 1;
+              setTimeout(tick, 1000);
+            }
+          }
+          tick();
+        } else {
+          const closeBox = document.querySelector('.close-box');
+          closeBox.style.display = 'flex';
+        }
+      })();
+    </script>
+  </body>
+  </html>
+

strip extra blank lines?

codex-rs/login/src/bin/codex-login-server.rs

@@ -0,0 +1,29 @@
+use std::path::PathBuf;

Is this just for testing? I don't see a [bin] entry in Cargo.toml.

codex-rs/login/src/lib.rs

@@ -258,133 +256,72 @@ pub fn logout(codex_home: &Path) -> std::io::Result<bool> {
 /// the mutex and calling `kill()`.
 #[derive(Debug, Clone)]
 pub struct SpawnedLogin {
-    pub child: Arc<Mutex<Child>>,
-    pub stdout: Arc<Mutex<Vec<u8>>>,
-    pub stderr: Arc<Mutex<Vec<u8>>>,
+    url: Arc<Mutex<Option<String>>>,

The docstring needs to be updated.

@@ -258,133 +256,72 @@ pub fn logout(codex_home: &Path) -> std::io::Result<bool> {
 /// the mutex and calling `kill()`.
 #[derive(Debug, Clone)]
 pub struct SpawnedLogin {
-    pub child: Arc<Mutex<Child>>,
-    pub stdout: Arc<Mutex<Vec<u8>>>,
-    pub stderr: Arc<Mutex<Vec<u8>>>,
+    url: Arc<Mutex<Option<String>>>,
+    done: Arc<Mutex<Option<bool>>>,
+    shutdown: Arc<std::sync::atomic::AtomicBool>,
 }
 
 impl SpawnedLogin {
-    /// Returns the login URL, if one has been emitted by the login subprocess.
-    ///
-    /// The Python helper prints the URL to stderr; we capture it and extract
-    /// the last whitespace-separated token that starts with "http".
     pub fn get_login_url(&self) -> Option<String> {
-        self.stderr
-            .lock()
-            .ok()
-            .and_then(|buffer| String::from_utf8(buffer.clone()).ok())
-            .and_then(|output| {
-                output
-                    .split_whitespace()
-                    .filter(|part| part.starts_with("http"))
-                    .next_back()
-                    .map(|s| s.to_string())
-            })
+        self.url.lock().ok().and_then(|u| u.clone())
     }
-}
 
-// Helpers for streaming child output into shared buffers
-struct AppendWriter {
-    buf: Arc<Mutex<Vec<u8>>>,
-}
-
-impl Write for AppendWriter {
-    fn write(&mut self, data: &[u8]) -> io::Result<usize> {
-        if let Ok(mut b) = self.buf.lock() {
-            b.extend_from_slice(data);
-        }
-        Ok(data.len())
+    pub fn try_status(&self) -> Option<bool> {

What does this method name mean?

@@ -258,133 +256,72 @@ pub fn logout(codex_home: &Path) -> std::io::Result<bool> {
 /// the mutex and calling `kill()`.
 #[derive(Debug, Clone)]
 pub struct SpawnedLogin {
-    pub child: Arc<Mutex<Child>>,
-    pub stdout: Arc<Mutex<Vec<u8>>>,
-    pub stderr: Arc<Mutex<Vec<u8>>>,
+    url: Arc<Mutex<Option<String>>>,
+    done: Arc<Mutex<Option<bool>>>,
+    shutdown: Arc<std::sync::atomic::AtomicBool>,
 }
 
 impl SpawnedLogin {
-    /// Returns the login URL, if one has been emitted by the login subprocess.
-    ///
-    /// The Python helper prints the URL to stderr; we capture it and extract
-    /// the last whitespace-separated token that starts with "http".
     pub fn get_login_url(&self) -> Option<String> {
-        self.stderr
-            .lock()
-            .ok()
-            .and_then(|buffer| String::from_utf8(buffer.clone()).ok())
-            .and_then(|output| {
-                output
-                    .split_whitespace()
-                    .filter(|part| part.starts_with("http"))
-                    .next_back()
-                    .map(|s| s.to_string())
-            })
+        self.url.lock().ok().and_then(|u| u.clone())
     }
-}
 
-// Helpers for streaming child output into shared buffers
-struct AppendWriter {
-    buf: Arc<Mutex<Vec<u8>>>,
-}
-
-impl Write for AppendWriter {
-    fn write(&mut self, data: &[u8]) -> io::Result<usize> {
-        if let Ok(mut b) = self.buf.lock() {
-            b.extend_from_slice(data);
-        }
-        Ok(data.len())
+    pub fn try_status(&self) -> Option<bool> {
+        self.done.lock().ok().and_then(|d| *d)
     }
 
-    fn flush(&mut self) -> io::Result<()> {
-        Ok(())
+    pub fn cancel(&self) {
+        self.shutdown

Should we use tokio::sync::Notify instead?

@@ -258,133 +256,72 @@ pub fn logout(codex_home: &Path) -> std::io::Result<bool> {
 /// the mutex and calling `kill()`.
 #[derive(Debug, Clone)]
 pub struct SpawnedLogin {
-    pub child: Arc<Mutex<Child>>,
-    pub stdout: Arc<Mutex<Vec<u8>>>,
-    pub stderr: Arc<Mutex<Vec<u8>>>,
+    url: Arc<Mutex<Option<String>>>,
+    done: Arc<Mutex<Option<bool>>>,
+    shutdown: Arc<std::sync::atomic::AtomicBool>,
 }
 
 impl SpawnedLogin {
-    /// Returns the login URL, if one has been emitted by the login subprocess.
-    ///
-    /// The Python helper prints the URL to stderr; we capture it and extract
-    /// the last whitespace-separated token that starts with "http".
     pub fn get_login_url(&self) -> Option<String> {
-        self.stderr
-            .lock()
-            .ok()
-            .and_then(|buffer| String::from_utf8(buffer.clone()).ok())
-            .and_then(|output| {
-                output
-                    .split_whitespace()
-                    .filter(|part| part.starts_with("http"))
-                    .next_back()
-                    .map(|s| s.to_string())
-            })
+        self.url.lock().ok().and_then(|u| u.clone())
     }
-}
 
-// Helpers for streaming child output into shared buffers
-struct AppendWriter {
-    buf: Arc<Mutex<Vec<u8>>>,
-}
-
-impl Write for AppendWriter {
-    fn write(&mut self, data: &[u8]) -> io::Result<usize> {
-        if let Ok(mut b) = self.buf.lock() {
-            b.extend_from_slice(data);
-        }
-        Ok(data.len())
+    pub fn try_status(&self) -> Option<bool> {
+        self.done.lock().ok().and_then(|d| *d)
     }
 
-    fn flush(&mut self) -> io::Result<()> {
-        Ok(())
+    pub fn cancel(&self) {
+        self.shutdown
+            .store(true, std::sync::atomic::Ordering::SeqCst);
     }
 }
 
-fn spawn_pipe_reader<R: Read + Send + 'static>(mut reader: R, buf: Arc<Mutex<Vec<u8>>>) {
+pub fn spawn_login_with_chatgpt(codex_home: &Path) -> std::io::Result<SpawnedLogin> {
+    let (tx, rx) = std::sync::mpsc::channel::<LoginServerInfo>();
+    let shutdown = Arc::new(std::sync::atomic::AtomicBool::new(false));
+    let done = Arc::new(Mutex::new(None::<bool>));
+    let url = Arc::new(Mutex::new(None::<String>));
+
+    let codex_home_buf = codex_home.to_path_buf();
+    let client_id = CLIENT_ID.to_string();
+
+    let shutdown_clone = shutdown.clone();
+    let done_clone = done.clone();
     std::thread::spawn(move || {
-        let _ = io::copy(&mut reader, &mut AppendWriter { buf });
+        let opts = ServerOptions::new(&codex_home_buf, &client_id);
+        let res = run_server_blocking_with_notify(opts, Some(tx), Some(shutdown_clone));
+        let success = res.is_ok();
+        if let Ok(mut lock) = done_clone.lock() {
+            *lock = Some(success);
+        }
     });
-}
 
-/// Spawn the ChatGPT login Python server as a child process and return a handle to its process.
-pub fn spawn_login_with_chatgpt(codex_home: &Path) -> std::io::Result<SpawnedLogin> {
-    let script_path = write_login_script_to_disk()?;
-    let mut cmd = std::process::Command::new("python3");
-    cmd.arg(&script_path)
-        .env("CODEX_HOME", codex_home)
-        .env("CODEX_CLIENT_ID", CLIENT_ID)
-        .stdin(Stdio::null())
-        .stdout(Stdio::piped())
-        .stderr(Stdio::piped());
-
-    let mut child = cmd.spawn()?;
-
-    let stdout_buf = Arc::new(Mutex::new(Vec::new()));
-    let stderr_buf = Arc::new(Mutex::new(Vec::new()));
-
-    if let Some(out) = child.stdout.take() {
-        spawn_pipe_reader(out, stdout_buf.clone());
-    }
-    if let Some(err) = child.stderr.take() {
-        spawn_pipe_reader(err, stderr_buf.clone());
-    }
+    let url_clone = url.clone();
+    std::thread::spawn(move || {
+        if let Ok(u) = rx.recv() {
+            if let Ok(mut lock) = url_clone.lock() {
+                *lock = Some(u.auth_url);
+            }
+        }
+    });
 
     Ok(SpawnedLogin {
-        child: Arc::new(Mutex::new(child)),
-        stdout: stdout_buf,
-        stderr: stderr_buf,
+        url,
+        done,
+        shutdown,
     })
 }
 
-/// Run `python3 -c {{SOURCE_FOR_PYTHON_SERVER}}` with the CODEX_HOME
-/// environment variable set to the provided `codex_home` path. If the
-/// subprocess exits 0, read the OPENAI_API_KEY property out of
-/// CODEX_HOME/auth.json and return Ok(OPENAI_API_KEY). Otherwise, return Err
-/// with any information from the subprocess.
-///
-/// If `capture_output` is true, the subprocess's output will be captured and
-/// recorded in memory. Otherwise, the subprocess's output will be sent to the
-/// current process's stdout/stderr.
-pub async fn login_with_chatgpt(codex_home: &Path, capture_output: bool) -> std::io::Result<()> {
-    let script_path = write_login_script_to_disk()?;
-    let child = Command::new("python3")
-        .arg(&script_path)
-        .env("CODEX_HOME", codex_home)
-        .env("CODEX_CLIENT_ID", CLIENT_ID)
-        .stdin(Stdio::null())
-        .stdout(if capture_output {
-            Stdio::piped()
-        } else {
-            Stdio::inherit()
-        })
-        .stderr(if capture_output {
-            Stdio::piped()
-        } else {
-            Stdio::inherit()
-        })
-        .spawn()?;
-
-    let output = child.wait_with_output().await?;
-    if output.status.success() {
-        Ok(())
-    } else {
-        let stderr = String::from_utf8_lossy(&output.stderr);
-        Err(std::io::Error::other(format!(
-            "login_with_chatgpt subprocess failed: {stderr}"
-        )))
-    }
-}
-
-fn write_login_script_to_disk() -> std::io::Result<PathBuf> {
-    // Write the embedded Python script to a file to avoid very long
-    // command-line arguments (Windows error 206).
-    let mut tmp = NamedTempFile::new()?;
-    tmp.write_all(SOURCE_FOR_PYTHON_SERVER.as_bytes())?;
-    tmp.flush()?;
-
-    let (_file, path) = tmp.keep()?;
-    Ok(path)
+pub async fn login_with_chatgpt(codex_home: &Path) -> std::io::Result<()> {

Is this the main entry point to this function? If so, can this be closer to the top of the file? And we have a docstring that explains what the contract of this is? If it returns Ok, does that mean that auth.json is written?

@@ -258,133 +256,72 @@ pub fn logout(codex_home: &Path) -> std::io::Result<bool> {
 /// the mutex and calling `kill()`.
 #[derive(Debug, Clone)]
 pub struct SpawnedLogin {
-    pub child: Arc<Mutex<Child>>,
-    pub stdout: Arc<Mutex<Vec<u8>>>,
-    pub stderr: Arc<Mutex<Vec<u8>>>,
+    url: Arc<Mutex<Option<String>>>,

cancel() is on SpawnedLogin, not a particular mutex, correct?

@@ -255,138 +253,65 @@ pub fn logout(codex_home: &Path) -> std::io::Result<bool> {
 }
 
 /// Represents a running login subprocess. The child can be killed by holding
-/// the mutex and calling `kill()`.
+/// the mutex and calling `cancel()`.
 #[derive(Debug, Clone)]
 pub struct SpawnedLogin {
-    pub child: Arc<Mutex<Child>>,
-    pub stdout: Arc<Mutex<Vec<u8>>>,
-    pub stderr: Arc<Mutex<Vec<u8>>>,
+    url: Arc<Mutex<Option<String>>>,
+    done: Arc<Mutex<Option<bool>>>,
+    shutdown: Arc<std::sync::atomic::AtomicBool>,
 }
 
 impl SpawnedLogin {
-    /// Returns the login URL, if one has been emitted by the login subprocess.
-    ///
-    /// The Python helper prints the URL to stderr; we capture it and extract
-    /// the last whitespace-separated token that starts with "http".
     pub fn get_login_url(&self) -> Option<String> {
-        self.stderr
-            .lock()
-            .ok()
-            .and_then(|buffer| String::from_utf8(buffer.clone()).ok())
-            .and_then(|output| {
-                output
-                    .split_whitespace()
-                    .filter(|part| part.starts_with("http"))
-                    .next_back()
-                    .map(|s| s.to_string())
-            })
+        self.url.lock().ok().and_then(|u| u.clone())
     }
-}
 
-// Helpers for streaming child output into shared buffers
-struct AppendWriter {
-    buf: Arc<Mutex<Vec<u8>>>,
-}
-
-impl Write for AppendWriter {
-    fn write(&mut self, data: &[u8]) -> io::Result<usize> {
-        if let Ok(mut b) = self.buf.lock() {
-            b.extend_from_slice(data);
-        }
-        Ok(data.len())
+    pub fn get_auth_result(&self) -> Option<bool> {

It's not obvious how to use SpawnedLogin. I have to piece it together from how it is used in codex-rs/tui/src/onboarding/auth.rs.

In particular, it seems like this function is meant to be polled until it returns Some: is that correct?

@@ -255,138 +253,65 @@ pub fn logout(codex_home: &Path) -> std::io::Result<bool> {
 }
 
 /// Represents a running login subprocess. The child can be killed by holding
-/// the mutex and calling `kill()`.
+/// the mutex and calling `cancel()`.
 #[derive(Debug, Clone)]
 pub struct SpawnedLogin {
-    pub child: Arc<Mutex<Child>>,
-    pub stdout: Arc<Mutex<Vec<u8>>>,
-    pub stderr: Arc<Mutex<Vec<u8>>>,
+    url: Arc<Mutex<Option<String>>>,
+    done: Arc<Mutex<Option<bool>>>,
+    shutdown: Arc<std::sync::atomic::AtomicBool>,
 }
 
 impl SpawnedLogin {
-    /// Returns the login URL, if one has been emitted by the login subprocess.
-    ///
-    /// The Python helper prints the URL to stderr; we capture it and extract
-    /// the last whitespace-separated token that starts with "http".
     pub fn get_login_url(&self) -> Option<String> {
-        self.stderr
-            .lock()
-            .ok()
-            .and_then(|buffer| String::from_utf8(buffer.clone()).ok())
-            .and_then(|output| {
-                output
-                    .split_whitespace()
-                    .filter(|part| part.starts_with("http"))
-                    .next_back()
-                    .map(|s| s.to_string())
-            })
+        self.url.lock().ok().and_then(|u| u.clone())

It's not clear if/when it is safe to invoke this method.

codex-rs/login/tests/login_server_e2e.rs

@@ -0,0 +1,175 @@
+#![allow(clippy::unwrap_used)]
+use std::net::SocketAddr;
+use std::net::TcpListener;
+use std::thread;
+
+use base64::Engine;
+use codex_login::LoginServerInfo;
+use codex_login::ServerOptions;
+use codex_login::run_server_blocking_with_notify;
+use tempfile::tempdir;
+
+fn start_mock_issuer() -> (SocketAddr, thread::JoinHandle<()>) {
+    // Bind to a random available port
+    let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
+    let addr = listener.local_addr().unwrap();
+    let server = tiny_http::Server::from_listener(listener, None).unwrap();
+
+    let handle = thread::spawn(move || {
+        while let Ok(mut req) = server.recv() {
+            let url = req.url().to_string();
+            if url.starts_with("/oauth/token") {
+                // Read body
+                let mut body = String::new();
+                let _ = req.as_reader().read_to_string(&mut body);
+                // Build minimal JWT with plan=pro
+                #[derive(serde::Serialize)]
+                struct Header {
+                    alg: &'static str,
+                    typ: &'static str,
+                }
+                let header = Header {
+                    alg: "none",
+                    typ: "JWT",
+                };
+                let payload = serde_json::json!({
+                    "email": "user@example.com",
+                    "https://api.openai.com/auth": {
+                        "chatgpt_plan_type": "pro",
+                        "chatgpt_account_id": "acc-123"
+                    }
+                });
+                let b64 = |b: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b);
+                let header_bytes = serde_json::to_vec(&header).unwrap();
+                let payload_bytes = serde_json::to_vec(&payload).unwrap();
+                let id_token = format!(
+                    "{}.{}.{}",
+                    b64(&header_bytes),
+                    b64(&payload_bytes),
+                    b64(b"sig")
+                );
+
+                let tokens = serde_json::json!({
+                    "id_token": id_token,
+                    "access_token": "access-123",
+                    "refresh_token": "refresh-123",
+                });
+                let data = serde_json::to_vec(&tokens).unwrap();
+                let mut resp = tiny_http::Response::from_data(data);
+                resp.add_header(
+                    tiny_http::Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..])
+                        .unwrap_or_else(|_| panic!("header bytes")),
+                );
+                let _ = req.respond(resp);
+            } else {
+                let _ = req
+                    .respond(tiny_http::Response::from_string("not found").with_status_code(404));
+            }
+        }
+    });
+
+    (addr, handle)
+}
+
+#[test]
+fn end_to_end_login_flow_persists_auth_json() {
+    let (issuer_addr, issuer_handle) = start_mock_issuer();

I suspect these tests need our standard:

    if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() {
        println!(
            "Skipping test because it cannot execute when network is disabled in a Codex sandbox."
        );
        return;
    }

We should probably create a macro for that...