mirror of
https://github.com/openai/codex.git
synced 2026-09-16 12:13:30 +00:00
initial
This commit is contained in:
252
codex-rs/Cargo.lock
generated
252
codex-rs/Cargo.lock
generated
@@ -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.41"
|
||||
@@ -795,13 +813,21 @@ version = "0.0.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"hex",
|
||||
"pretty_assertions",
|
||||
"rand 0.8.5",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tempfile",
|
||||
"thiserror 2.0.12",
|
||||
"tiny_http",
|
||||
"tokio",
|
||||
"ureq",
|
||||
"url",
|
||||
"urlencoding",
|
||||
"webbrowser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -947,6 +973,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"
|
||||
@@ -1001,6 +1037,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"
|
||||
@@ -2451,6 +2497,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"
|
||||
@@ -2787,6 +2855,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"
|
||||
@@ -2940,6 +3014,31 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "88c6597e14493ab2e44ce58f2fdecf095a51f12ca57bec060a11c57332520551"
|
||||
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"
|
||||
@@ -3654,6 +3753,7 @@ dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"encoding_rs",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"h2",
|
||||
@@ -3785,7 +3885,9 @@ version = "0.23.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2491382039b29b9b11ff08b76ff6c97cf287671dbb74f0be44bda389fffe9bd1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki",
|
||||
"subtle",
|
||||
@@ -3976,7 +4078,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",
|
||||
@@ -4135,6 +4237,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"
|
||||
@@ -4499,7 +4612,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",
|
||||
]
|
||||
|
||||
@@ -4694,6 +4807,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"
|
||||
@@ -5134,6 +5259,23 @@ version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||
|
||||
[[package]]
|
||||
name = "ureq"
|
||||
version = "2.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"log",
|
||||
"once_cell",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"url",
|
||||
"webpki-roots 0.26.11",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.5.4"
|
||||
@@ -5146,6 +5288,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"
|
||||
@@ -5369,6 +5517,40 @@ 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 = "webpki-roots"
|
||||
version = "0.26.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
|
||||
dependencies = [
|
||||
"webpki-roots 1.0.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e8983c3ab33d6fb807cfcdad2491c4ea8cbc8ed839181c7dfd9c67c83e261b2"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "weezl"
|
||||
version = "0.1.10"
|
||||
@@ -5557,6 +5739,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"
|
||||
@@ -5584,6 +5775,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"
|
||||
@@ -5616,6 +5822,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"
|
||||
@@ -5628,6 +5840,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"
|
||||
@@ -5640,6 +5858,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"
|
||||
@@ -5664,6 +5888,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"
|
||||
@@ -5676,6 +5906,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"
|
||||
@@ -5688,6 +5924,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"
|
||||
@@ -5700,6 +5942,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"
|
||||
|
||||
@@ -6,10 +6,13 @@ version = { workspace = true }
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[features]
|
||||
http-e2e-tests = []
|
||||
|
||||
[dependencies]
|
||||
base64 = "0.22"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
reqwest = { version = "0.12", features = ["json"] }
|
||||
reqwest = { version = "0.12", features = ["json", "blocking"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
thiserror = "2.0.12"
|
||||
@@ -20,7 +23,15 @@ tokio = { version = "1", features = [
|
||||
"rt-multi-thread",
|
||||
"signal",
|
||||
] }
|
||||
hex = "0.4"
|
||||
rand = "0.8"
|
||||
sha2 = "0.10"
|
||||
tiny_http = "0.12"
|
||||
url = "2"
|
||||
urlencoding = "2"
|
||||
webbrowser = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions = "1.4.1"
|
||||
tempfile = "3"
|
||||
ureq = { version = "2", default-features = false, features = ["tls", "json"] }
|
||||
|
||||
@@ -18,14 +18,18 @@ use std::process::Stdio;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
use std::time::Duration;
|
||||
use tokio::process::Command;
|
||||
// use tokio::process::Command; // no longer used
|
||||
|
||||
pub use crate::token_data::TokenData;
|
||||
use crate::token_data::parse_id_token;
|
||||
|
||||
mod token_data;
|
||||
|
||||
const SOURCE_FOR_PYTHON_SERVER: &str = include_str!("./login_with_chatgpt.py");
|
||||
mod server;
|
||||
pub use server::LoginServerOptions;
|
||||
pub use server::run_local_login_server_with_options;
|
||||
pub use server::process_callback_headless;
|
||||
pub use server::HeadlessOutcome;
|
||||
pub use server::Http;
|
||||
|
||||
const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
|
||||
pub const OPENAI_API_KEY_ENV_VAR: &str = "OPENAI_API_KEY";
|
||||
@@ -261,13 +265,12 @@ pub struct SpawnedLogin {
|
||||
pub stderr: Arc<Mutex<Vec<u8>>>,
|
||||
}
|
||||
|
||||
/// Spawn the ChatGPT login Python server as a child process and return a handle to its process.
|
||||
/// Spawn the Rust login server via the current executable ("codex login") and return a handle to its process.
|
||||
pub fn spawn_login_with_chatgpt(codex_home: &Path) -> std::io::Result<SpawnedLogin> {
|
||||
let mut cmd = std::process::Command::new("python3");
|
||||
cmd.arg("-c")
|
||||
.arg(SOURCE_FOR_PYTHON_SERVER)
|
||||
let current_exe = std::env::current_exe()?;
|
||||
let mut cmd = std::process::Command::new(current_exe);
|
||||
cmd.arg("login")
|
||||
.env("CODEX_HOME", codex_home)
|
||||
.env("CODEX_CLIENT_ID", CLIENT_ID)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
@@ -314,34 +317,12 @@ pub fn spawn_login_with_chatgpt(codex_home: &Path) -> std::io::Result<SpawnedLog
|
||||
/// 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 child = Command::new("python3")
|
||||
.arg("-c")
|
||||
.arg(SOURCE_FOR_PYTHON_SERVER)
|
||||
.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}"
|
||||
)))
|
||||
}
|
||||
pub async fn login_with_chatgpt(codex_home: &Path, _capture_output: bool) -> std::io::Result<()> {
|
||||
let codex_home = codex_home.to_path_buf();
|
||||
tokio::task::spawn_blocking(move || server::run_local_login_server(&codex_home, CLIENT_ID))
|
||||
.await
|
||||
.map_err(|e| std::io::Error::other(format!("task join error: {e}")))??;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn login_with_api_key(codex_home: &Path, api_key: &str) -> std::io::Result<()> {
|
||||
|
||||
728
codex-rs/login/src/server.rs
Normal file
728
codex-rs/login/src/server.rs
Normal file
@@ -0,0 +1,728 @@
|
||||
use base64::Engine as _;
|
||||
use chrono::Utc;
|
||||
use rand::RngCore;
|
||||
use reqwest::blocking::Client;
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::HashMap;
|
||||
use std::fs::{OpenOptions};
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
use tiny_http::{Header, Method, Response, Server};
|
||||
use url::Url;
|
||||
use std::path::PathBuf;
|
||||
|
||||
const DEFAULT_PORT: u16 = 1455;
|
||||
const DEFAULT_ISSUER: &str = "https://auth.openai.com";
|
||||
|
||||
// Copied from the Python HTML to keep UX consistent.
|
||||
pub const LOGIN_SUCCESS_HTML: &str = include_str!("./success_page.html");
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PkceCodes {
|
||||
code_verifier: String,
|
||||
code_challenge: String,
|
||||
}
|
||||
|
||||
fn generate_pkce() -> PkceCodes {
|
||||
// Equivalent to Python's secrets.token_hex(64)
|
||||
let mut bytes = [0u8; 64];
|
||||
rand::thread_rng().fill_bytes(&mut bytes);
|
||||
let code_verifier = hex::encode(bytes);
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CodeExchangeResponse {
|
||||
id_token: String,
|
||||
access_token: String,
|
||||
refresh_token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TokenExchangeResponse {
|
||||
access_token: String,
|
||||
}
|
||||
|
||||
fn now_rfc3339_z() -> String {
|
||||
Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Micros, true)
|
||||
}
|
||||
|
||||
fn decode_jwt_payload_segment(segment_b64: &str) -> serde_json::Value {
|
||||
let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.decode(segment_b64)
|
||||
.ok();
|
||||
decoded
|
||||
.and_then(|bytes| serde_json::from_slice::<serde_json::Value>(&bytes).ok())
|
||||
.unwrap_or(serde_json::Value::Object(Default::default()))
|
||||
}
|
||||
|
||||
fn parse_jwt_claims(token: &str) -> serde_json::Value {
|
||||
// Expect three segments: header.payload.signature
|
||||
let mut parts = token.split('.');
|
||||
let _header = parts.next();
|
||||
let payload = parts.next();
|
||||
let _sig = parts.next();
|
||||
match payload {
|
||||
Some(p) if !p.is_empty() => decode_jwt_payload_segment(p),
|
||||
_ => serde_json::Value::Object(Default::default()),
|
||||
}
|
||||
}
|
||||
|
||||
fn write_auth_file(
|
||||
codex_home: &Path,
|
||||
api_key: Option<String>,
|
||||
id_token: &str,
|
||||
access_token: &str,
|
||||
refresh_token: &str,
|
||||
account_id: Option<String>,
|
||||
) -> std::io::Result<()> {
|
||||
std::fs::create_dir_all(codex_home)?;
|
||||
let auth_path = codex_home.join("auth.json");
|
||||
|
||||
let contents = json!({
|
||||
"OPENAI_API_KEY": api_key,
|
||||
"tokens": {
|
||||
"id_token": id_token,
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
"account_id": account_id,
|
||||
},
|
||||
"last_refresh": now_rfc3339_z(),
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let mut opts = OpenOptions::new();
|
||||
opts.create(true).truncate(true).write(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
opts.mode(0o600);
|
||||
}
|
||||
let mut f = opts.open(auth_path)?;
|
||||
use std::io::Write;
|
||||
f.write_all(contents.as_bytes())?;
|
||||
f.flush()
|
||||
}
|
||||
|
||||
fn maybe_redeem_credits(
|
||||
issuer: &str,
|
||||
client_id: &str,
|
||||
id_token_opt: Option<&str>,
|
||||
refresh_token: &str,
|
||||
codex_home: &Path,
|
||||
) {
|
||||
// Best-effort: any error should not abort the login flow.
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.build();
|
||||
let Ok(client) = client else { return };
|
||||
|
||||
// Parse initial ID token claims and check expiration.
|
||||
let mut id_token = id_token_opt.unwrap_or("").to_string();
|
||||
let mut claims = parse_jwt_claims(&id_token);
|
||||
|
||||
let mut token_expired = true;
|
||||
if let Some(exp) = claims.get("exp").and_then(|v| v.as_i64()) {
|
||||
let now_ms = (Utc::now().timestamp_millis()) as i64;
|
||||
token_expired = now_ms >= exp * 1000;
|
||||
}
|
||||
|
||||
if token_expired {
|
||||
eprintln!("Refreshing credentials...");
|
||||
#[derive(serde::Serialize)]
|
||||
struct RefreshReq<'a> {
|
||||
client_id: &'a str,
|
||||
grant_type: &'a str,
|
||||
refresh_token: &'a str,
|
||||
scope: &'a str,
|
||||
}
|
||||
let body = RefreshReq {
|
||||
client_id,
|
||||
grant_type: "refresh_token",
|
||||
refresh_token,
|
||||
scope: "openid profile email",
|
||||
};
|
||||
let resp = client
|
||||
.post("https://auth.openai.com/oauth/token")
|
||||
.json(&body)
|
||||
.send();
|
||||
let Ok(resp) = resp else { return };
|
||||
let Ok(val) = resp.json::<serde_json::Value>() else { return };
|
||||
let new_id_token = val.get("id_token").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||||
let new_refresh_token = val
|
||||
.get("refresh_token")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
if let (Some(new_id), Some(new_refresh)) = (new_id_token, new_refresh_token) {
|
||||
// Update file on disk with new tokens.
|
||||
// Read, modify, write.
|
||||
let path = codex_home.join("auth.json");
|
||||
if let Ok(mut existing) = std::fs::read_to_string(&path) {
|
||||
if let Ok(mut obj) = serde_json::from_str::<serde_json::Value>(&existing) {
|
||||
obj["tokens"]["id_token"] = serde_json::Value::String(new_id.clone());
|
||||
obj["tokens"]["refresh_token"] = serde_json::Value::String(new_refresh.clone());
|
||||
obj["tokens"]["last_refresh"] = serde_json::Value::String(now_rfc3339_z());
|
||||
existing = serde_json::to_string_pretty(&obj).unwrap_or(existing);
|
||||
let _ = std::fs::write(&path, existing);
|
||||
id_token = new_id;
|
||||
claims = parse_jwt_claims(&id_token);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Eligibility checks
|
||||
let auth_claims = claims
|
||||
.get("https://api.openai.com/auth")
|
||||
.cloned()
|
||||
.unwrap_or(serde_json::Value::Object(Default::default()));
|
||||
let needs_setup = {
|
||||
let completed = auth_claims
|
||||
.get("completed_platform_onboarding")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let is_owner = auth_claims
|
||||
.get("is_org_owner")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
!completed && is_owner
|
||||
};
|
||||
if needs_setup {
|
||||
eprintln!("Only users with Plus or Pro subscriptions can redeem free API credits.");
|
||||
return;
|
||||
}
|
||||
let plan_type = auth_claims
|
||||
.get("chatgpt_plan_type")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
if plan_type != "plus" && plan_type != "pro" {
|
||||
eprintln!("Only users with Plus or Pro subscriptions can redeem free API credits.");
|
||||
return;
|
||||
}
|
||||
|
||||
let api_host = if issuer == DEFAULT_ISSUER {
|
||||
"https://api.openai.com"
|
||||
} else {
|
||||
"https://api.openai.org"
|
||||
};
|
||||
|
||||
let payload = json!({"id_token": id_token});
|
||||
let resp = client
|
||||
.post(format!("{api_host}/v1/billing/redeem_credits"))
|
||||
.json(&payload)
|
||||
.send();
|
||||
match resp {
|
||||
Ok(r) => match r.json::<serde_json::Value>() {
|
||||
Ok(val) => {
|
||||
let granted = val
|
||||
.get("granted_chatgpt_subscriber_api_credits")
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(0);
|
||||
if granted > 0 {
|
||||
let amount = if plan_type == "plus" { "$5" } else { "$50" };
|
||||
eprintln!(
|
||||
"Thanks for being a ChatGPT {} subscriber! If you haven't already redeemed, you should receive {} in API credits.",
|
||||
if plan_type == "plus" { "Plus" } else { "Pro" },
|
||||
amount
|
||||
);
|
||||
} else {
|
||||
eprintln!("It looks like no credits were granted: {}", val);
|
||||
}
|
||||
}
|
||||
Err(_) => {}
|
||||
},
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LoginServerOptions {
|
||||
pub codex_home: PathBuf,
|
||||
pub client_id: String,
|
||||
pub issuer: String,
|
||||
pub port: u16,
|
||||
pub open_browser: bool,
|
||||
pub redeem_credits: bool,
|
||||
pub expose_state_endpoint: bool,
|
||||
/// When set, the server will auto-exit after the specified number of seconds by
|
||||
/// issuing an internal request to a test-only endpoint. Intended for CI/tests.
|
||||
pub testing_timeout_secs: Option<u64>,
|
||||
}
|
||||
|
||||
fn default_url_base(port: u16) -> String { format!("http://127.0.0.1:{port}") }
|
||||
|
||||
pub fn run_local_login_server(codex_home: &Path, client_id: &str) -> std::io::Result<()> {
|
||||
let opts = LoginServerOptions {
|
||||
codex_home: codex_home.to_path_buf(),
|
||||
client_id: client_id.to_string(),
|
||||
issuer: DEFAULT_ISSUER.to_string(),
|
||||
port: DEFAULT_PORT,
|
||||
open_browser: true,
|
||||
redeem_credits: true,
|
||||
expose_state_endpoint: false,
|
||||
testing_timeout_secs: None,
|
||||
};
|
||||
run_local_login_server_with_options(opts)
|
||||
}
|
||||
|
||||
pub fn run_local_login_server_with_options(opts: LoginServerOptions) -> std::io::Result<()> {
|
||||
let addr = format!("127.0.0.1:{}", opts.port);
|
||||
let server = Server::http(&addr)
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?;
|
||||
|
||||
let issuer = opts.issuer.clone();
|
||||
let token_endpoint = format!("{}/oauth/token", issuer);
|
||||
let url_base = default_url_base(opts.port);
|
||||
|
||||
let pkce = generate_pkce();
|
||||
let state = {
|
||||
let mut bytes = [0u8; 32];
|
||||
rand::thread_rng().fill_bytes(&mut bytes);
|
||||
hex::encode(bytes)
|
||||
};
|
||||
|
||||
let redirect_uri = format!("{}/auth/callback", url_base);
|
||||
let mut auth_url = Url::parse(&format!("{}/oauth/authorize", issuer)).unwrap();
|
||||
auth_url.query_pairs_mut()
|
||||
.append_pair("response_type", "code")
|
||||
.append_pair("client_id", &opts.client_id)
|
||||
.append_pair("redirect_uri", &redirect_uri)
|
||||
.append_pair("scope", "openid profile email offline_access")
|
||||
.append_pair("code_challenge", &pkce.code_challenge)
|
||||
.append_pair("code_challenge_method", "S256")
|
||||
.append_pair("id_token_add_organizations", "true")
|
||||
.append_pair("codex_cli_simplified_flow", "true")
|
||||
.append_pair("state", &state);
|
||||
|
||||
eprintln!("Starting local login server on {}", url_base);
|
||||
// Try to open the browser, but ignore failures.
|
||||
if opts.open_browser {
|
||||
let _ = webbrowser::open(auth_url.as_str());
|
||||
}
|
||||
eprintln!(
|
||||
". If your browser did not open, navigate to this URL to authenticate: \n\n{}",
|
||||
auth_url
|
||||
);
|
||||
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.build()
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?;
|
||||
|
||||
// If a testing timeout is configured, schedule an internal exit request so tests don't hang.
|
||||
if let Some(secs) = opts.testing_timeout_secs {
|
||||
let port = opts.port;
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(Duration::from_secs(secs));
|
||||
let _ = reqwest::blocking::get(format!("http://127.0.0.1:{port}/__test/exit"));
|
||||
});
|
||||
}
|
||||
|
||||
// Main request loop
|
||||
'outer: loop {
|
||||
let request = match server.recv() {
|
||||
Ok(r) => r,
|
||||
Err(e) => return Err(std::io::Error::new(std::io::ErrorKind::Other, e.to_string())),
|
||||
};
|
||||
|
||||
// Parse URL path and query
|
||||
let full = request.url().to_string();
|
||||
let (path, query) = match full.split_once('?') {
|
||||
Some((p, q)) => (p.to_string(), Some(q.to_string())),
|
||||
None => (full.clone(), None),
|
||||
};
|
||||
|
||||
match (request.method().clone(), path.as_str()) {
|
||||
(Method::Get, "/success") => {
|
||||
let mut resp = Response::from_string(LOGIN_SUCCESS_HTML)
|
||||
.with_status_code(200);
|
||||
resp.add_header(Header::from_bytes(&b"Content-Type"[..], &b"text/html; charset=utf-8"[..]).unwrap());
|
||||
let _ = request.respond(resp);
|
||||
break 'outer;
|
||||
}
|
||||
(Method::Get, "/__test/exit") => {
|
||||
let _ = request.respond(Response::from_string("bye").with_status_code(200));
|
||||
break 'outer;
|
||||
}
|
||||
// Test-only helper to retrieve the current state, enabled via options.
|
||||
(Method::Get, "/__test/state") if opts.expose_state_endpoint => {
|
||||
let mut resp = Response::from_string(state.clone()).with_status_code(200);
|
||||
resp.add_header(Header::from_bytes(&b"Content-Type"[..], &b"text/plain"[..]).unwrap());
|
||||
let _ = request.respond(resp);
|
||||
}
|
||||
(Method::Get, "/auth/callback") => {
|
||||
// Parse query params
|
||||
let params: HashMap<String, String> = query
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
.split('&')
|
||||
.filter_map(|kv| kv.split_once('='))
|
||||
.filter_map(|(k, v)| {
|
||||
let kk = urlencoding::decode(k).ok()?.into_owned();
|
||||
let vv = urlencoding::decode(v).ok()?.into_owned();
|
||||
Some((kk, vv))
|
||||
})
|
||||
.collect();
|
||||
|
||||
if params.get("state").map(|s| s.as_str()) != Some(state.as_str()) {
|
||||
let _ = request.respond(Response::from_string("State parameter mismatch").with_status_code(400));
|
||||
continue;
|
||||
}
|
||||
let code = match params.get("code").cloned() {
|
||||
Some(c) if !c.is_empty() => c,
|
||||
_ => {
|
||||
let _ = request.respond(Response::from_string("Missing authorization code").with_status_code(400));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// 1) Authorization code -> tokens
|
||||
let token_resp = client
|
||||
.post(&token_endpoint)
|
||||
.form(&[
|
||||
("grant_type", "authorization_code"),
|
||||
("code", code.as_str()),
|
||||
("redirect_uri", redirect_uri.as_str()),
|
||||
("client_id", opts.client_id.as_str()),
|
||||
("code_verifier", pkce.code_verifier.as_str()),
|
||||
])
|
||||
.send();
|
||||
let Ok(token_resp) = token_resp else {
|
||||
let _ = request.respond(Response::from_string("Token exchange failed").with_status_code(500));
|
||||
continue;
|
||||
};
|
||||
let Ok(tokens) = token_resp.json::<CodeExchangeResponse>() else {
|
||||
let _ = request.respond(Response::from_string("Token exchange failed").with_status_code(500));
|
||||
continue;
|
||||
};
|
||||
|
||||
// Extract account_id from id_token claims
|
||||
let id_claims = parse_jwt_claims(&tokens.id_token);
|
||||
let auth_claims = id_claims
|
||||
.get("https://api.openai.com/auth")
|
||||
.cloned()
|
||||
.unwrap_or(serde_json::Value::Object(Default::default()));
|
||||
let account_id = auth_claims
|
||||
.get("chatgpt_account_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
// Parse access token claims to compute redirect target
|
||||
let access_claims = parse_jwt_claims(&tokens.access_token);
|
||||
let access_auth_claims = access_claims
|
||||
.get("https://api.openai.com/auth")
|
||||
.cloned()
|
||||
.unwrap_or(serde_json::Value::Object(Default::default()));
|
||||
let org_id = access_auth_claims.get("organization_id").and_then(|v| v.as_str());
|
||||
let project_id = access_auth_claims.get("project_id").and_then(|v| v.as_str());
|
||||
let completed_onboarding = access_auth_claims
|
||||
.get("completed_platform_onboarding")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let is_org_owner = access_auth_claims
|
||||
.get("is_org_owner")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let plan_type = access_auth_claims
|
||||
.get("chatgpt_plan_type")
|
||||
.and_then(|v| v.as_str());
|
||||
let needs_setup = !completed_onboarding && is_org_owner;
|
||||
|
||||
// 2) Token exchange for API key
|
||||
let today = Utc::now().format("%Y-%m-%d").to_string();
|
||||
let random_id = {
|
||||
let mut bytes = [0u8; 6];
|
||||
rand::thread_rng().fill_bytes(&mut bytes);
|
||||
hex::encode(bytes)
|
||||
};
|
||||
let token_x_resp = client
|
||||
.post(&token_endpoint)
|
||||
.form(&[
|
||||
("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange"),
|
||||
("client_id", opts.client_id.as_str()),
|
||||
("requested_token", "openai-api-key"),
|
||||
("subject_token", tokens.id_token.as_str()),
|
||||
("subject_token_type", "urn:ietf:params:oauth:token-type:id_token"),
|
||||
(
|
||||
"name",
|
||||
format!("Codex CLI [auto-generated] ({today}) [{random_id}]").as_str(),
|
||||
),
|
||||
])
|
||||
.send();
|
||||
let Ok(token_x_resp) = token_x_resp else {
|
||||
let _ = request.respond(Response::from_string("Token exchange failed").with_status_code(500));
|
||||
continue;
|
||||
};
|
||||
let Ok(token_x) = token_x_resp.json::<TokenExchangeResponse>() else {
|
||||
let _ = request.respond(Response::from_string("Token exchange failed").with_status_code(500));
|
||||
continue;
|
||||
};
|
||||
|
||||
// Persist auth.json
|
||||
if let Err(e) = write_auth_file(
|
||||
&opts.codex_home,
|
||||
Some(token_x.access_token.clone()),
|
||||
&tokens.id_token,
|
||||
&tokens.access_token,
|
||||
&tokens.refresh_token,
|
||||
account_id,
|
||||
) {
|
||||
let _ = request.respond(Response::from_string(format!("Unable to persist auth file: {e}")).with_status_code(500));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Best-effort credits redemption
|
||||
if opts.redeem_credits {
|
||||
maybe_redeem_credits(
|
||||
&issuer,
|
||||
&opts.client_id,
|
||||
Some(&tokens.id_token),
|
||||
&tokens.refresh_token,
|
||||
&opts.codex_home,
|
||||
);
|
||||
}
|
||||
|
||||
// Build success URL and redirect
|
||||
let platform_url = if issuer == DEFAULT_ISSUER {
|
||||
"https://platform.openai.com"
|
||||
} else {
|
||||
"https://platform.api.openai.org"
|
||||
};
|
||||
let mut success_url = Url::parse(&format!("{}/success", url_base)).unwrap();
|
||||
if let Some(id_tok) = Some(&tokens.id_token) {
|
||||
success_url.query_pairs_mut().append_pair("id_token", id_tok);
|
||||
}
|
||||
if let Some(org) = org_id { success_url.query_pairs_mut().append_pair("org_id", org); }
|
||||
if let Some(proj) = project_id { success_url.query_pairs_mut().append_pair("project_id", proj); }
|
||||
if let Some(pt) = plan_type { success_url.query_pairs_mut().append_pair("plan_type", pt); }
|
||||
success_url
|
||||
.query_pairs_mut()
|
||||
.append_pair("needs_setup", if needs_setup { "true" } else { "false" })
|
||||
.append_pair("platform_url", platform_url);
|
||||
|
||||
let mut resp = Response::empty(302);
|
||||
resp.add_header(Header::from_bytes(&b"Location"[..], success_url.as_str()).unwrap());
|
||||
let _ = request.respond(resp);
|
||||
}
|
||||
_ => {
|
||||
let _ = request.respond(Response::from_string("Endpoint not supported").with_status_code(404));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// -------- Headless testing helpers (no HTTP server) --------
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HeadlessOutcome {
|
||||
pub success_url: String,
|
||||
pub api_key: Option<String>,
|
||||
}
|
||||
|
||||
pub trait Http {
|
||||
fn post_form(&self, url: &str, form: &[(String, String)]) -> std::io::Result<serde_json::Value>;
|
||||
fn post_json(&self, url: &str, body: &serde_json::Value) -> std::io::Result<serde_json::Value>;
|
||||
}
|
||||
|
||||
pub struct DefaultHttp(Client);
|
||||
impl Default for DefaultHttp {
|
||||
fn default() -> Self {
|
||||
let c = Client::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.build()
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))
|
||||
.unwrap();
|
||||
Self(c)
|
||||
}
|
||||
}
|
||||
impl Http for DefaultHttp {
|
||||
fn post_form(&self, url: &str, form: &[(String, String)]) -> std::io::Result<serde_json::Value> {
|
||||
let resp = self
|
||||
.0
|
||||
.post(url)
|
||||
.form(&form.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect::<Vec<_>>())
|
||||
.send()
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?;
|
||||
let val = resp
|
||||
.json::<serde_json::Value>()
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?;
|
||||
Ok(val)
|
||||
}
|
||||
fn post_json(&self, url: &str, body: &serde_json::Value) -> std::io::Result<serde_json::Value> {
|
||||
let resp = self
|
||||
.0
|
||||
.post(url)
|
||||
.json(body)
|
||||
.send()
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?;
|
||||
let val = resp
|
||||
.json::<serde_json::Value>()
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?;
|
||||
Ok(val)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn process_callback_headless(
|
||||
opts: &LoginServerOptions,
|
||||
expected_state: &str,
|
||||
incoming_state: &str,
|
||||
code_opt: Option<&str>,
|
||||
code_verifier: &str,
|
||||
http: &dyn Http,
|
||||
) -> std::io::Result<HeadlessOutcome> {
|
||||
if incoming_state != expected_state {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"state mismatch",
|
||||
));
|
||||
}
|
||||
let code = code_opt.ok_or_else(|| {
|
||||
std::io::Error::new(std::io::ErrorKind::InvalidInput, "missing authorization code")
|
||||
})?;
|
||||
|
||||
let token_endpoint = format!("{}/oauth/token", opts.issuer);
|
||||
let redirect_uri = format!("{}/auth/callback", default_url_base(opts.port));
|
||||
|
||||
// 1) Code -> tokens
|
||||
let form = vec![
|
||||
("grant_type".to_string(), "authorization_code".to_string()),
|
||||
("code".to_string(), code.to_string()),
|
||||
("redirect_uri".to_string(), redirect_uri.clone()),
|
||||
("client_id".to_string(), opts.client_id.clone()),
|
||||
("code_verifier".to_string(), code_verifier.to_string()),
|
||||
];
|
||||
let tokens_val = http.post_form(&token_endpoint, &form)?;
|
||||
let id_token = tokens_val["id_token"].as_str().unwrap_or("").to_string();
|
||||
let access_token = tokens_val["access_token"].as_str().unwrap_or("").to_string();
|
||||
let refresh_token = tokens_val["refresh_token"].as_str().unwrap_or("").to_string();
|
||||
if id_token.is_empty() || access_token.is_empty() || refresh_token.is_empty() {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::Other,
|
||||
"token exchange failed",
|
||||
));
|
||||
}
|
||||
|
||||
// Extract claims
|
||||
let id_claims = parse_jwt_claims(&id_token);
|
||||
let account_id = id_claims
|
||||
.get("https://api.openai.com/auth")
|
||||
.and_then(|v| v.get("chatgpt_account_id"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
let access_claims = parse_jwt_claims(&access_token);
|
||||
let access_auth = access_claims
|
||||
.get("https://api.openai.com/auth")
|
||||
.cloned()
|
||||
.unwrap_or(serde_json::Value::Object(Default::default()));
|
||||
let org_id = access_auth.get("organization_id").and_then(|v| v.as_str());
|
||||
let project_id = access_auth.get("project_id").and_then(|v| v.as_str());
|
||||
let completed = access_auth
|
||||
.get("completed_platform_onboarding")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let is_owner = access_auth
|
||||
.get("is_org_owner")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let plan_type = access_auth
|
||||
.get("chatgpt_plan_type")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
let needs_setup = !completed && is_owner;
|
||||
|
||||
// 2) Token exchange -> API key
|
||||
let today = Utc::now().format("%Y-%m-%d").to_string();
|
||||
let random_id = {
|
||||
let mut bytes = [0u8; 6];
|
||||
rand::thread_rng().fill_bytes(&mut bytes);
|
||||
hex::encode(bytes)
|
||||
};
|
||||
let exchange_form = vec![
|
||||
(
|
||||
"grant_type".to_string(),
|
||||
"urn:ietf:params:oauth:grant-type:token-exchange".to_string(),
|
||||
),
|
||||
("client_id".to_string(), opts.client_id.clone()),
|
||||
("requested_token".to_string(), "openai-api-key".to_string()),
|
||||
("subject_token".to_string(), id_token.clone()),
|
||||
(
|
||||
"subject_token_type".to_string(),
|
||||
"urn:ietf:params:oauth:token-type:id_token".to_string(),
|
||||
),
|
||||
(
|
||||
"name".to_string(),
|
||||
format!("Codex CLI [auto-generated] ({today}) [{random_id}]").to_string(),
|
||||
),
|
||||
];
|
||||
let exchange_val = http.post_form(&token_endpoint, &exchange_form)?;
|
||||
let api_key = exchange_val["access_token"].as_str().map(|s| s.to_string());
|
||||
|
||||
// Persist auth.json
|
||||
write_auth_file(
|
||||
&opts.codex_home,
|
||||
api_key.clone(),
|
||||
&id_token,
|
||||
&access_token,
|
||||
&refresh_token,
|
||||
account_id,
|
||||
)?;
|
||||
|
||||
// Attempt credit redemption (best-effort)
|
||||
if opts.redeem_credits {
|
||||
let platform_url = if opts.issuer == DEFAULT_ISSUER {
|
||||
"https://api.openai.com"
|
||||
} else {
|
||||
"https://api.openai.org"
|
||||
};
|
||||
let redeem_url = format!("{platform_url}/v1/billing/redeem_credits");
|
||||
let _ = http.post_json(&redeem_url, &json!({"id_token": id_token}));
|
||||
}
|
||||
|
||||
// Build success URL
|
||||
let base = default_url_base(opts.port);
|
||||
let mut success_url = Url::parse(&format!("{base}/success")).unwrap();
|
||||
success_url.query_pairs_mut().append_pair("id_token", &id_token);
|
||||
if let Some(org) = org_id {
|
||||
success_url.query_pairs_mut().append_pair("org_id", org);
|
||||
}
|
||||
if let Some(proj) = project_id {
|
||||
success_url
|
||||
.query_pairs_mut()
|
||||
.append_pair("project_id", proj);
|
||||
}
|
||||
if !plan_type.is_empty() {
|
||||
success_url
|
||||
.query_pairs_mut()
|
||||
.append_pair("plan_type", plan_type);
|
||||
}
|
||||
success_url
|
||||
.query_pairs_mut()
|
||||
.append_pair("needs_setup", if needs_setup { "true" } else { "false" })
|
||||
.append_pair(
|
||||
"platform_url",
|
||||
if opts.issuer == DEFAULT_ISSUER {
|
||||
"https://platform.openai.com"
|
||||
} else {
|
||||
"https://platform.api.openai.org"
|
||||
},
|
||||
);
|
||||
|
||||
Ok(HeadlessOutcome {
|
||||
success_url: success_url.into_string(),
|
||||
api_key,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
199
codex-rs/login/src/success_page.html
Normal file
199
codex-rs/login/src/success_page.html
Normal file
@@ -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>
|
||||
|
||||
|
||||
154
codex-rs/login/tests/headless.rs
Normal file
154
codex-rs/login/tests/headless.rs
Normal file
@@ -0,0 +1,154 @@
|
||||
use codex_login::{process_callback_headless, HeadlessOutcome, LoginServerOptions};
|
||||
use serde_json::json;
|
||||
use std::cell::RefCell;
|
||||
use std::collections::VecDeque;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[derive(Default)]
|
||||
struct MockHttp {
|
||||
forms: RefCell<Vec<(String, Vec<(String, String)>)>>,
|
||||
jsons: RefCell<Vec<(String, serde_json::Value)>>,
|
||||
replies: RefCell<VecDeque<serde_json::Value>>,
|
||||
}
|
||||
|
||||
impl MockHttp {
|
||||
fn queue(&self, val: serde_json::Value) {
|
||||
self.replies.borrow_mut().push_back(val);
|
||||
}
|
||||
}
|
||||
|
||||
impl codex_login::Http for MockHttp {
|
||||
fn post_form(
|
||||
&self,
|
||||
url: &str,
|
||||
form: &[(String, String)],
|
||||
) -> std::io::Result<serde_json::Value> {
|
||||
self.forms
|
||||
.borrow_mut()
|
||||
.push((url.to_string(), form.to_vec()));
|
||||
self.replies
|
||||
.borrow_mut()
|
||||
.pop_front()
|
||||
.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::Other, "no reply"))
|
||||
}
|
||||
|
||||
fn post_json(
|
||||
&self,
|
||||
url: &str,
|
||||
body: &serde_json::Value,
|
||||
) -> std::io::Result<serde_json::Value> {
|
||||
self.jsons
|
||||
.borrow_mut()
|
||||
.push((url.to_string(), body.clone()));
|
||||
self.replies
|
||||
.borrow_mut()
|
||||
.pop_front()
|
||||
.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::Other, "no reply"))
|
||||
}
|
||||
}
|
||||
|
||||
fn make_fake_jwt(payload: serde_json::Value) -> String {
|
||||
use base64::Engine;
|
||||
let header = serde_json::json!({"alg": "none", "typ": "JWT"});
|
||||
let b64 = |b: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b);
|
||||
let header_b64 = b64(&serde_json::to_vec(&header).unwrap());
|
||||
let payload_b64 = b64(&serde_json::to_vec(&payload).unwrap());
|
||||
let signature_b64 = b64(b"sig");
|
||||
format!("{header_b64}.{payload_b64}.{signature_b64}")
|
||||
}
|
||||
|
||||
fn default_opts(tmp: &TempDir) -> LoginServerOptions {
|
||||
LoginServerOptions {
|
||||
codex_home: tmp.path().to_path_buf(),
|
||||
client_id: "test-client".into(),
|
||||
issuer: "http://auth.local".into(),
|
||||
port: 1455,
|
||||
open_browser: false,
|
||||
redeem_credits: true,
|
||||
expose_state_endpoint: false,
|
||||
}
|
||||
}
|
||||
|
||||
// 1) Success flow writes file and returns success URL
|
||||
#[test]
|
||||
fn headless_success_writes_auth_and_url() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let opts = default_opts(&tmp);
|
||||
let http = MockHttp::default();
|
||||
// Code exchange response
|
||||
http.queue(json!({
|
||||
"id_token": make_fake_jwt(json!({"https://api.openai.com/auth": {"chatgpt_account_id": "acc"}})),
|
||||
"access_token": make_fake_jwt(json!({"https://api.openai.com/auth": {"organization_id": "org","project_id": "proj","completed_platform_onboarding": true, "is_org_owner": false, "chatgpt_plan_type": "plus"}})),
|
||||
"refresh_token": "r1"
|
||||
}));
|
||||
// Token-exchange to API key
|
||||
http.queue(json!({"access_token": "sk-xyz"}));
|
||||
// Credits redeem
|
||||
http.queue(json!({"granted_chatgpt_subscriber_api_credits": 5}));
|
||||
|
||||
let outcome = process_callback_headless(&opts, "state", "state", Some("code"), "ver", &http).unwrap();
|
||||
assert!(outcome.success_url.contains("/success"));
|
||||
let contents = std::fs::read_to_string(tmp.path().join("auth.json")).unwrap();
|
||||
let v: serde_json::Value = serde_json::from_str(&contents).unwrap();
|
||||
assert_eq!(v["OPENAI_API_KEY"].as_str(), Some("sk-xyz"));
|
||||
}
|
||||
|
||||
// 2) State mismatch errors
|
||||
#[test]
|
||||
fn headless_state_mismatch() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let opts = default_opts(&tmp);
|
||||
let http = MockHttp::default();
|
||||
let err = process_callback_headless(&opts, "state", "wrong", Some("code"), "ver", &http)
|
||||
.err()
|
||||
.unwrap();
|
||||
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
|
||||
}
|
||||
|
||||
// 3) Missing code errors
|
||||
#[test]
|
||||
fn headless_missing_code() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let opts = default_opts(&tmp);
|
||||
let http = MockHttp::default();
|
||||
let err = process_callback_headless(&opts, "state", "state", None, "ver", &http)
|
||||
.err()
|
||||
.unwrap();
|
||||
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
|
||||
}
|
||||
|
||||
// 4) Token endpoint failure propagates error
|
||||
#[test]
|
||||
fn headless_token_endpoint_failure() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let opts = default_opts(&tmp);
|
||||
let http = MockHttp::default();
|
||||
// no replies queued -> will error
|
||||
let err = process_callback_headless(&opts, "state", "state", Some("code"), "ver", &http)
|
||||
.err()
|
||||
.unwrap();
|
||||
assert_eq!(err.kind(), std::io::ErrorKind::Other);
|
||||
}
|
||||
|
||||
// 5) Credit redemption best-effort: even if it errors, success persists
|
||||
#[test]
|
||||
fn headless_credit_redemption_best_effort() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let mut opts = default_opts(&tmp);
|
||||
opts.redeem_credits = true;
|
||||
let http = MockHttp::default();
|
||||
// Code exchange
|
||||
http.queue(json!({
|
||||
"id_token": make_fake_jwt(json!({"https://api.openai.com/auth": {"chatgpt_account_id": "acc"}})),
|
||||
"access_token": make_fake_jwt(json!({"https://api.openai.com/auth": {"organization_id": "org","project_id": "proj","completed_platform_onboarding": false, "is_org_owner": true, "chatgpt_plan_type": "pro"}})),
|
||||
"refresh_token": "r1"
|
||||
}));
|
||||
// Token exchange -> API key
|
||||
http.queue(json!({"access_token": "sk-xyz"}));
|
||||
// Credits redeem: simulate error by not queuing a third response; the mock will error internally
|
||||
let outcome = process_callback_headless(&opts, "state", "state", Some("code"), "ver", &http).unwrap();
|
||||
assert!(outcome.success_url.contains("needs_setup=true"));
|
||||
assert!(tmp.path().join("auth.json").exists());
|
||||
}
|
||||
|
||||
|
||||
330
codex-rs/login/tests/server.rs
Normal file
330
codex-rs/login/tests/server.rs
Normal file
@@ -0,0 +1,330 @@
|
||||
#![cfg(feature = "http-e2e-tests")]
|
||||
use codex_login::{run_local_login_server_with_options, LoginServerOptions};
|
||||
use base64::Engine;
|
||||
use std::io::Read;
|
||||
use std::net::TcpListener;
|
||||
// use std::path::PathBuf;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn find_free_port() -> u16 {
|
||||
TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port()
|
||||
}
|
||||
|
||||
fn start_mock_oauth_server(port: u16, behavior: MockBehavior) {
|
||||
thread::spawn(move || {
|
||||
let server = tiny_http::Server::http(format!("127.0.0.1:{port}")).unwrap();
|
||||
for mut request in server.incoming_requests() {
|
||||
let url = request.url().to_string();
|
||||
if request.method() == &tiny_http::Method::Post && url.starts_with("/oauth/token") {
|
||||
// Read body
|
||||
let mut body = String::new();
|
||||
request.as_reader().read_to_string(&mut body).ok();
|
||||
let content_type = request
|
||||
.headers()
|
||||
.iter()
|
||||
.find(|h| h.field.equiv("Content-Type"))
|
||||
.map(|h| h.value.as_str().to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
// Parse either x-www-form-urlencoded or JSON
|
||||
let mut form = std::collections::HashMap::<String, String>::new();
|
||||
if content_type.starts_with("application/x-www-form-urlencoded") {
|
||||
for kv in body.split('&') {
|
||||
if let Some((k, v)) = kv.split_once('=') {
|
||||
let k = urlencoding::decode(k).unwrap().into_owned();
|
||||
let v = urlencoding::decode(v).unwrap().into_owned();
|
||||
form.insert(k, v);
|
||||
}
|
||||
}
|
||||
} else if content_type.starts_with("application/json") {
|
||||
let v: serde_json::Value = serde_json::from_str(&body).unwrap_or_default();
|
||||
if let Some(obj) = v.as_object() {
|
||||
for (k, vv) in obj.iter() {
|
||||
form.insert(k.clone(), vv.as_str().unwrap_or_default().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match behavior {
|
||||
MockBehavior::Success => {
|
||||
if form.get("grant_type").map(|s| s.as_str()) == Some("authorization_code") {
|
||||
// Return tokens
|
||||
let id_token = make_fake_jwt(serde_json::json!({
|
||||
"https://api.openai.com/auth": {
|
||||
"chatgpt_account_id": "acc-1",
|
||||
}
|
||||
}));
|
||||
let access_token = make_fake_jwt(serde_json::json!({
|
||||
"https://api.openai.com/auth": {
|
||||
"organization_id": "org-1",
|
||||
"project_id": "proj-1",
|
||||
"completed_platform_onboarding": true,
|
||||
"is_org_owner": false,
|
||||
"chatgpt_plan_type": "plus"
|
||||
}
|
||||
}));
|
||||
let payload = serde_json::json!({
|
||||
"id_token": id_token,
|
||||
"access_token": access_token,
|
||||
"refresh_token": "refresh-1"
|
||||
});
|
||||
let _ = request.respond(
|
||||
tiny_http::Response::from_string(payload.to_string())
|
||||
.with_status_code(200)
|
||||
.with_header(
|
||||
tiny_http::Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]).unwrap(),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// token-exchange → API key
|
||||
let payload = serde_json::json!({
|
||||
"access_token": "sk-test-123"
|
||||
});
|
||||
let _ = request.respond(
|
||||
tiny_http::Response::from_string(payload.to_string())
|
||||
.with_status_code(200)
|
||||
.with_header(
|
||||
tiny_http::Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]).unwrap(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
MockBehavior::TokenError => {
|
||||
let _ = request.respond(tiny_http::Response::from_string("error").with_status_code(500));
|
||||
}
|
||||
}
|
||||
} else if request.method() == &tiny_http::Method::Post
|
||||
&& url.starts_with("/v1/billing/redeem_credits")
|
||||
{
|
||||
let payload = serde_json::json!({"granted_chatgpt_subscriber_api_credits": 5});
|
||||
let _ = request.respond(
|
||||
tiny_http::Response::from_string(payload.to_string())
|
||||
.with_status_code(200)
|
||||
.with_header(
|
||||
tiny_http::Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]).unwrap(),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
let _ = request.respond(tiny_http::Response::from_string("not found").with_status_code(404));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum MockBehavior {
|
||||
Success,
|
||||
TokenError,
|
||||
}
|
||||
|
||||
fn make_fake_jwt(payload: serde_json::Value) -> String {
|
||||
let header = serde_json::json!({"alg": "none", "typ": "JWT"});
|
||||
let b64 = |b: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b);
|
||||
let header_b64 = b64(&serde_json::to_vec(&header).unwrap());
|
||||
let payload_b64 = b64(&serde_json::to_vec(&payload).unwrap());
|
||||
let signature_b64 = b64(b"sig");
|
||||
format!("{header_b64}.{payload_b64}.{signature_b64}")
|
||||
}
|
||||
|
||||
fn http_get(url: &str) -> (u16, String, Option<String>) {
|
||||
let resp = ureq::get(url).call().expect("http get failed");
|
||||
let status = resp.status();
|
||||
let body = resp.into_string().unwrap_or_default();
|
||||
let location = None; // ureq consumed headers in previous call; not needed here
|
||||
(status as u16, body, location)
|
||||
}
|
||||
|
||||
fn http_get_follow_redirect(url: &str) -> (u16, String) {
|
||||
let agent = ureq::AgentBuilder::new().redirects(5).build();
|
||||
match agent.get(url).call() {
|
||||
Ok(resp) => (resp.status(), resp.into_string().unwrap_or_default()),
|
||||
Err(ureq::Error::Status(code, resp)) => (code, resp.into_string().unwrap_or_default()),
|
||||
Err(err) => panic!("http error: {err}"),
|
||||
}
|
||||
}
|
||||
|
||||
// 1) Happy path: writes auth.json and exits after /success
|
||||
#[test]
|
||||
fn login_server_happy_path() {
|
||||
let oauth_port = find_free_port();
|
||||
start_mock_oauth_server(oauth_port, MockBehavior::Success);
|
||||
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
let port = find_free_port();
|
||||
let issuer = format!("http://127.0.0.1:{oauth_port}");
|
||||
|
||||
let opts = LoginServerOptions {
|
||||
codex_home: codex_home.path().to_path_buf(),
|
||||
client_id: "test-client".to_string(),
|
||||
issuer: issuer.clone(),
|
||||
port,
|
||||
open_browser: false,
|
||||
redeem_credits: true,
|
||||
expose_state_endpoint: true,
|
||||
testing_timeout_secs: Some(5),
|
||||
};
|
||||
|
||||
let handle = thread::spawn(move || run_local_login_server_with_options(opts).unwrap());
|
||||
|
||||
// Wait for server to bind
|
||||
wait_for_state_endpoint(port, Duration::from_secs(5));
|
||||
|
||||
// Get state via test-only endpoint
|
||||
let state_url = format!("http://127.0.0.1:{port}/__test/state");
|
||||
let (_s, state, _) = http_get(&state_url);
|
||||
assert!(!state.is_empty());
|
||||
|
||||
// Simulate callback
|
||||
let cb_url = format!("http://127.0.0.1:{port}/auth/callback?code=abc&state={state}");
|
||||
let (status, body) = http_get_follow_redirect(&cb_url);
|
||||
assert_eq!(status, 200);
|
||||
assert!(body.contains("Signed in to Codex CLI"));
|
||||
|
||||
handle.join().unwrap();
|
||||
|
||||
// Verify auth.json written
|
||||
let auth_path = codex_home.path().join("auth.json");
|
||||
let contents = std::fs::read_to_string(&auth_path).unwrap();
|
||||
let v: serde_json::Value = serde_json::from_str(&contents).unwrap();
|
||||
assert_eq!(v["OPENAI_API_KEY"].as_str(), Some("sk-test-123"));
|
||||
assert!(v["tokens"]["id_token"].as_str().is_some());
|
||||
}
|
||||
|
||||
// 2) State mismatch returns 400 and server stays up
|
||||
#[test]
|
||||
fn login_server_state_mismatch() {
|
||||
let oauth_port = find_free_port();
|
||||
start_mock_oauth_server(oauth_port, MockBehavior::Success);
|
||||
let port = find_free_port();
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
let issuer = format!("http://127.0.0.1:{oauth_port}");
|
||||
|
||||
let opts = LoginServerOptions {
|
||||
codex_home: codex_home.path().into(),
|
||||
client_id: "test-client".into(),
|
||||
issuer,
|
||||
port,
|
||||
open_browser: false,
|
||||
redeem_credits: false,
|
||||
expose_state_endpoint: true,
|
||||
testing_timeout_secs: Some(5),
|
||||
};
|
||||
let handle = thread::spawn(move || run_local_login_server_with_options(opts).unwrap());
|
||||
wait_for_state_endpoint(port, Duration::from_secs(5));
|
||||
|
||||
let cb_url = format!("http://127.0.0.1:{port}/auth/callback?code=abc&state=wrong");
|
||||
let (status, body) = http_get_follow_redirect(&cb_url);
|
||||
assert_eq!(status, 400);
|
||||
assert!(body.contains("State parameter mismatch") || body.is_empty());
|
||||
|
||||
// Stop server
|
||||
let _ = ureq::get(&format!("http://127.0.0.1:{port}/success")).call();
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
// 3) Missing code returns 400
|
||||
#[test]
|
||||
fn login_server_missing_code() {
|
||||
let oauth_port = find_free_port();
|
||||
start_mock_oauth_server(oauth_port, MockBehavior::Success);
|
||||
let port = find_free_port();
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
let issuer = format!("http://127.0.0.1:{oauth_port}");
|
||||
let opts = LoginServerOptions {
|
||||
codex_home: codex_home.path().into(),
|
||||
client_id: "test-client".into(),
|
||||
issuer,
|
||||
port,
|
||||
open_browser: false,
|
||||
redeem_credits: false,
|
||||
expose_state_endpoint: true,
|
||||
testing_timeout_secs: Some(5),
|
||||
};
|
||||
let handle = thread::spawn(move || run_local_login_server_with_options(opts).unwrap());
|
||||
wait_for_state_endpoint(port, Duration::from_secs(5));
|
||||
|
||||
// Fetch state
|
||||
let state = ureq::get(&format!("http://127.0.0.1:{port}/__test/state")).call().expect("get state").into_string().unwrap();
|
||||
// Missing code
|
||||
let cb_url = format!("http://127.0.0.1:{port}/auth/callback?state={state}");
|
||||
let (status, _body) = http_get_follow_redirect(&cb_url);
|
||||
assert_eq!(status, 400);
|
||||
let _ = ureq::get(&format!("http://127.0.0.1:{port}/success")).call();
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
// 4) Token endpoint error returns 500 (on code exchange) and server stays up
|
||||
#[test]
|
||||
fn login_server_token_exchange_error() {
|
||||
let oauth_port = find_free_port();
|
||||
start_mock_oauth_server(oauth_port, MockBehavior::TokenError);
|
||||
let port = find_free_port();
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
let issuer = format!("http://127.0.0.1:{oauth_port}");
|
||||
let opts = LoginServerOptions {
|
||||
codex_home: codex_home.path().into(),
|
||||
client_id: "test-client".into(),
|
||||
issuer,
|
||||
port,
|
||||
open_browser: false,
|
||||
redeem_credits: false,
|
||||
expose_state_endpoint: true,
|
||||
testing_timeout_secs: Some(5),
|
||||
};
|
||||
let handle = thread::spawn(move || run_local_login_server_with_options(opts).unwrap());
|
||||
wait_for_state_endpoint(port, Duration::from_secs(5));
|
||||
let state = ureq::get(&format!("http://127.0.0.1:{port}/__test/state")).call().expect("get state").into_string().unwrap();
|
||||
let cb_url = format!("http://127.0.0.1:{port}/auth/callback?code=abc&state={state}");
|
||||
let (status, _body) = http_get_follow_redirect(&cb_url);
|
||||
assert_eq!(status, 500);
|
||||
let _ = ureq::get(&format!("http://127.0.0.1:{port}/success")).call();
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
// 5) Credit redemption errors do not block success
|
||||
#[test]
|
||||
fn login_server_credit_redemption_best_effort() {
|
||||
// Mock behavior success for token endpoints, but have redeem endpoint return 500 by not matching path (using different port)
|
||||
let oauth_port = find_free_port();
|
||||
start_mock_oauth_server(oauth_port, MockBehavior::Success);
|
||||
let port = find_free_port();
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
let issuer = format!("http://127.0.0.1:{oauth_port}");
|
||||
let opts = LoginServerOptions {
|
||||
codex_home: codex_home.path().into(),
|
||||
client_id: "test-client".into(),
|
||||
issuer,
|
||||
port,
|
||||
open_browser: false,
|
||||
redeem_credits: true,
|
||||
expose_state_endpoint: true,
|
||||
testing_timeout_secs: Some(5),
|
||||
};
|
||||
let handle = thread::spawn(move || run_local_login_server_with_options(opts).unwrap());
|
||||
wait_for_state_endpoint(port, Duration::from_secs(5));
|
||||
let state = ureq::get(&format!("http://127.0.0.1:{port}/__test/state")).call().expect("get state").into_string().unwrap();
|
||||
let cb_url = format!("http://127.0.0.1:{port}/auth/callback?code=abc&state={state}");
|
||||
let (status, _body) = http_get_follow_redirect(&cb_url);
|
||||
assert_eq!(status, 200);
|
||||
handle.join().unwrap();
|
||||
// auth.json exists
|
||||
assert!(codex_home.path().join("auth.json").exists());
|
||||
}
|
||||
|
||||
|
||||
fn wait_for_state_endpoint(port: u16, timeout: Duration) {
|
||||
let start = std::time::Instant::now();
|
||||
loop {
|
||||
if start.elapsed() > timeout {
|
||||
panic!("server did not expose __test/state within timeout");
|
||||
}
|
||||
if let Ok(resp) = ureq::get(&format!("http://127.0.0.1:{port}/__test/state")).call() {
|
||||
if resp.status() == 200 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user