124 KiB
PR #2294: Port login server to rust
- URL: https://github.com/openai/codex/pull/2294
- Author: easong-openai
- Created: 2025-08-14 09:36:05 UTC
- Updated: 2025-08-18 08:56:12 UTC
- Changes: +1225/-1094, Files changed: 13, Commits: 13
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) = ¬ify_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
- Created: 2025-08-14 22:22:01 UTC | Link: https://github.com/openai/codex/pull/2294#discussion_r2277833645
@@ -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()?
- Created: 2025-08-14 22:22:45 UTC | Link: https://github.com/openai/codex/pull/2294#discussion_r2277834521
@@ -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-logincrate directly, but I guess this is the main thing?
codex-rs/login/src/assets/success.html
- Created: 2025-08-14 20:22:46 UTC | Link: https://github.com/openai/codex/pull/2294#discussion_r2277648356
@@ -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
- Created: 2025-08-14 20:26:17 UTC | Link: https://github.com/openai/codex/pull/2294#discussion_r2277654937
@@ -0,0 +1,29 @@
+use std::path::PathBuf;
Is this just for testing? I don't see a
[bin]entry inCargo.toml.
codex-rs/login/src/lib.rs
- Created: 2025-08-14 20:30:35 UTC | Link: https://github.com/openai/codex/pull/2294#discussion_r2277662935
@@ -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.
- Created: 2025-08-14 20:46:44 UTC | Link: https://github.com/openai/codex/pull/2294#discussion_r2277690871
@@ -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?
- Created: 2025-08-14 20:48:00 UTC | Link: https://github.com/openai/codex/pull/2294#discussion_r2277693114
@@ -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::Notifyinstead?
- Created: 2025-08-14 20:55:07 UTC | Link: https://github.com/openai/codex/pull/2294#discussion_r2277704947
@@ -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 thatauth.jsonis written?
- Created: 2025-08-14 22:18:51 UTC | Link: https://github.com/openai/codex/pull/2294#discussion_r2277829504
@@ -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 onSpawnedLogin, not a particular mutex, correct?
- Created: 2025-08-14 22:29:57 UTC | Link: https://github.com/openai/codex/pull/2294#discussion_r2277847606
@@ -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 incodex-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?
- Created: 2025-08-14 22:31:20 UTC | Link: https://github.com/openai/codex/pull/2294#discussion_r2277849484
@@ -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
- Created: 2025-08-14 20:28:26 UTC | Link: https://github.com/openai/codex/pull/2294#discussion_r2277658832
@@ -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...